diff --git a/apps/control-station/test/sensorEnrollment.test.mjs b/apps/control-station/test/sensorEnrollment.test.mjs index 6ca74f5..be4a659 100644 --- a/apps/control-station/test/sensorEnrollment.test.mjs +++ b/apps/control-station/test/sensorEnrollment.test.mjs @@ -130,3 +130,29 @@ test('a confirmed scan with zero candidates remains a valid empty result',async( })}; assert.deepEqual((await api.enroll(transport,initial,'scan')).candidates,[]); }); + +test('finishing enrollment requires a verified current device session and survives no stale fence',()=>{ + const connected={...initial,connected:true,discovery_generation:3,mode_revision:2, + device_session:{device_session_id:'session-one'},command_result:{status:'succeeded'}}; + assert.equal(api.enrollmentProof(initial,'synthetic-ble'),null); + const proof=api.enrollmentProof(connected,'synthetic-ble'); + assert.ok(proof); + assert.equal(api.enrollmentProofCurrent(proof,connected,'synthetic-ble'),true); + for(const patch of [{fresh:false},{available:false},{connected:false},{runtime_id:'other-runtime'}, + {discovery_generation:4},{mode_revision:3},{selected_device_id:'other-device'}, + {device_session:{device_session_id:'other-session'}},{command_result:{status:'rejected'}}]){ + assert.equal(api.enrollmentProofCurrent(proof,{...connected,...patch},'synthetic-ble'),false); + } + assert.equal(api.enrollmentProofCurrent(null,connected,'synthetic-ble'),false); + assert.equal(api.enrollmentProofCurrent(proof,connected,'other-device'),false); +}); + +test('lost BLE candidate tells the operator to rescan without blaming Ethernet or Wi-Fi credentials',()=>{ + const notice=api.enrollmentNotice({...initial,connection_attempt:{ + schema_version:'missioncore.xgrids-k1-connection-attempt/v1',attempt_id:'test',status:'failed', + public_error_code:'network-provision-candidate-not-fresh', + }}); + assert.match(notice,/Найдите K1 ещё раз/); + assert.match(notice,/Настройки Wi-Fi не были отправлены/); + assert.doesNotMatch(notice,/пароль|Ethernet/); +}); diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 08cc9bf..270d0e7 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.2" +VERSION = "0.8.3" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index 1bb0274..8592e7c 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -15,6 +15,14 @@ case "$1" in systemctl enable mission-core-node.service systemctl restart mission-core-node.service systemctl try-restart mission-core-realsense.service + if [ -f /run/mission-core-node-k1-upgrade-active ]; then + # A jointly upgraded plugin starts itself after its own configuration. + # Restore it here only when that package is already configured. + if [ "$(dpkg-query -W -f='${Status}' mission-core-xgrids-k1 2>/dev/null || true)" = "install ok installed" ]; then + systemctl start mission-core-k1.service + fi + rm -f /run/mission-core-node-k1-upgrade-active + fi fi ;; esac diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index cff2e9e..814de8d 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -28,7 +28,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then esac # End the admitted idle worker before dpkg replaces its Python modules. # New UI commands now fail unavailable instead of racing the package copy. - if [ -f /usr/lib/systemd/system/mission-core-k1.service ]; then + if systemctl is-active --quiet mission-core-k1.service; then + # Preserve an optional worker's prior running state across a Node-only + # update. /run is root-owned; the plugin still owns initial activation. + (umask 077; : > /run/mission-core-node-k1-upgrade-active) systemctl stop mission-core-k1.service fi fi diff --git a/docs/audits/2026-09-07-k1-wireless-flow-r5.md b/docs/audits/2026-09-07-k1-wireless-flow-r5.md new file mode 100644 index 0000000..bc4bc93 --- /dev/null +++ b/docs/audits/2026-09-07-k1-wireless-flow-r5.md @@ -0,0 +1,65 @@ +# Wireless enrollment sequence R5 + +The owner confirmed onboard Bluetooth discovery and requested visible device +rows, no redundant bottom Close button, and no Connect footer before a +successful connection check. The board is connected to its router by Ethernet; +only K1 needs that router's Wi-Fi. LinuxWifiAssociationProbe already represents +Ethernet as a valid not-wifi path with continuity evidence. No host Wi-Fi +association or network switching is added. + +## Observed R4 connection result + +The operation completed at 09:44:26 UTC with action=connect, status=failed, +error_code=network-provision-candidate-not-fresh, phase=network_not_applied and +side_effect_status=none. The selected native BLE handle was no longer usable; +this attempt did not transmit the network settings. The exact preceding event +that invalidated that handle is not established by the available projection. +Do not describe this as a wrong Wi-Fi password, an Ethernet failure or a K1 +station refusal. Private journal inspection without interactive sudo was +unavailable; no authentication bypass was used. + +## Interface sequence + +WirelessEnrollmentWindow keeps the top close action and omits the footer when +there are no contributed actions. The supported-model selector remains first. +The K1 contribution renders discovered devices as canonical ResourceList / +ResourceRow entries with explicit Select actions. Wi-Fi appears after selection. + +Check connection explicitly says it sends the entered Wi-Fi settings to K1 and +checks communication with the board. It uses the existing single provision +intent and observes its owned bootstrap; it does not issue a second command. +The separate read-only state check is reserved for an unconfirmed network +outcome, preserving recovery without another provisioning write. + +Connect appears only after the dialog obtains a successful current connection +proof, including the exact runtime, discovery generation, mode revision, +selected device and device session. Closing with this button finishes the +dialog and refreshes inventory; it does not provision again or create another +backend enrollment phase. A restart, outage, new scan, changed mode/device or +changed session invalidates the displayed completion action. The ordinary +backend session is established by the verified connection itself. + +A candidate-not-fresh result explicitly asks for a new Bluetooth scan and says +that Wi-Fi settings were not sent. The failed selection is cleared and cannot +be reselected from that same invalid generation in the open dialog. No runtime +fence or BLE safety rule was relaxed. The device protocol, optional K1 backend, +Rerun profiles and recovery supervisors are unchanged. + +## Standalone Node upgrade + +Inspection found that Node preinst stopped K1, while Node postinst left it +stopped during a Node-only update. Preinst now records a previously active K1 +worker in a root-owned /run marker; postinst restores it if its optional package +is already configured. A jointly upgraded plugin starts itself after its own +configuration. An inactive plugin is not started by this path. + +The real shell scripts were executed against isolated OS paths and fake OS +commands for active/configured, inactive/configured and active/unpacked cases. +The focused Python suite has 21 passing tests, including Ethernet without a +host Wi-Fi request and retention of a usable native BLE capture after scanning. +The Core frontend suite has 791 passing tests; Core TypeScript and production +build passed. Ruff, shell syntax and diff checks passed. + +Node 0.8.3 is the replacement UI/host package. Installed optional K1 +0.1.2+private.1 is retained. Exact artifact and installation acceptance follows; +a new fresh-cache UI connection, live streams and recovery remain pending. diff --git a/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx index eb00b62..e2936e8 100644 --- a/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx +++ b/packages/sensor-ui/src/WirelessEnrollmentWindow.tsx @@ -1,5 +1,5 @@ import {useState} from 'react'; -import {Button,Select,Window,WindowFooterActions} from '@nodedc/ui-react'; +import {Select,Window,WindowFooterActions} from '@nodedc/ui-react'; import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions'; export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange}:{ @@ -11,7 +11,7 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View; const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>( {actions}}> + footer={actions?{actions}:undefined}>
({value:value.id,label:value.name}))]} - onChange={value=>{setDevice(value);setPassword('');}} disabled={pending}/>} - {selected&&<> + {!!state?.candidates?.length&& + {state.candidates.map(value=>
  • {setDevice(value.id);setPassword('');setVerified(null);}}>{device===value.id?'Выбрано':'Выбрать'}}/>
  • )} +
    } + {selected&&!confirmed&&<> :undefined} onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}}/> @@ -107,9 +118,14 @@ export function DeviceEnrollmentWindow({transport,onChange,renderWindow}:SensorE onChange={value=>{setNetwork(value);if(value!=='manual')setSSID(networks[Number(value)].ssid);setPassword('');}} disabled={pending}/>} {setSSID(event.target.value);setNetwork('manual');}} disabled={pending} autoComplete="off"/> setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/> - + :undefined} + onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}}/> + {attempt&&attempt.phase==='network_outcome_unknown'&&enrollmentAllowed(state,'verify')&& + } } } {notice&&} diff --git a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts index ba763cb..63204bf 100644 --- a/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts +++ b/plugins/xgrids-k1/frontend/src/sensors/enrollment.ts @@ -115,6 +115,7 @@ export function enrollmentNotice(state:EnrollmentState):string { return attempt.phase==='network_applied'?'K1 подключился к Wi-Fi. Проверяем канал управления.':'Подключаем K1 к выбранной сети Wi-Fi.'; } const code=attempt?.public_error_code??state.command_result?.error_code; + if(code==='network-provision-candidate-not-fresh')return 'Результат Bluetooth-поиска больше недоступен. Найдите K1 ещё раз и выберите его из списка. Настройки Wi-Fi не были отправлены.'; 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 подключён к БК.'; @@ -124,6 +125,25 @@ export function enrollmentNotice(state:EnrollmentState):string { return ''; } +export interface EnrollmentProof { + runtime:string; generation:number|undefined; modeRevision:number|undefined; + device:string; session:string; +} + +export function enrollmentProof(state:EnrollmentState,device:string):EnrollmentProof|null { + if(!state.available||state.fresh===false||!state.connected||!state.runtime_id|| + state.selected_device_id!==device||!state.device_session?.device_session_id|| + ['failed','rejected'].includes(state.command_result?.status??''))return null; + return {runtime:state.runtime_id,generation:state.discovery_generation,modeRevision:state.mode_revision, + device,session:state.device_session.device_session_id}; +} + +export function enrollmentProofCurrent(proof:EnrollmentProof|null,state:EnrollmentState|null,device:string):boolean { + if(!proof||!state)return false; + const current=enrollmentProof(state,device); + return !!current&&Object.entries(proof).every(([key,value])=>current[key as keyof EnrollmentProof]===value); +} + export function bridgeFormValid(ssid:string,password:string):boolean{ const bytes=new TextEncoder();return bytes.encode(ssid).length>0&&bytes.encode(ssid).length<=32&&bytes.encode(password).length>0&&bytes.encode(password).length<=64; } diff --git a/tests/test_node_k1_bridge.py b/tests/test_node_k1_bridge.py index ecc996a..75709cb 100644 --- a/tests/test_node_k1_bridge.py +++ b/tests/test_node_k1_bridge.py @@ -165,6 +165,8 @@ def test_node_scan_real_service_preserves_operation_identity_and_replay( "action": "scan", "parameters": {}, } result = await device.deliver(command) + if not radio_failure: + assert scanner.capture_discovered_device("AA:BB:CC:DD:EE:FF") is not None repeated = await device.deliver(command) assert len(calls) == 1 assert result["discovery_generation"] == 1 @@ -307,6 +309,21 @@ def test_networkmanager_ssids_are_not_split_at_escaped_colons(): assert nm_fields(r"field\:network\\name:88:WPA2") == ["field:network\\name", "88", "WPA2"] +def test_wired_board_bridge_does_not_require_host_wifi_association(tmp_path, monkeypatch): + from k1link.device_plugins.xgrids_k1 import linux_host + + (tmp_path / "enp1s0").mkdir() + + def forbidden(*_args, **_kwargs): + raise AssertionError("Ethernet must not request Wi-Fi association") + + monkeypatch.setattr(linux_host, "_run", forbidden) + value = linux_host.LinuxWifiAssociationProbe(tmp_path).observe(interface_name="enp1s0") + assert value["association_state"] == "not-wifi" + assert value["continuity_proven"] is True + assert value["reason_code"] is None + + 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 diff --git a/tests/test_node_k1_package_lifecycle.py b/tests/test_node_k1_package_lifecycle.py new file mode 100644 index 0000000..8bbb145 --- /dev/null +++ b/tests/test_node_k1_package_lifecycle.py @@ -0,0 +1,56 @@ +"""Execute the real maintainer scripts with isolated OS paths and commands.""" + +import os +import subprocess +from pathlib import Path + +import pytest + +PACKAGING = Path(__file__).resolve().parents[1] / "apps/node-agent/packaging" + + +@pytest.mark.parametrize("active,configured", [(True, True), (False, True), (True, False)]) +def test_node_update_restores_only_previously_running_configured_plugin( + tmp_path, active, configured, +): + binary = tmp_path / "bin" + binary.mkdir() + run = tmp_path / "run" + (run / "systemd/system").mkdir(parents=True) + release = tmp_path / "os-release" + release.write_text("ID=ubuntu\nVERSION_ID=24.04\n") + state = tmp_path / "active" + if active: + state.touch() + events = tmp_path / "events" + systemctl = binary / "systemctl" + systemctl.write_text('''#!/bin/sh +printf '%s\\n' "$*" >> "$TEST_EVENTS" +case "$1" in + is-active) [ -f "$TEST_ACTIVE" ]; exit $? ;; + show) echo inactive ;; + stop) rm -f "$TEST_ACTIVE" ;; + start) touch "$TEST_ACTIVE" ;; +esac +''') + (binary / "getent").write_text("#!/bin/sh\nexit 0\n") + (binary / "dpkg-query").write_text( + "#!/bin/sh\necho 'install ok " + ("installed" if configured else "unpacked") + "'\n" + ) + for file in binary.iterdir(): + file.chmod(0o700) + env = dict(os.environ, PATH=str(binary) + os.pathsep + os.environ["PATH"], + TEST_EVENTS=str(events), TEST_ACTIVE=str(state)) + for name, args in [("preinst", ["upgrade", "0.8.2"]), ("postinst", ["configure", "0.8.2"])]: + script = tmp_path / name + script.write_text((PACKAGING / name).read_text() + .replace("/run/", str(run) + "/") + .replace("/etc/os-release", str(release))) + subprocess.run(["/bin/sh", str(script), *args], env=env, check=True, + capture_output=True, text=True) + if name == "preinst": + assert not state.exists() + calls = events.read_text().splitlines() + assert ("start mission-core-k1.service" in calls) is (active and configured) + assert state.exists() is (active and configured) + assert not (run / "mission-core-node-k1-upgrade-active").exists()