diff --git a/apps/control-station/test/sensorEnrollment.test.mjs b/apps/control-station/test/sensorEnrollment.test.mjs index 94582ae..fbc5afa 100644 --- a/apps/control-station/test/sensorEnrollment.test.mjs +++ b/apps/control-station/test/sensorEnrollment.test.mjs @@ -88,6 +88,17 @@ test('Bluetooth failure before dispatch asks for discovery without blaming Wi-Fi assert.equal(api.enrollmentBluetoothFailure(unknown),false); assert.doesNotMatch(api.enrollmentNotice(unknown),/не были отправлены/); }); +test('applied and restored network states admit read-only verification without credentials',async()=>{ + const f=fixture(); + const applied={...initial,allowed_actions:['observe-configured-device-network'],connection_attempt:{schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'previous',status:'failed',stage:'control-bootstrap-failed',phase:'network_applied',side_effect_status:'applied',public_error_code:'TypeError'}}; + assert.equal(api.enrollmentAllowed(applied,'verify'),true); + assert.match(api.enrollmentNotice(applied),/Проверить состояние K1/); + assert.equal(api.enrollmentAllowed({...applied,connection_attempt:null},'verify'),true); + await api.enroll(f.transport,applied,'verify',{device_id:'synthetic-ble'},f.observer); + assert.equal(f.request().action,'verify'); + assert.deepEqual(f.request().parameters,{device_id:'synthetic-ble'}); + assert.equal(f.counts().posts,1); +}); test('new workflow content scrolls only the containing viewport without stealing focus',()=>{ const original={document:globalThis.document,window:globalThis.window,getComputedStyle:globalThis.getComputedStyle}; const calls=[];let reduced=false; diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index 0c53aaa..d9a1bd8 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -11,7 +11,7 @@ import sys from build_deb import build, VERSION, BRAND_SHA256 ROOT = Path(__file__).resolve().parents[1] -DG_COMMIT = "1bdfc6c24072d38cc1068086ea271c444f2524ad" +DG_COMMIT = "5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc" def guideline_sources(): diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 2806c6d..f20be49 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,7 +11,7 @@ import sys ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.8.4" +VERSION = "0.8.5" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/docs/audits/2026-09-07-k1-linux-route-contract-r7.md b/docs/audits/2026-09-07-k1-linux-route-contract-r7.md new file mode 100644 index 0000000..9f88424 --- /dev/null +++ b/docs/audits/2026-09-07-k1-linux-route-contract-r7.md @@ -0,0 +1,61 @@ +# Applied Wi-Fi and Linux control-path contract R7 + +The owner UI attempt on R6 confirmed K1 station Wi-Fi with an assigned private +address. The network operation succeeded; its owned read-only control bootstrap +then failed with TypeError. The terminal attempt is network_applied, +side_effect_status=applied, stage=control-bootstrap-failed. This differs from +the preceding pre-dispatch Bluetooth failure. A repeat provisioning write is +not the appropriate next action. + +## Reproduced implementation defect + +HostWifiAssociationProbe defines observe(interface_name, *, timeout_seconds). +The real service _sample_host_path calls that positional interface contract. +LinuxWifiAssociationProbe instead declared interface_name keyword-only. +Consequently every real Linux service path observation raised TypeError before +examining Ethernet/Wi-Fi or probing the K1 control endpoint. The earlier adapter +test called observe by keyword and therefore missed this consumer boundary. + +A new test invokes the actual service host-path and correlated TCP observation +with the real Linux adapter and synthetic OS route/sysfs/TCP endpoints. Before +the fix it reproduces TypeError at facade.py:_sample_host_path. The Linux adapter +now accepts the existing protocol signature. Stable Ethernet reaches a single +TCP probe without nmcli or host association; a route change across that probe +still fails closed. The caller, route fingerprints, supervisor, Wi-Fi protocol, +application authority and MQTT/Rerun profiles are unchanged. + +## Operator continuation and messages + +The modal previously displayed read-only verification only for +network_outcome_unknown, leaving an applied network without its prescribed +next action. It now exposes Check K1 state whenever the backend permits that +read-only action for the explicitly selected device, including an applied or +restored configured network. No credentials are required and the existing +verification contract remains fenced by runtime/mode/discovery/target. +The ordinary check still submits one explicit provisioning intent; final +Connect still only completes a currently verified dialog. + +Messages in the modal use SettingsCard align=center. The canonical Design +Guideline change is 5b882bc3d9b13a86e6c26111ef5ded687ea3a2fc, including registry, +documentation and a catalog example. Centered cards omit an empty body and +center their content on both axes; ordinary settings groups keep their existing +start layout. No local CSS override, fixed height, new colors or decoration. +Node packaging pins this exact DG source. + +## Validation and pending physical acceptance + +The failing reproduction is retained privately. After the fix: 18 NodeBridge / +package lifecycle checks passed, four applied-network bootstrap lifecycle checks +passed, 23 focused frontend/architecture checks passed and 794 full Core +frontend tests passed. Changed Linux/backend files pass Ruff. DG ui-react build, +catalog typecheck and registry validation passed. Production package and +installation results follow separately. + +Node 0.8.5 and K1 0.1.4 are reserved for this source. The next owner UI test must +clear browser cache, select K1 and use read-only Check K1 state to observe the +already applied network. It must prove DeviceInfo/control readiness before +claiming Bridge or live acceptance. Private screenshot, exact operation, +UTC/monotonic manifest and hashed logs are retained under +private/acceptance/k1-node085-20260907-core-ui. No agent CLI BLE/MQTT commands +were sent. Publication of prior installation reports remains blocked by the +earlier automatic review decision; no remote push workaround is used. diff --git a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx index 2dda3d6..e5eb9ef 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx +++ b/plugins/xgrids-k1/frontend/src/sensors/DeviceEnrollmentWindow.tsx @@ -115,14 +115,14 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow} : {ready?'БК доступен':'Подключение недоступно'}}/> - {busy!=='loading'&&!ready?:ready&&<> :undefined} onClick={()=>void run('scan')}>{busy==='scan'?'Ищем K1':'Найти K1'}}>
- {scanned&&!state?.candidates?.length&&} {!!state?.candidates?.length&& {state.candidates.map(value=>
  • {setDevice(value.id);setPassword('');setVerified(null);show('wifi');}}>{device===value.id?'Выбрано':'Выбрать'}}/>
  • )}
    }
    - {bluetoothFailure&&
    } + {bluetoothFailure&&
    }
    {selected&&
    :undefined} onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'} - {attempt&&attempt.phase==='network_outcome_unknown'&&enrollmentAllowed(state,'verify')&& + {enrollmentAllowed(state,'verify')&& } } - {notice&&!bluetoothFailure&&
    } + {notice&&!bluetoothFailure&&
    }
    } } - {notice&&!selected&&!bluetoothFailure&&
    } + {notice&&!selected&&!bluetoothFailure&&
    } setError('')}/> , }); diff --git a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts index 877066a..5a838f2 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts @@ -120,7 +120,7 @@ export function enrollmentNotice(state:EnrollmentState):string { if(state.command_result?.status==='rejected')return 'Выбранное устройство или сеанс изменились. Обновите сведения и выберите K1 заново.'; if(code&&STATION_WIFI_FAILURE_MESSAGES[code])return STATION_WIFI_FAILURE_MESSAGES[code]; if(state.connected)return 'K1 подключён к БК.'; - if(attempt?.phase==='network_applied')return 'Настройки Wi-Fi применены. Связь с K1 пока не подтверждена; проверьте текущее подключение.'; + if(attempt?.phase==='network_applied')return 'K1 подключился к Wi-Fi. Связь с БК пока не подтверждена. Нажмите «Проверить состояние K1»; повторно вводить сеть и пароль не нужно.'; if(attempt?.phase==='network_outcome_unknown')return unknownResult; if(attempt?.status==='failed'||state.command_result?.status==='failed')return 'Подключение не завершено. Проверьте состояние K1 и выбранную сеть.'; return ''; diff --git a/plugins/xgrids-k1/packaging/build_deb.py b/plugins/xgrids-k1/packaging/build_deb.py index 2f87141..2351da7 100644 --- a/plugins/xgrids-k1/packaging/build_deb.py +++ b/plugins/xgrids-k1/packaging/build_deb.py @@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402 from debian import package # noqa: E402 from runtime_payload import files as runtime_files # noqa: E402 -VERSION = "0.1.3" +VERSION = "0.1.4" RESOURCES = ( "plugins/xgrids-k1/profile_loader.py", "plugins/xgrids-k1/plugin.manifest.json", diff --git a/src/k1link/device_plugins/xgrids_k1/linux_host.py b/src/k1link/device_plugins/xgrids_k1/linux_host.py index fad2323..73184f9 100644 --- a/src/k1link/device_plugins/xgrids_k1/linux_host.py +++ b/src/k1link/device_plugins/xgrids_k1/linux_host.py @@ -105,7 +105,7 @@ class LinuxWifiAssociationProbe: self.sys_net = sys_net self.key = secrets.token_bytes(32) - def observe(self, *, interface_name: str | None, timeout_seconds: float = 30) -> dict: + def observe(self, interface_name: str | None, *, timeout_seconds: float = 30) -> dict: result = { "schema_version": 1, "adapter": "linux-networkmanager", diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py index e59bf93..3f26e36 100644 --- a/tests/test_node_k1_bridge.py +++ b/tests/test_node_k1_bridge.py @@ -351,6 +351,58 @@ def test_wired_board_bridge_does_not_require_host_wifi_association(tmp_path, mon assert value["reason_code"] is None +@pytest.mark.parametrize("route_changes", [False, True]) +def test_wired_board_real_service_correlates_route_and_control_endpoint( + tmp_path, monkeypatch, route_changes, +): + from dataclasses import replace + + from k1link.device_plugins.xgrids_k1 import facade, linux_host + + sys_net = tmp_path / "sys-net" + (sys_net / "enp1s0").mkdir(parents=True) + probe = linux_host.LinuxWifiAssociationProbe(sys_net) + service = facade.XgridsK1CompatibilityService( + tmp_path / "runtime", host_wifi_association_probe=probe, + ) + path = facade.HostPathProbeResult( + available=True, fingerprint="synthetic-route", interface="enp1s0", + source_ipv4="192.168.1.2", route_class="direct", reason_code=None, + ) + monkeypatch.setattr(facade, "_inspect_host_path", lambda _target: path) + calls = [] + + def tcp(_target): + calls.append("tcp") + if route_changes: + monkeypatch.setattr(facade, "_inspect_host_path", lambda _target: replace( + path, fingerprint="new-route", source_ipv4="192.168.1.3", + )) + return True + + monkeypatch.setattr(facade, "_control_endpoint_reachable", tcp) + + def forbidden(*_args, **_kwargs): + raise AssertionError("Wired control bootstrap must not request host Wi-Fi") + + monkeypatch.setattr(linux_host, "_run", forbidden) + try: + observed = service._sample_host_path("192.168.1.7") + assert observed.available is True + assert observed.interface == "enp1s0" + assert observed.reason_code is None + assert observed.fingerprint != path.fingerprint + assert service._sample_host_path("192.168.1.7").fingerprint == observed.fingerprint + endpoint = service._probe_control_endpoint("192.168.1.7") + assert calls == ["tcp"] + assert endpoint.reachable is (not route_changes) + assert endpoint.reason_code == ( + "host-path-changed-during-tcp-probe" if route_changes else None + ) + finally: + service.close() + + def test_linux_kernel_route_is_matched_route_not_resolved_host(monkeypatch): from k1link.device_plugins.xgrids_k1 import linux_host from k1link.device_plugins.xgrids_k1.facade import _classify_host_route