From ff5b07752c9f4323eee3ed695d9c3803b3e3af2a Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:35:49 -0400 Subject: [PATCH 1/2] NET-04C: add rollback-bounded Wi-Fi profile connect --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 16 +-- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 13 ++ docs/DEVELOPMENT.md | 19 +++ docs/SECURITY.md | 9 ++ static/build/.tmp_update/bin/bloom-network | 16 +++ .../.tmp_update/bin/bloom-network-transaction | 124 +++++++++++++++++- static/build/.tmp_update/bin/bloom-wifi | 6 + static/build/.tmp_update/bin/bloomctl | 2 +- test/shell/bloom_device.bats | 6 + test/shell/network_status.bats | 17 +++ test/shell/network_transaction.bats | 97 +++++++++++++- test/shell/wifi_backend.bats | 14 ++ tools/bloom-device | 7 +- 15 files changed, 330 insertions(+), 20 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 8c58a55f6..8d159390d 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 332 file_count = 2131 -byte_count = 314669717 +byte_count = 314676226 source_build_components = 228 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 7cbf86f05..1c7b252c7 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 8583, - "tree_sha256": "ee0dd293f4c6e56efd7c853203db60209d116b8f8858a51aaa0734019f11662b", + "byte_count": 9428, + "tree_sha256": "6b0235ded2cd16f8134dc85b4825066213d31259c41e142b1380f0158a1f4917", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 27658, - "tree_sha256": "96398c18ceb12113691600ebdf18de86ff8a1dc96022ecaa9d0111f851495efe", + "byte_count": 33007, + "tree_sha256": "cbe6b067187a9d20d9cb62efe389323933c7f6111002af706c5252fd1bc25260", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -982,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 9401, - "tree_sha256": "d7dd29d8ae55b7d5f2e8b2febf1bfc318dd60f389e7bab0fe59da12e14f3d7ab", + "byte_count": 9695, + "tree_sha256": "a9dcd950bf0cd94da139656db9ff29db4ab40889a64cd6d5309308e3abc67ea6", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -1021,8 +1021,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloomctl", "file_count": 1, - "byte_count": 35351, - "tree_sha256": "db5fa1c268ed71f098c626f80d29edb2f21896469262a7c2acfaeb49e48dad02", + "byte_count": 35372, + "tree_sha256": "653222365d8f06ca4bae9a750525e04649991943afab1701eb6a0f6919a6578d", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 2685682ea..82211ea59 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,7 +23,7 @@ observable properties and never substitutes for physical UX review. | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | -| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; unqualified remote Wi-Fi disable rejected before state or radio mutation; legacy Wi-Fi-disable recovery; apply-failure, explicit, timeout, and boot-interruption restoration; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; exact mode-0600 station snapshot in a mode-0700 JFFS2-private root with only its digest in public metadata; nonce digest; monotonic detached watchdog; one bounded mutation; guarded profile projection and explicit station restart; association/global-IPv4 deadline; corrupt-snapshot denial; apply-failure, explicit, timeout, and boot-interruption exact restoration; unqualified remote Wi-Fi disable rejected before state or radio mutation; legacy Wi-Fi-disable recovery; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | ## Task-based UX certification diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6456e8f6e..60e49cad1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -310,6 +310,19 @@ and classify only explicit open, WPA2-PSK, or unsupported security. A caller can cancel active work and explicitly discard any terminal result. The scanner does not change a profile, preference, association, DHCP client, or listener. +Profile association reuses the one guarded network-transaction authority rather +than adding a second recovery mechanism. `begin` copies the exact current +supplicant file into a mode-0700 JFFS2-private transaction root, records only +its SHA-256 in public transaction metadata, and arms the existing durable +watchdog before `wifi-profile-connect ID` may project a stored profile. The +canonical `bloom-network` owner then requests an explicit `bloom-wifi` station +restart and waits only until the monotonic transaction deadline for association +and a global IPv4 address. Apply failure, explicit rollback, timeout, and boot +recovery verify the private snapshot, atomically restore it, restart the station +lifecycle, and recheck services and listeners. Confirmation still requires a +fresh host-key-verified SSH connection; neither status nor evidence exposes a +profile, SSID, credential, address, nonce, or snapshot content. + Listener reconciliation is event-driven at existing fixed boundaries: boot, Wi-Fi reconciliation, Wi-Fi preference changes, remote-key publication, and each listener preference request. A request publishes its desired boolean and diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4b68ba2c8..38aa8072d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -314,6 +314,25 @@ timeout, backend failure, oversized output, duplicate SSIDs, security classification, unsafe paths, stale/live locks, rate limiting, and bounded history. This slice performs no connect or forget transition. +The first connect transition is deliberately routed through the existing +network transaction and its independent-confirmation workflow: + +```sh +tools/bloom-device network-begin bloom-plus 120 +tools/bloom-device network-apply bloom-plus NONCE wifi-profile-connect PROFILE_ID +tools/bloom-device network-confirm bloom-plus NONCE +``` + +`PROFILE_ID` is a bounded non-secret handle; profile data remains in the +private store. Begin must preserve the exact current supplicant bytes in the +private JFFS2 transaction root before apply. A dropped apply connection is not +success: wait for a fresh independent connection and confirm only after +association, global IPv4, key-only SSH, listener audit, and aggregate health +all pass. Otherwise let the watchdog restore the exact prior configuration or +invoke `network-rollback` from an independently safe path. Never exercise this +transition over the only unattended device route until its exact candidate has +passed the host fault matrix and a local synthetic device fixture. + An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable `.bloom/update` state. After device checks pass, developers can promote the diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3f85cf0e8..5de32b6fc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -75,6 +75,15 @@ and the expected active overlay. The host never confirms persistent Wi-Fi or SSH disable operations over their sole route. Public status and evidence omit addresses, SSIDs, profiles, public-key material, and nonce material. +Wi-Fi profile rollback material is credential-bearing and therefore never +resides beside public SD-card transaction metadata. The guarded transaction +stores only a SHA-256 there and keeps the exact mode-0600 snapshot beneath the +mode-0700 JFFS2 private network root. Restore rejects links, changed snapshots, +unsafe parents, occupied candidates, and hash mismatches before replacing the +live supplicant file. Profile IDs are non-secret handles, but status, health, +logs, screenshots, PR evidence, and remote diagnostics still omit profile +contents, SSIDs, credentials, addresses, nonces, and snapshot bytes. + Updates and packages must be staged and verified with at least SHA-256; release signing is preferred. Downloaded code must never be executed through a `curl | sh` pattern. Logs and support bundles must redact credentials, tokens, and private keys. The removable SD card is VFAT and cannot enforce Unix secret permissions. diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index 30ac2c740..f37070182 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -29,6 +29,10 @@ preference_result() { "$1" "$2" "$3" "$4" } +reconnect_result() { + printf '{"schema":1,"service":"bloom-network","operation":"reconnect","applied":%s,"state":"%s"}\n' "$1" "$2" +} + wifi_capability() { [ -x "$PLATFORM" ] || return 1 wifi=$($PLATFORM capability-status network.wifi_station 2>/dev/null) || return 1 @@ -200,9 +204,21 @@ set_preference() { preference_result "$operation" true true applied } +reconnect() { + wifi=$(wifi_capability) || { reconnect_result false platform_unavailable; return 1; } + [ "$wifi" = true ] || { reconnect_result false no_network_hardware; return 1; } + [ -x "$BACKEND" ] || { reconnect_result false backend_unavailable; return 1; } + preference=$(wifi_preference) || { reconnect_result false wifi_state_unavailable; return 1; } + [ "$preference" = 1 ] || { reconnect_result false wifi_disabled; return 1; } + "$BACKEND" restart >/dev/null 2>&1 || { reconnect_result false backend_failed; return 1; } + reconcile_services || { reconnect_result false remote_services_failed; return 1; } + reconnect_result true applied +} + case "$#:${1:-}:${2:-}" in 1:status:) status ;; 2:request:reconcile) reconcile ;; + 2:request:reconnect) reconnect ;; 2:request:enable) set_preference enable ;; 2:request:disable) set_preference disable ;; *) result false false false false invalid_arguments; exit 2 ;; diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index a197e7c1c..80f4f8aa9 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -7,6 +7,8 @@ SYSTEM_ROOT="$SD_ROOT/.tmp_update" TX_ROOT="$SD_ROOT/.bloom/network-transaction" STATE="$TX_ROOT/state.json" PRIOR="$TX_ROOT/prior.json" +PRIVATE_ROOT=${BLOOM_NETWORK_TRANSACTION_PRIVATE_ROOT:-$ROOT/appconfigs/bloom-network-transaction} +PRIOR_CONFIG="$PRIVATE_ROOT/prior-wpa_supplicant.conf" LEASE="$TX_ROOT/recovery-lease.json" LAST="$TX_ROOT/last.json" LOCK="$TX_ROOT/.lock" @@ -24,9 +26,12 @@ DEV_DEPLOY=${BLOOM_DEV_DEPLOY_BIN:-$SYSTEM_ROOT/bin/bloom-dev-deploy} LISTENER_AUDIT=${BLOOM_LISTENER_AUDIT_BIN:-$SYSTEM_ROOT/bin/bloom-listener-audit} PLATFORM=${BLOOM_PLATFORM_BIN:-$SYSTEM_ROOT/bin/bloom-platform} SSH_HELPER=${BLOOM_SSH_HELPER_BIN:-$SYSTEM_ROOT/bin/bloom-dev-ssh} +WIFI_PROFILES=${BLOOM_WIFI_PROFILES_BIN:-$SYSTEM_ROOT/bin/bloom-wifi-profiles} SHADOW_PORT=${BLOOM_SSH_SHADOW_PORT:-2222} NOHUP=${BLOOM_NOHUP_BIN:-nohup} SLEEP=${BLOOM_TRANSACTION_SLEEP_BIN:-sleep} +SYNC=${BLOOM_TRANSACTION_SYNC_BIN:-sync} +SHA256SUM=${BLOOM_TRANSACTION_SHA256_BIN:-sha256sum} SELF=${BLOOM_NETWORK_TRANSACTION_SELF:-$SYSTEM_ROOT/bin/bloom-network-transaction} LOCKED=0 @@ -43,10 +48,69 @@ trap unlock EXIT HUP INT TERM lock() { mkdir -p "$TX_ROOT" || fail "cannot create transaction root" [ ! -L "$TX_ROOT" ] || fail "transaction root is unsafe" + chmod 700 "$TX_ROOT" || fail "cannot protect transaction root" mkdir "$LOCK" 2>/dev/null || fail "a network transaction operation is busy" LOCKED=1 } +file_sha256() { + command -v "$SHA256SUM" >/dev/null 2>&1 || return 1 + "$SHA256SUM" "$1" 2>/dev/null | awk '{print $1}' +} + +ensure_private_root() { + private_parent=${PRIVATE_ROOT%/*} + [ -n "$private_parent" ] || private_parent=/ + [ -d "$private_parent" ] && [ ! -L "$private_parent" ] || return 1 + if [ -e "$PRIVATE_ROOT" ] || [ -L "$PRIVATE_ROOT" ]; then + [ -d "$PRIVATE_ROOT" ] && [ ! -L "$PRIVATE_ROOT" ] || return 1 + else + mkdir "$PRIVATE_ROOT" || return 1 + fi + chmod 700 "$PRIVATE_ROOT" +} + +snapshot_wpa_config() { + ensure_private_root || return 1 + [ -f "$WPA_CONFIG" ] && [ ! -L "$WPA_CONFIG" ] && [ -s "$WPA_CONFIG" ] || return 1 + [ ! -e "$PRIOR_CONFIG.tmp.$$" ] && [ ! -L "$PRIOR_CONFIG.tmp.$$" ] || return 1 + before="$(file_sha256 "$WPA_CONFIG")" || return 1 + case "$before" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#before}" -eq 64 ] || return 1 + (umask 077; cp -p "$WPA_CONFIG" "$PRIOR_CONFIG.tmp.$$") || return 1 + chmod 600 "$PRIOR_CONFIG.tmp.$$" || { rm -f "$PRIOR_CONFIG.tmp.$$"; return 1; } + copied="$(file_sha256 "$PRIOR_CONFIG.tmp.$$")" || { rm -f "$PRIOR_CONFIG.tmp.$$"; return 1; } + after="$(file_sha256 "$WPA_CONFIG")" || { rm -f "$PRIOR_CONFIG.tmp.$$"; return 1; } + [ "$before" = "$copied" ] && [ "$before" = "$after" ] || { rm -f "$PRIOR_CONFIG.tmp.$$"; return 1; } + mv -f "$PRIOR_CONFIG.tmp.$$" "$PRIOR_CONFIG" || { rm -f "$PRIOR_CONFIG.tmp.$$"; return 1; } + "$SYNC" || return 1 + printf '%s\n' "$before" +} + +restore_wpa_config() { + expected=$1 + case "$expected" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#expected}" -eq 64 ] || return 1 + ensure_private_root || return 1 + [ -f "$PRIOR_CONFIG" ] && [ ! -L "$PRIOR_CONFIG" ] || return 1 + [ "$(file_sha256 "$PRIOR_CONFIG")" = "$expected" ] || return 1 + config_parent=${WPA_CONFIG%/*} + [ -n "$config_parent" ] || config_parent=/ + [ -d "$config_parent" ] && [ ! -L "$config_parent" ] || return 1 + if [ -e "$WPA_CONFIG" ] || [ -L "$WPA_CONFIG" ]; then + [ -f "$WPA_CONFIG" ] && [ ! -L "$WPA_CONFIG" ] || return 1 + fi + candidate="$WPA_CONFIG.bloom-rollback" + [ ! -e "$candidate" ] && [ ! -L "$candidate" ] || return 1 + (umask 077; cp -p "$PRIOR_CONFIG" "$candidate") || return 1 + chmod 600 "$candidate" || { rm -f "$candidate"; return 1; } + [ "$(file_sha256 "$candidate")" = "$expected" ] || { rm -f "$candidate"; return 1; } + "$SYNC" || { rm -f "$candidate"; return 1; } + mv -f "$candidate" "$WPA_CONFIG" || { rm -f "$candidate"; return 1; } + "$SYNC" || return 1 + [ "$(file_sha256 "$WPA_CONFIG")" = "$expected" ] +} + monotonic_seconds() { if [ -n "${BLOOM_MONOTONIC_BIN:-}" ]; then value="$($BLOOM_MONOTONIC_BIN)" || return 1 @@ -223,11 +287,13 @@ command_begin() { transaction_id="tx-$started-$$" tx_nonce="$(nonce)" || fail "cannot create transaction nonce" tx_nonce_hash="$(nonce_hash "$tx_nonce")" + wpa_sha256="$(snapshot_wpa_config)" || fail "cannot preserve prior station configuration" prior_tmp="$PRIOR.tmp.$$" (umask 077; "$JQ" -n \ --argjson wifi "$wifi_enabled" --argjson associated "$associated" \ --argjson ssh "$ssh_enabled" --argjson sftp "$sftp_enabled" --argjson samba "$samba_enabled" \ - '{schema:1,wifi_enabled:$wifi,associated:$associated,services:{ssh:$ssh,sftp:$sftp,samba:$samba}}' >"$prior_tmp") || + --arg wpa_sha256 "$wpa_sha256" \ + '{schema:2,wifi_enabled:$wifi,associated:$associated,wpa_sha256:$wpa_sha256,services:{ssh:$ssh,sftp:$sftp,samba:$samba}}' >"$prior_tmp") || fail "cannot write prior network state" mv -f "$prior_tmp" "$PRIOR" || fail "cannot publish prior network state" lease="$($JQ -n -c --arg id "$transaction_id" --argjson started "$started" --argjson deadline "$deadline" \ @@ -274,8 +340,9 @@ authorize() { restore_prior() { [ -f "$PRIOR" ] && [ ! -L "$PRIOR" ] || return 1 "$JQ" -e ' - (keys|sort)==["associated","schema","services","wifi_enabled"] and - .schema==1 and + (((keys|sort)==["associated","schema","services","wifi_enabled"] and .schema==1) or + ((keys|sort)==["associated","schema","services","wifi_enabled","wpa_sha256"] and .schema==2 and + (.wpa_sha256 | type == "string" and test("^[0-9a-f]{64}$")))) and (.wifi_enabled|type)=="boolean" and (.associated|type)=="boolean" and (.services|keys|sort)==["samba","sftp","ssh"] and @@ -285,7 +352,20 @@ restore_prior() { prior_ssh="$($JQ -r '.services.ssh' "$PRIOR")" prior_sftp="$($JQ -r '.services.sftp' "$PRIOR")" prior_samba="$($JQ -r '.services.samba' "$PRIOR")" - "$NETWORK" request "$([ "$prior_wifi" = true ] && printf enable || printf disable)" >/dev/null 2>&1 || return 1 + prior_schema="$($JQ -r '.schema' "$PRIOR")" + config_changed=false + if [ "$prior_schema" = 2 ]; then + expected="$($JQ -r '.wpa_sha256' "$PRIOR")" + if [ ! -f "$WPA_CONFIG" ] || [ -L "$WPA_CONFIG" ] || [ "$(file_sha256 "$WPA_CONFIG" 2>/dev/null || true)" != "$expected" ]; then + restore_wpa_config "$expected" || return 1 + config_changed=true + fi + fi + if [ "$prior_wifi" = true ] && [ "$config_changed" = true ]; then + "$NETWORK" request reconnect >/dev/null 2>&1 || return 1 + else + "$NETWORK" request "$([ "$prior_wifi" = true ] && printf enable || printf disable)" >/dev/null 2>&1 || return 1 + fi "$SERVICES" request ssh "$([ "$prior_ssh" = true ] && printf enable || printf disable)" >/dev/null 2>&1 || return 1 "$SERVICES" request sftp "$([ "$prior_sftp" = true ] && printf enable || printf disable)" >/dev/null 2>&1 || return 1 "$SERVICES" request samba "$([ "$prior_samba" = true ] && printf enable || printf disable)" >/dev/null 2>&1 || return 1 @@ -293,6 +373,24 @@ restore_prior() { [ ! -x "$LISTENER_AUDIT" ] || "$LISTENER_AUDIT" >/dev/null 2>&1 || return 1 } +wait_for_network_associated() { + wait_limit=$1 + deadline=$2 + wait_attempt=0 + while [ "$wait_attempt" -lt "$wait_limit" ]; do + network="$($NETWORK status 2>/dev/null || true)" + if [ "$(printf '%s\n' "$network" | "$JQ" -er '.wifi_enabled == true and .associated == true' 2>/dev/null || true)" = true ]; then + return 0 + fi + now="$(monotonic_seconds)" || return 1 + [ "$now" -lt "$deadline" ] || return 1 + wait_attempt=$((wait_attempt + 1)) + [ "$wait_attempt" -lt "$wait_limit" ] || break + "$SLEEP" 1 || return 1 + done + return 1 +} + verify_prior_unchanged() { [ -f "$PRIOR" ] && [ ! -L "$PRIOR" ] || return 1 network="$($NETWORK status)" || return 1 @@ -302,6 +400,12 @@ verify_prior_unchanged() { [ "$(printf '%s\n' "$services" | "$JQ" -er '.ssh.enabled')" = "$($JQ -r '.services.ssh' "$PRIOR")" ] || return 1 [ "$(printf '%s\n' "$services" | "$JQ" -er '.sftp.enabled')" = "$($JQ -r '.services.sftp' "$PRIOR")" ] || return 1 [ "$(printf '%s\n' "$services" | "$JQ" -er '.samba.enabled')" = "$($JQ -r '.services.samba' "$PRIOR")" ] || return 1 + prior_schema="$($JQ -r '.schema' "$PRIOR")" + if [ "$prior_schema" = 2 ]; then + expected="$($JQ -r '.wpa_sha256' "$PRIOR")" + [ -f "$WPA_CONFIG" ] && [ ! -L "$WPA_CONFIG" ] || return 1 + [ "$(file_sha256 "$WPA_CONFIG")" = "$expected" ] || return 1 + fi [ ! -x "$LISTENER_AUDIT" ] || "$LISTENER_AUDIT" >/dev/null 2>&1 || return 1 } @@ -383,6 +487,7 @@ command_apply() { wifi-disable) fail "remote Wi-Fi disable is not device-qualified" ;; wifi-enable|ssh-enable|ssh-disable|ssh-shadow) [ -z "$overlay" ] || fail "operation has unexpected arguments" ;; network-overlay) safe_id "$overlay" || fail "network overlay id is unsafe" ;; + wifi-profile-connect) safe_id "$overlay" || fail "station profile id is unsafe" ;; *) fail "network mutation operation is unsupported" ;; esac preflight @@ -391,7 +496,9 @@ command_apply() { state="$(read_state)" shadow_port=0 [ "$operation" != ssh-shadow ] || shadow_port=$SHADOW_PORT - applying="$(printf '%s\n' "$state" | "$JQ" -c --arg operation "$operation" --arg overlay "$overlay" --argjson shadow_port "$shadow_port" '.phase="applying"|.operation=$operation|.network_overlay=$overlay|.shadow_port=$shadow_port')" + state_overlay= + [ "$operation" != network-overlay ] || state_overlay=$overlay + applying="$(printf '%s\n' "$state" | "$JQ" -c --arg operation "$operation" --arg overlay "$state_overlay" --argjson shadow_port "$shadow_port" '.phase="applying"|.operation=$operation|.network_overlay=$overlay|.shadow_port=$shadow_port')" publish_state "$applying" applied=true case "$operation" in @@ -400,6 +507,13 @@ command_apply() { ssh-disable) "$SERVICES" request ssh disable >/dev/null 2>&1 || applied=false ;; ssh-shadow) "$SSH_HELPER" shadow-start "$(printf '%s\n' "$state" | "$JQ" -r '.transaction_id')" "$SHADOW_PORT" >/dev/null 2>&1 || applied=false ;; network-overlay) "$DEV_DEPLOY" activate-network "$overlay" "$(printf '%s\n' "$state" | "$JQ" -r '.transaction_id')" >/dev/null 2>&1 || applied=false ;; + wifi-profile-connect) + if [ ! -x "$WIFI_PROFILES" ] || ! "$WIFI_PROFILES" project "$overlay" >/dev/null 2>&1 || + ! "$NETWORK" request reconnect >/dev/null 2>&1 || + ! wait_for_network_associated 45 "$(printf '%s\n' "$state" | "$JQ" -r '.deadline')"; then + applied=false + fi + ;; esac if [ "$applied" = true ]; then final="$(printf '%s\n' "$applying" | "$JQ" -c '.phase="applied"')" diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index 820fa1f39..b26171dc0 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -249,6 +249,12 @@ reconcile() { case "$#:${1:-}" in 1:reconcile) reconcile ;; + 1:restart) + preference=$(enabled) || { result true false preference_unavailable; exit 1; } + [ "$preference" = 1 ] || { result true false wifi_disabled; exit 1; } + start_wifi || { result true false restart_failed; exit 1; } + result true true restarted + ;; 1:watch) reconcile_watcher ;; *) result false false invalid_arguments; exit 2 ;; esac diff --git a/static/build/.tmp_update/bin/bloomctl b/static/build/.tmp_update/bin/bloomctl index 86a91dd06..87c7d974a 100755 --- a/static/build/.tmp_update/bin/bloomctl +++ b/static/build/.tmp_update/bin/bloomctl @@ -270,7 +270,7 @@ network_transaction_status() { overlay=$(printf '%s' "$raw_transaction" | sed -n 's/.*"network_overlay":"\([a-z0-9._-]*\)".*/\1/p') failure=$(printf '%s' "$raw_transaction" | sed -n 's/.*"failure_reason":"\([a-z_]*\)".*/\1/p') case "$transaction_id" in tx-[a-z0-9._-]*) ;; *) phase=invalid ;; esac - case "$operation" in none|wifi-enable|wifi-disable|ssh-enable|ssh-disable|ssh-shadow|network-overlay) ;; *) phase=invalid ;; esac + case "$operation" in none|wifi-enable|wifi-disable|wifi-profile-connect|ssh-enable|ssh-disable|ssh-shadow|network-overlay) ;; *) phase=invalid ;; esac case "$failure" in none|apply_failed|explicit|confirmation_timeout|watchdog_start_failed|restore_failed) ;; *) phase=invalid ;; esac case "$remaining" in ''|*[!0-9]*) phase=invalid ;; esac if [ "$operation" = network-overlay ]; then diff --git a/test/shell/bloom_device.bats b/test/shell/bloom_device.bats index a1934d5ef..bd9ed4339 100644 --- a/test/shell/bloom_device.bats +++ b/test/shell/bloom_device.bats @@ -368,6 +368,10 @@ PY [ "$status" -eq 0 ] grep -F "bloom-network-transaction apply '$nonce' 'network-overlay' 'network-test'" "$MOCK_LOG" grep -F -- '-o ControlMaster=auto -o ControlPersist=1800' "$MOCK_LOG" + + run "$DEVICE_TOOL" network-apply test-plus "$nonce" wifi-profile-connect fixture-home + [ "$status" -eq 0 ] + grep -F "bloom-network-transaction apply '$nonce' 'wifi-profile-connect' 'fixture-home'" "$MOCK_LOG" } @test "network confirmation reopens an independent connection and verifies every recovery surface" { @@ -419,6 +423,8 @@ PY run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef wifi-disable [ "$status" -eq 1 ] [[ "$output" == *'remote Wi-Fi disable is not device-qualified'* ]] + run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef wifi-profile-connect '../escape' + [ "$status" -eq 1 ] ! grep -Fq 'bloom-network-transaction begin' "$MOCK_LOG" ! grep -Fq 'bloom-network-transaction apply' "$MOCK_LOG" } diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index 8870af086..d5ee9c695 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -138,6 +138,23 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] } +@test "reconnect restarts only the canonical enabled backend and reconciles listeners" { + run "$SERVICE" request reconnect + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"service":"bloom-network","operation":"reconnect","applied":true,"state":"applied"}' ] + [ "$(cat "$BACKEND_LOG")" = restart ] + [ "$(cat "$SERVICES_LOG")" = reconcile ] + [ ! -e "$COMPAT_LOG" ] + + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_NETWORK_JSONVAL_BIN" + chmod +x "$BLOOM_NETWORK_JSONVAL_BIN" + : >"$BACKEND_LOG" + run "$SERVICE" request reconnect + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"wifi_disabled"'* ]] + [ ! -s "$BACKEND_LOG" ] +} + @test "unsupported Bloom Wi-Fi backend falls back to the complete compatibility check" { printf '#!/bin/sh\nexit 3\n' >"$BLOOM_NETWORK_BACKEND_BIN" chmod +x "$BLOOM_NETWORK_BACKEND_BIN" diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index f4d41933c..69a014b39 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -7,6 +7,8 @@ setup() { export SDCARD="$BLOOM_NETWORK_TRANSACTION_ROOT/mnt/SDCARD" export SYSTEM_ROOT="$SDCARD/.tmp_update" export TX_ROOT="$SDCARD/.bloom/network-transaction" + export BLOOM_NETWORK_TRANSACTION_PRIVATE_ROOT="$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/bloom-network/transaction" + export PRIVATE_ROOT="$BLOOM_NETWORK_TRANSACTION_PRIVATE_ROOT" export BLOOM_JQ_BIN=/usr/bin/jq export BLOOM_NETWORK_BIN="$BATS_TEST_TMPDIR/network" export BLOOM_NETWORK_SERVICES_BIN="$BATS_TEST_TMPDIR/services" @@ -16,11 +18,12 @@ setup() { export BLOOM_LISTENER_AUDIT_BIN="$BATS_TEST_TMPDIR/listener-audit" export BLOOM_PLATFORM_BIN="$BATS_TEST_TMPDIR/platform" export BLOOM_SSH_HELPER_BIN="$BATS_TEST_TMPDIR/ssh-helper" + export BLOOM_WIFI_PROFILES_BIN="$BATS_TEST_TMPDIR/wifi-profiles" export BLOOM_MONOTONIC_BIN="$BATS_TEST_TMPDIR/monotonic" export BLOOM_TRANSACTION_NO_DETACH=1 export TRANSACTION_LOG="$BATS_TEST_TMPDIR/operations.log" export TIME_FILE="$BATS_TEST_TMPDIR/time" - mkdir -p "$SYSTEM_ROOT/bin" "$SDCARD/.bloom/dev" "$TX_ROOT" "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs" + mkdir -p "$SYSTEM_ROOT/bin" "$SDCARD/.bloom/dev" "$TX_ROOT" "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/bloom-network" : >"$SDCARD/.bloom-dev" : >"$SDCARD/.bloom/dev/network-mutation.enabled" printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBoundedDeveloperRecoveryKey test' >"$SDCARD/.bloom/authorized_keys" @@ -90,10 +93,19 @@ case "$1" in ;; shadow-status) printf '%s\n' running_key_only ;; esac +EOF + cat >"$BLOOM_WIFI_PROFILES_BIN" <<'EOF' +#!/bin/sh +printf 'wifi-profiles %s\n' "$*" >>"$TRANSACTION_LOG" +[ "$1" = project ] && [ "$#" -eq 2 ] || exit 2 +[ "${PROFILE_PROJECT_FAIL:-0}" != 1 ] || exit 1 +printf '# managed-by=BloomOS wifi-profile-store-v1\n# bloom-profile-id=%s\nnetwork={ssid="synthetic" key_mgmt=NONE}\n' "$2" \ + >"$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" +chmod 600 "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" EOF chmod +x "$BLOOM_NETWORK_BIN" "$BLOOM_NETWORK_SERVICES_BIN" "$BLOOM_UPDATE_STATE_BIN" \ "$BLOOM_SESSION_BIN" "$BLOOM_DEV_DEPLOY_BIN" "$BLOOM_LISTENER_AUDIT_BIN" "$BLOOM_MONOTONIC_BIN" \ - "$BLOOM_PLATFORM_BIN" "$BLOOM_SSH_HELPER_BIN" + "$BLOOM_PLATFORM_BIN" "$BLOOM_SSH_HELPER_BIN" "$BLOOM_WIFI_PROFILES_BIN" } begin_transaction() { @@ -108,6 +120,10 @@ seed_legacy_wifi_disable_transaction() { temporary="$TX_ROOT/state.json.legacy-wifi-disable" jq -c '.phase="applied" | .operation="wifi-disable"' "$TX_ROOT/state.json" >"$temporary" mv "$temporary" "$TX_ROOT/state.json" + temporary="$TX_ROOT/prior.json.legacy" + jq -c 'del(.wpa_sha256) | .schema=1' "$TX_ROOT/prior.json" >"$temporary" + mv "$temporary" "$TX_ROOT/prior.json" + rm -f "$PRIVATE_ROOT/prior-wpa_supplicant.conf" printf '%s\n' 'network request disable' >>"$TRANSACTION_LOG" } @@ -139,6 +155,7 @@ seed_legacy_wifi_disable_transaction() { ! grep -Fq '0123456789abcdef0123456789abcdef' "$TX_ROOT/state.json" ! grep -Fq 'private' "$TX_ROOT/state.json" ! grep -Fq 'AAAAC3' "$TX_ROOT/state.json" + ! grep -R -Fq private "$TX_ROOT" ! grep -Fq private "$TX_ROOT/recovery-lease.json" ! grep -Fq AAAAC3 "$TX_ROOT/recovery-lease.json" ! grep -Fq 0123456789abcdef "$TX_ROOT/recovery-lease.json" @@ -148,6 +165,82 @@ seed_legacy_wifi_disable_transaction() { [[ "$output" != *private* ]] } +@test "begin preserves an exact private station configuration snapshot" { + expected="$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" + begin_transaction + + [ "$(jq -r .schema "$TX_ROOT/prior.json")" -eq 2 ] + [ "$(jq -r .wpa_sha256 "$TX_ROOT/prior.json")" = "$expected" ] + [ "$(sha256sum "$PRIVATE_ROOT/prior-wpa_supplicant.conf" | awk '{print $1}')" = "$expected" ] + [ "$(stat -c %a "$TX_ROOT")" = 700 ] + [ "$(stat -c %a "$PRIVATE_ROOT")" = 700 ] + [ "$(stat -c %a "$PRIVATE_ROOT/prior-wpa_supplicant.conf")" = 600 ] + [ ! -e "$TX_ROOT/prior-wpa_supplicant.conf" ] + ! grep -Fq private "$TX_ROOT/prior.json" +} + +@test "profile connect projects by non-secret id and exact rollback restores the prior station" { + original="$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" + begin_transaction + + run "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-profile-connect fixture-home + [ "$status" -eq 0 ] + grep -F 'wifi-profiles project fixture-home' "$TRANSACTION_LOG" + grep -F 'network request reconnect' "$TRANSACTION_LOG" + [ "$(jq -r .operation "$TX_ROOT/state.json")" = wifi-profile-connect ] + [ "$(jq -r .network_overlay "$TX_ROOT/state.json")" = "" ] + [ "$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" != "$original" ] + + run "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" = "$original" ] + [ "$(stat -c %a "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf")" = 600 ] + [ "$(grep -Fc 'network request reconnect' "$TRANSACTION_LOG")" -eq 2 ] +} + +@test "failed profile projection rolls back without changing the exact station configuration" { + original="$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" + begin_transaction + + run env PROFILE_PROJECT_FAIL=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-profile-connect fixture-home + [ "$status" -ne 0 ] + [[ "$output" == *'mutation failed and was rolled back'* ]] + [ "$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" = "$original" ] + ! grep -Fq 'network request reconnect' "$TRANSACTION_LOG" + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] +} + +@test "corrupt private snapshot fails closed and keeps the recovery lease armed" { + begin_transaction + printf '%s\n' corrupt >"$PRIVATE_ROOT/prior-wpa_supplicant.conf" + printf '%s\n' changed >"$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" + + run "$SERVICE" rollback + [ "$status" -ne 0 ] + [ "$(cat "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf")" = changed ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rollback_failed ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = armed ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] +} + +@test "boot recovery atomically restores an interrupted profile transition" { + original="$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" + begin_transaction + "$BLOOM_WIFI_PROFILES_BIN" project fixture-home + temporary="$TX_ROOT/state.json.interrupted-connect" + jq -c '.phase="applying" | .operation="wifi-profile-connect"' "$TX_ROOT/state.json" >"$temporary" + mv "$temporary" "$TX_ROOT/state.json" + mkdir "$TX_ROOT/.lock" + + run "$SERVICE" boot-recover + [ "$status" -eq 0 ] + [[ "$output" == *'"recovery":"restored"'* ]] + [ "$(sha256sum "$BLOOM_NETWORK_TRANSACTION_ROOT/appconfigs/wpa_supplicant.conf" | awk '{print $1}')" = "$original" ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .failure_reason "$TX_ROOT/state.json")" = boot_recovery ] + grep -F 'network request reconnect' "$TRANSACTION_LOG" +} + @test "begin launches the detached watchdog with the published transaction identity" { nohup_mock="$BATS_TEST_TMPDIR/nohup" cat >"$nohup_mock" <<'EOF' diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index dca55bc5e..032b3a78b 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -187,6 +187,20 @@ SH grep -Fx 'iw dev wlan0 set power_save off' "$WIFI_LOG" } +@test "explicit restart replaces the station lifecycle even while associated" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" + + run "$SERVICE" restart + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"restarted"'* ]] + grep -Fx 'pkill -TERM wpa_supplicant' "$WIFI_LOG" + grep -Fx 'pkill -TERM udhcpc' "$WIFI_LOG" + grep -Fx 'axp_test wifion' "$WIFI_LOG" + grep -Fx "wpa_supplicant -B -D nl80211 -iwlan0 -c $BLOOM_WIFI_CONFIG" "$WIFI_LOG" +} + @test "disabled Plus stops clients and powers off the radio" { printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_WIFI_JSONVAL_BIN" chmod +x "$BLOOM_WIFI_JSONVAL_BIN" diff --git a/tools/bloom-device b/tools/bloom-device index 26e42dcff..8f662c3ae 100755 --- a/tools/bloom-device +++ b/tools/bloom-device @@ -15,7 +15,7 @@ Usage: bloom-device [output-directory] bloom-device dev-evidence [output-directory] bloom-device network- bloom-device network-begin [rollback-seconds] - bloom-device network-apply [deployment-id] + bloom-device network-apply [id] bloom-device network-confirm Targets are read from tools/targets.toml or BLOOM_TARGETS_FILE. Copy @@ -341,6 +341,9 @@ command_network_apply() { network-overlay) [[ "$deployment_id" =~ ^[a-z0-9][a-z0-9._-]{0,79}$ ]] || die "unsafe deployment id" ;; + wifi-profile-connect) + [[ "$deployment_id" =~ ^[a-z0-9][a-z0-9._-]{0,31}$ ]] || die "unsafe station profile id" + ;; *) die "unsupported network mutation: $operation" ;; esac verify_dev_device @@ -372,7 +375,7 @@ if transaction.get("phase") != "applied": operation = transaction.get("operation") if operation in {"wifi-disable", "ssh-disable"}: raise SystemExit("a sole-route disable operation must expire or be rolled back; it cannot be confirmed remotely") -if operation not in {"wifi-enable", "ssh-enable", "ssh-shadow", "network-overlay"}: +if operation not in {"wifi-enable", "wifi-profile-connect", "ssh-enable", "ssh-shadow", "network-overlay"}: raise SystemExit("transaction operation is not confirmable") if not network.get("associated"): raise SystemExit("independent station association is not healthy") From 6117afd7bdabfba8a45b76de9bc1a6fb4a98dc6d Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:40:46 -0400 Subject: [PATCH 2/2] docs(program): record NET-04C pull request --- docs/program/BloomOS_Work_Program_v3.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 816b6eeb5..651fe2be1 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -833,18 +833,21 @@ ], "github_prs": [ 487, - 488 + 488, + 489 ], "risk_tier": 2, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "105f744d2fc88d0cacf023ecb62e800572dd0933", + "last_audited_main": "5f155d6d16fd927d775f18219353c76ae641d750", "evidence": [ "issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration", "draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04", "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green; PR #487 merged as 105f744d2fc88d0cacf023ecb62e800572dd0933", "draft PR #488 adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; 9/9 affected tests passed in 13.8 seconds and 13/13 focused manifest/provenance tests passed in 271 seconds with exact 314669717-byte dependency preflight, and the slice does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04", - "exact head b5de4b1ae7f7d558886a8c55047b3a7ada141621 passed risk-scoped shell workflow 33173965383 with 1m39s test execution; its exact 17244-byte helper with SHA-256 0d3b3e1ac893fb787fcd7c3d28b2c526175475d360bab83836d9072efab695b3 and a 156-byte synthetic platform fixture were transferred through strict key-only SSH and exercised only under /tmp with /bin/echo replacing iw, transitioned RUNNING to SUCCEEDED with zero results, enforced 0700/0700/0600/0600 root-operation-state-result modes, discarded the terminal operation, removed every task file, retained live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef, and left aggregate device health green without a real scan or network mutation" + "exact head b5de4b1ae7f7d558886a8c55047b3a7ada141621 passed risk-scoped shell workflow 33173965383 with 1m39s test execution; its exact 17244-byte helper with SHA-256 0d3b3e1ac893fb787fcd7c3d28b2c526175475d360bab83836d9072efab695b3 and a 156-byte synthetic platform fixture were transferred through strict key-only SSH and exercised only under /tmp with /bin/echo replacing iw, transitioned RUNNING to SUCCEEDED with zero results, enforced 0700/0700/0600/0600 root-operation-state-result modes, discarded the terminal operation, removed every task file, retained live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef, and left aggregate device health green without a real scan or network mutation", + "PR #489 adds a rollback-bounded wifi-profile-connect transaction that preserves the exact prior supplicant bytes in a mode-0600 snapshot below a mode-0700 JFFS2-private root, places only its SHA-256 in public transaction metadata, routes station replacement through the canonical network and Wi-Fi owners, and restores the exact prior configuration after apply failure, explicit rollback, timeout, or interrupted boot; 88/88 direct affected tests and 140/140 exhaustive focused tests passed with exact 314676226-byte inventory preflight", + "exact candidate ff5b0775 was 33007 bytes with SHA-256 d3bed4f13e6e3b84344ba9c370f6a258b09d5e23a96378068a64eff30b2ab68e and passed an isolated Mini Plus /tmp fixture using synthetic station content and mocked dependencies: apply changed only the fixture, rollback restored the exact prior hash, private root/snapshot modes were 0700/0600, no public secret copy existed, all task files were removed, and the live device remained associated and healthy without a real scan, profile transition, Wi-Fi toggle, DHCP mutation, listener mutation, update, or reboot" ] }, {