diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0a3e8f7..8578766f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,142 @@ jobs: exit 1 fi + gateway-composed: + needs: repo-hygiene + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + sha256: ${{ steps.package.outputs.sha256 }} + steps: + - name: Fail if repo hygiene failed + if: ${{ needs.repo-hygiene.result != 'success' }} + shell: bash + run: | + echo "repo-hygiene failed; see the repo-hygiene job output." >&2 + exit 1 + + - name: Checkout frozen OpenClaw main source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: openclaw/openclaw + ref: b9a28cde42872cfe13051ddd8f1667e34bf59b17 + fetch-depth: 0 + + - name: Setup Node 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Activate pinned pnpm + shell: bash + run: | + corepack enable + corepack prepare pnpm@11.2.2 --activate + + - name: Compose main with exact PR 110382 and PR 111956 patches + shell: bash + run: | + set -euo pipefail + base_sha=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + wizard_base=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + wizard_head=4ef65f07940a601a8bf7ef18fae72270f4843ccb + package_base=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + package_head=070527ddfe32f0156a6258a638e226f731458ecd + fork_repository=https://github.com/TheAngryPit/openclaw.git + wizard_fetch_ref=refs/heads/codex/fix-gateway-wizard-rpc-exits + package_fetch_ref=refs/heads/codex/windows-package-runner-tree-timeout + wizard_paths="$RUNNER_TEMP/pr110382-expected-paths.txt" + package_paths="$RUNNER_TEMP/pr111956-expected-paths.txt" + combined_paths="$RUNNER_TEMP/combined-expected-paths.txt" + staged_paths="$RUNNER_TEMP/combined-staged-paths.txt" + wizard_patch="$RUNNER_TEMP/pr110382.patch" + package_patch="$RUNNER_TEMP/pr111956.patch" + + cat >"$wizard_paths" <<'EOF' + apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift + packages/gateway-protocol/src/schema/wizard.ts + src/gateway/gateway.test.ts + src/gateway/server-methods/wizard.test.ts + src/gateway/server-methods/wizard.ts + src/gateway/server-wizard-sessions.test.ts + src/gateway/server-wizard-sessions.ts + src/wizard/session.ts + src/wizard/setup.finalize.test.ts + src/wizard/setup.finalize.ts + EOF + cat >"$package_paths" <<'EOF' + scripts/package-openclaw-for-docker.d.mts + scripts/package-openclaw-for-docker.mjs + test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts + EOF + cat "$wizard_paths" "$package_paths" | sort >"$combined_paths" + + test "$(git rev-parse HEAD)" = "$base_sha" + # Fetch immutable reviewed commits from the operator fork. The upstream + # PR 111956 object was stale at freeze time, so it is not an authority. + git fetch --no-tags "$fork_repository" \ + "+${wizard_fetch_ref}:refs/remotes/fork/pr-110382" \ + "+${package_fetch_ref}:refs/remotes/fork/pr-111956" + test "$(git rev-parse refs/remotes/fork/pr-110382)" = "$wizard_head" + test "$(git rev-parse refs/remotes/fork/pr-111956)" = "$package_head" + git merge-base --is-ancestor "$wizard_base" "$wizard_head" + git merge-base --is-ancestor "$package_base" "$package_head" + git diff --name-only "$wizard_base" "$wizard_head" | sort | diff -u "$wizard_paths" - + git diff --name-only "$package_base" "$package_head" | sort | diff -u "$package_paths" - + git diff --binary "$wizard_base" "$wizard_head" -- $(cat "$wizard_paths") >"$wizard_patch" + git diff --binary "$package_base" "$package_head" -- $(cat "$package_paths") >"$package_patch" + test -s "$wizard_patch" + test -s "$package_patch" + sha256sum "$wizard_patch" >"$RUNNER_TEMP/pr110382-patch.SHA256SUM" + sha256sum "$package_patch" >"$RUNNER_TEMP/pr111956-patch.SHA256SUM" + git apply --index "$wizard_patch" + git apply --index "$package_patch" + git diff --cached --name-only | sort >"$staged_paths" + diff -u "$combined_paths" "$staged_paths" + git diff --cached --check + + - name: Build immutable composed gateway package + id: package + shell: bash + run: | + set -euo pipefail + test "$(node -p "require('./package.json').version")" = "2026.7.2" + pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ + --source-dir . \ + --output-dir "$RUNNER_TEMP/gateway-composed" \ + --output-name openclaw-2026.7.2-main-pr110382-pr111956.tgz + tarball="$RUNNER_TEMP/gateway-composed/openclaw-2026.7.2-main-pr110382-pr111956.tgz" + sha256="$(sha256sum "$tarball" | awk '{print $1}')" + packaged_version="$(tar -xOf "$tarball" package/package.json | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).version))")" + test "$packaged_version" = "2026.7.2" + wizard_patch_sha256="$(awk '{print $1}' "$RUNNER_TEMP/pr110382-patch.SHA256SUM")" + package_patch_sha256="$(awk '{print $1}' "$RUNNER_TEMP/pr111956-patch.SHA256SUM")" + test "${#sha256}" -eq 64 + test "${#wizard_patch_sha256}" -eq 64 + test "${#package_patch_sha256}" -eq 64 + printf '%s %s\n' "$sha256" "$(basename "$tarball")" >"$RUNNER_TEMP/gateway-composed/SHA256SUMS" + printf '{"source_ref":"main","source_sha":"%s","fixes":[{"pr":110382,"base":"%s","head":"%s","fetch_repository":"TheAngryPit/openclaw","fetch_ref":"codex/fix-gateway-wizard-rpc-exits","patch_sha256":"%s"},{"pr":111956,"base":"%s","head":"%s","fetch_repository":"TheAngryPit/openclaw","fetch_ref":"codex/windows-package-runner-tree-timeout","upstream_pr_object_state_at_freeze":"stale","patch_sha256":"%s"}],"changed_paths":["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift","packages/gateway-protocol/src/schema/wizard.ts","scripts/package-openclaw-for-docker.d.mts","scripts/package-openclaw-for-docker.mjs","src/gateway/gateway.test.ts","src/gateway/server-methods/wizard.test.ts","src/gateway/server-methods/wizard.ts","src/gateway/server-wizard-sessions.test.ts","src/gateway/server-wizard-sessions.ts","src/wizard/session.ts","src/wizard/setup.finalize.test.ts","src/wizard/setup.finalize.ts","test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],"package_version":"%s","package_sha256":"%s"}\n' \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + 4ef65f07940a601a8bf7ef18fae72270f4843ccb \ + "$wizard_patch_sha256" \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + 070527ddfe32f0156a6258a638e226f731458ecd \ + "$package_patch_sha256" \ + "$packaged_version" \ + "$sha256" >"$RUNNER_TEMP/gateway-composed/provenance.json" + echo "sha256=$sha256" >>"$GITHUB_OUTPUT" + + - name: Upload composed gateway package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: openclaw-main-pr110382-pr111956-composed + path: ${{ runner.temp }}/gateway-composed/ + if-no-files-found: error + retention-days: 7 + test: needs: repo-hygiene if: ${{ !cancelled() }} @@ -338,29 +474,50 @@ jobs: majorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }} e2etests: - needs: repo-hygiene - if: ${{ !cancelled() }} + needs: [repo-hygiene, gateway-composed] + if: ${{ needs.repo-hygiene.result == 'success' && needs.gateway-composed.result == 'success' }} runs-on: windows-latest timeout-minutes: ${{ matrix.timeout_minutes }} strategy: fail-fast: false matrix: include: - - name: setup-connect + - name: lkg-setup-connect + gateway_source: lkg + gateway_version: "" + scenario: setup-connect + timeout_minutes: 45 + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" + - name: official-beta-setup-connect + gateway_source: official + gateway_version: 2026.7.2-beta.3 + scenario: setup-connect timeout_minutes: 45 filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" - - name: revocation-recovery + - name: composed-setup-connect + gateway_source: composed + gateway_version: "" + scenario: setup-connect + timeout_minutes: 45 + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" + - name: composed-revocation-recovery + gateway_source: composed + gateway_version: "" + scenario: revocation-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests - - name: network-recovery + - name: composed-network-recovery + gateway_source: composed + gateway_version: "" + scenario: network-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.NetworkRecoveryTests steps: - name: Fail if repo hygiene failed - if: ${{ needs.repo-hygiene.result != 'success' }} + if: ${{ needs.repo-hygiene.result != 'success' || needs.gateway-composed.result != 'success' }} shell: pwsh run: | - Write-Error "repo-hygiene failed; see the repo-hygiene job output." + Write-Error "A prerequisite failed; see repo-hygiene and gateway-composed job output." exit 1 - uses: actions/checkout@v7 @@ -395,9 +552,46 @@ jobs: - name: Build E2E Tests run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 + - name: Run gateway package selector tests + if: ${{ matrix.name == 'official-beta-setup-connect' }} + shell: pwsh + run: | + dotnet test tests/OpenClaw.E2ETests ` + --no-build ` + -c Debug ` + -r win-x64 ` + --verbosity normal ` + --filter "FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Download composed gateway package + if: ${{ matrix.gateway_source == 'composed' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: openclaw-main-pr110382-pr111956-composed + path: TestResults/GatewayComposed + + - name: Start disposable WSL package host + if: ${{ matrix.gateway_source == 'composed' }} + id: gateway_package_host + shell: pwsh + run: | + .\scripts\Start-E2EGatewayPackageHost.ps1 ` + -PackagePath "TestResults\GatewayComposed\openclaw-2026.7.2-main-pr110382-pr111956.tgz" ` + -ExpectedSha256 "${{ needs.gateway-composed.outputs.sha256 }}" ` + -DistroName "OpenClawE2EPackageHost-${{ matrix.name }}" ` + -InstallLocation "${{ runner.temp }}\OpenClawE2EPackageHost-${{ matrix.name }}" ` + -OwnershipToken "${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}" ` + -GitHubOutput $env:GITHUB_OUTPUT + - name: Run E2E Tests (${{ matrix.name }}) env: OPENCLAW_RUN_E2E: 1 + OPENCLAW_E2E_GATEWAY_SOURCE: ${{ matrix.gateway_source }} + OPENCLAW_E2E_GATEWAY_VERSION: ${{ matrix.gateway_version }} + OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC: ${{ matrix.gateway_source == 'composed' && steps.gateway_package_host.outputs.package_spec || '' }} + OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256: ${{ matrix.gateway_source == 'composed' && steps.gateway_package_host.outputs.package_sha256 || '' }} + OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO: ${{ matrix.gateway_source == 'composed' && steps.gateway_package_host.outputs.distro_name || '' }} shell: pwsh run: | dotnet test tests/OpenClaw.E2ETests ` @@ -417,7 +611,7 @@ jobs: Write-Error "E2E shard '${{ matrix.name }}' executed zero tests. Check OPENCLAW_RUN_E2E gating/filter before merging." exit 1 } - if ("${{ matrix.name }}" -eq "setup-connect") { + if ("${{ matrix.scenario }}" -eq "setup-connect") { $mxcProofNames = @( "RealGateway_SystemRun_ExecutesThroughWindowsNodeMxcSandbox", "RealGateway_SystemRun_BlocksWritesToTrayDataDirectoryInMxcSandbox" @@ -448,6 +642,15 @@ jobs: } } + - name: Stop disposable WSL package host + if: ${{ always() && matrix.gateway_source == 'composed' }} + shell: pwsh + run: | + .\scripts\Stop-E2EGatewayPackageHost.ps1 ` + -DistroName "OpenClawE2EPackageHost-${{ matrix.name }}" ` + -InstallLocation "${{ runner.temp }}\OpenClawE2EPackageHost-${{ matrix.name }}" ` + -OwnershipToken "${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}" + - name: Upload E2E Test Results & Logs if: always() uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 3424a7162..4f7a9c121 100644 --- a/README.md +++ b/README.md @@ -251,40 +251,50 @@ Packaged installs declare camera, microphone, and location capabilities. Windows openclaw devices list # Find your Windows device openclaw devices approve # Approve it ``` -4. **Configure gateway allowCommands** - Add the commands you want to allow under `gateway.nodes` in `~/.openclaw/openclaw.json`: +4. **Configure the gateway node-command allowlist** - Match the key to the Gateway version reported by `openclaw --version`. Current/frozen packages use `gateway.nodes.commands.allow`; the supported legacy versions `2026.6.11` and `2026.7.2-beta.3` use `gateway.nodes.allowCommands`. + + Current/frozen schema: ```json { "gateway": { "nodes": { - "allowCommands": [ - "system.notify", - "system.run", - "system.run.prepare", - "system.which", - "system.execApprovals.get", - "system.execApprovals.set", - "canvas.present", - "canvas.hide", - "canvas.navigate", - "canvas.eval", - "canvas.snapshot", - "canvas.a2ui.push", - "canvas.a2ui.pushJSONL", - "canvas.a2ui.reset", - "screen.snapshot", - "camera.list", - "camera.snap", - "camera.clip", - "location.get", - "device.info", - "device.status", - "tts.speak" - ] + "commands": { + "allow": [ + "system.notify", + "system.run", + "system.run.prepare", + "system.which", + "system.execApprovals.get", + "system.execApprovals.set", + "canvas.present", + "canvas.hide", + "canvas.navigate", + "canvas.eval", + "canvas.snapshot", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "screen.snapshot", + "camera.list", + "camera.snap", + "camera.clip", + "location.get", + "device.info", + "device.status", + "tts.speak" + ] + } } } } ``` - > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` and agent-driven audio playback via `tts.speak` should only be added to `allowCommands` when you explicitly want to allow them. + + For `2026.6.11` or `2026.7.2-beta.3`, preserve the complete array above but + move it directly to `gateway.nodes.allowCommands`: remove the `commands` + wrapper and rename its `allow` property to `allowCommands`. Do not shorten + the array when applying this legacy key transformation. + + > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` and agent-driven audio playback via `tts.speak` should only be added to the version-appropriate allowlist when you explicitly want to allow them. 5. **Test it** from your Mac/gateway: ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5b704e115..2434d5cbe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,6 +114,8 @@ leading and trailing pipe. Columns, in order: | wsl-posix-quoting | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | ad hoc ShellEscape with divergent wrap semantics | WslShellQuoting | - | WSL command lines use POSIX single-quote quoting via WslShellQuoting not cmd/PowerShell quoting | WslShellQuotingTests.QuotePosixSingleQuote_WrapsAndEscapesEmbeddedQuote | behavioral | when no code builds WSL command lines outside WslShellQuoting | | setup-shellescape-closed | closed | src/OpenClaw.SetupEngine/SetupSteps.cs | private ShellEscape helpers with divergent wrap semantics | WslShellQuoting | - | SetupSteps builds WSL command lines only via WslShellQuoting; no local ShellEscape helper | SetupStepsShellEscapeClosureTests.SetupSteps_DoesNotReintroduce_PrivateShellEscape | source-shape | when SetupSteps.cs no longer builds any WSL command strings | | wsl-distro-install-path | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | inline Path.Combine wsl distro install-path derivation | DistroInstallPathPolicy | - | new installs use the strict supported name grammar; teardown accepts only unambiguous single-segment names whose canonical path is an immediate child of LocalDataDir\wsl with no aliases, case or Unicode collisions, or reparse points at the root or child | SetupStepsTests.DistroInstallPathPolicy_ResolvesImmediateChild | behavioral | - | +| gateway-version-alignment | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | local WSL version probe, pinned update transaction, and recovery result policy | OpenClaw.Connection/GatewayVersionAlignmentCoordinator | App composes the service and presents confirmation/results only | normal update never unregisters/imports; exact package version, full rollback point, and Gateway/Node/pairing health gate completion | GatewayVersionAlignmentCoordinatorTests.UpdateAsync_CreatesOfflineVerifiedPointThenUpdatesAndCleansOnlyAfterHealth | behavioral | - | +| gateway-rollback-points | authoritative | none | offline VHD export, manifest receipts, retention, verification, and explicit emergency restore | OpenClaw.Connection/GatewayRollbackPointManager | SettingsPage owns selection and confirmation UI only | immutable verified VHD is retained outside the live distro; unregister/import-in-place is restore-only; direct ext4.vhdx replacement is forbidden | GatewayVersionAlignmentCoordinatorTests.RestoreAsync_RecreatesOnlyRegistrationThenProvesVersionIdentityAndSynchronization | behavioral | - | | app-window-manager | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | window creation/show/hide/shutdown | IWindowManager | composition/delegation only | startup/shutdown ordering deterministic; disposed once | none | review-only | extracted in Phase 3 | | app-tray-controller | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon/menu/action routing | ITrayController | composition/delegation only | tray actions route unchanged | none | review-only | extracted in Phase 3 | | app-activation-router | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | deep-link/toast/single-instance activation | IActivationRouter | composition/delegation only | activation routes land on the same UI/actions; current-user pipe security preserved | none | review-only | extracted in Phase 3 | diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index facd11278..f86737703 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -105,6 +105,24 @@ Settings changes are classified by `SettingsChangeClassifier.Classify()` which c | `OperatorReconnectRequired` | Reconnect operator (SSH tunnel changed) | | `FullReconnectRequired` | Full tear down and reconnect (gateway URL changed) | +## Companion-owned Gateway update and rollback + +`GatewayVersionAlignmentCoordinator` owns exact installed-version comparison and the update transaction for the fixed Companion-owned WSL distro. A normal update: + +1. proves the active gateway is the setup-managed WSL gateway; +2. proves current Gateway, Companion, Windows Node, and pairing health; +3. asks `GatewayRollbackPointManager` to terminate only that distro and export a complete offline VHD; +4. verifies the VHDX signature, byte size, SHA-256, and private manifest receipt, and attests the distro machine identity plus exact OpenClaw version (including build metadata) immediately before stop and after export; +5. runs `openclaw update --tag --yes --json` in the existing distro; +6. verifies the exact installed version and resynchronizes Gateway, Companion, Windows Node, and pairing; +7. re-verifies the rollback point, marks the update healthy, and only then applies retention cleanup. + +Normal update never unregisters, imports, recreates, or directly replaces the distro's `ext4.vhdx`. A failed or ambiguous update preserves the verified rollback point and requires an explicit restore decision. Its target version is stored in the durable receipt, so retry reuses the same point without requiring the disrupted pre-update runtime to pass the new-update health gate again. Pending-update discovery is scoped to the fixed Companion-owned distro rather than the mutable Gateway record ID; a receipt belonging to an earlier record blocks for explicit recovery instead of starting a second transaction. A later Companion requiring a different target likewise detects the pending transaction and blocks. For a fresh transaction, `UpdateInProgress` is flushed before the final live attestation; immediately after that receipt write and before every updater invocation, and again before post-update healthy marking or cleanup, canonical registration BasePath, machine identity, WSL registration configuration, effective default user, and the expected exact normalized package version must still match the receipt and observed transaction state. Exact equality is ordinal and includes prerelease and build metadata. Ordered numeric build identifiers such as `companion.3` are compared without discarding metadata; differing metadata that cannot be safely ordered blocks instead of guessing. Concurrent drift, including a newer observed package, preserves the receipt and never triggers a downgrade. If the package is already aligned after a transient health failure, retry resumes synchronization, rollback verification, exact live attestation, healthy marking, and cleanup instead of silently abandoning the transaction. + +Emergency restore is a separate confirmed operation. It copies the immutable retained VHD through a private `.partial` staging file, flushes and verifies it before promotion, and safely recreates stale or invalid regular staging files from the immutable point before any destructive WSL lifecycle call. Before unregister, it verifies the same-name WSL registry entry maps to the canonical app-owned `BasePath`, the directory contains only its regular `ext4.vhdx`, and no owned path boundary is a reparse point; it repeats host-side validation after termination and revalidates registration absence/path safety before import. It may then unregister the current Companion-owned distro, move the verified staged copy into the canonical app-owned install directory, and use documented `wsl --import-in-place` under the same distro name. The retained rollback VHD never becomes the mutable live disk. Manifest receipts independently capture the supported WSL registration version/default UID/flags and the effective default username/UID selected inside the distro on both sides of export; `/etc/wsl.conf` may make those UID values differ. Receipt writes use write-through and disk flush before `UnregisterPending` and `ImportPending` lifecycle transitions. Across the entire Companion-owned distro, regardless of a later Gateway record-id change, a receipt in `RestoreStaged`, `UnregisterPending`, `DistroUnregistered`, `ImportPending`, or `Imported` blocks both the app-level package probe and every fresh update. Multiple unresolved restore receipts fail as ambiguous. Only `RestoreStaged` may be durably cancelled as `RestoreCancelled`; Settings exposes that exact-point-confirmed non-destructive exit. After the unregister boundary, recovery must resume the same point, and `Imported` remains blocking until full synchronization plus a final exact live attestation records `RestoreHealthy`. Once a receipt reaches the unregister boundary, later probe/preflight failures preserve that monotonic phase rather than degrading it to a generic failure that could repeat lifecycle operations. After import, the supported WSL configuration API restores the recorded registration UID/flags and readback must match before the internal machine identity and independently recorded effective default user are accepted. Durable phases support retry after unregister or failed import; a same-name distro with a different internal machine identity, mismatched registration/default user, or any registration/install-path collision fails closed. + +Retention keeps at least the newest verified known-good point. Settings allow one previous version (default), two, or indefinite retention. Optional age retention is additive: points inside the age window are kept in addition to the count floor. Points are cleanup-eligible only after successful post-update or post-restore health. + ## Connection state machine `ConnectionStateMachine` (internal) drives state transitions for both operator and node roles: diff --git a/docs/MISSION_CONTROL.md b/docs/MISSION_CONTROL.md index d3629853d..00b6f9115 100644 --- a/docs/MISSION_CONTROL.md +++ b/docs/MISSION_CONTROL.md @@ -83,7 +83,7 @@ Gateway/browser facts: - `browser.proxy` is a canonical node command and included in Windows platform defaults at the gateway policy level. - Gateway policy still requires both gates: - - command allowed by platform defaults or `gateway.nodes.allowCommands` + - command allowed by platform defaults or the version-appropriate explicit allowlist: current/frozen `gateway.nodes.commands.allow`; legacy `2026.6.11` and `2026.7.2-beta.3` `gateway.nodes.allowCommands` - command declared by the node - The browser plugin/node-host contract is: - input: `method`, `path`, optional `query`, `body`, `timeoutMs`, `profile` diff --git a/docs/OPERATOR_NODE_CONCEPTS.md b/docs/OPERATOR_NODE_CONCEPTS.md index d317c9c09..8080a4d00 100644 --- a/docs/OPERATOR_NODE_CONCEPTS.md +++ b/docs/OPERATOR_NODE_CONCEPTS.md @@ -61,10 +61,12 @@ so a new or changed node capability is visible before the gateway can use it. ## Capability Allowlist Node Mode advertises available Windows commands, but the gateway decides which -commands it may call through `gateway.nodes.allowCommands` in -`~/.openclaw/openclaw.json`. Add exact command names such as `screen.snapshot`, -`canvas.present`, or `system.run`; wildcard entries are not expanded by the -gateway. +commands it may call through the explicit allowlist in +`~/.openclaw/openclaw.json`. Current/frozen packages use +`gateway.nodes.commands.allow`; exact legacy versions `2026.6.11` and +`2026.7.2-beta.3` use `gateway.nodes.allowCommands`. Add exact command names +such as `screen.snapshot`, `canvas.present`, or `system.run`; wildcard entries +are not expanded by the gateway. Privacy-sensitive commands, especially `screen.record`, `camera.snap`, `camera.clip`, `stt.transcribe`, `tts.speak`, and `system.run`, should only be diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index f759dbd6b..742b98466 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -140,8 +140,9 @@ rerun setup with a supported new name. "Bind": "loopback", "InstallUrl": null, "Version": null, + "ExpectedPackageSha256": null, "HealthTimeoutSeconds": 90, - "ReloadMode": "hot", + "ReloadMode": "hybrid", "AuthMode": "token", "ExtraConfig": null }, @@ -194,9 +195,9 @@ Executed sequentially. Each step is a small class (30–120 lines) in `SetupStep | 7 | `ConfigureWslInstanceStep` | Write wsl.conf, create user, set dirs | | 8 | `ValidateWslLockdownStep` | Verify WSL isolation settings are applied | | 9 | `InstallCliStep` | Run install script inside WSL | -| 10 | `ConfigureGatewayStep` | Write gateway config (bind, port, auth) | -| 11 | `InstallGatewayServiceStep` | `openclaw gateway install --force` | -| 12 | `StartGatewayStep` | Start service, poll health endpoint (90s timeout) | +| 10 | `ConfigureGatewayStep` | Write gateway config (bind, port, auth, and explicit node-command allowlist). Current/custom packages use `gateway.nodes.commands.allow`; the pinned `2026.6.11` LKG and CI-pinned `2026.7.2-beta.3` retain their schema-compatible `gateway.nodes.allowCommands` key. | +| 11 | `InstallGatewayServiceStep` | Run supported `openclaw gateway install --force`, which installs and activates or restarts the service | +| 12 | `WaitForGatewayHealthStep` | Verify the selected port is free or owned by the managed systemd Gateway service, then perform a separate retryable health-only wait (90s timeout) | | 13 | `MintBootstrapTokenStep` | Generate bootstrap token via CLI | | 14 | `PairOperatorStep` | WebSocket operator connection + device approval | | 15 | `PairNodeStep` | WebSocket node connection + capability registration | diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 84b0c0cb0..6bbf1caf0 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -93,10 +93,18 @@ dotnet test # Explicit local E2E run $env:OPENCLAW_RUN_E2E = "1" +$env:OPENCLAW_E2E_GATEWAY_SOURCE = "official" +$env:OPENCLAW_E2E_GATEWAY_VERSION = "2026.7.2-beta.3" dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj -r win-x64 -# Formal MXC validation path. This sets the required integration/E2E env vars -# itself and fails when MXC proofs skip unless -AllowSkip is explicitly supplied. +# Formal composed-main MXC path. Start-E2EGatewayPackageHost.ps1 provides the +# exact content-addressed package URL, SHA-256, and host distro required below. +# The E2E config carries that digest into Setup Engine, which verifies the +# downloaded HTTP response before invoking the official installer on the same .tgz. +$env:OPENCLAW_E2E_GATEWAY_SOURCE = "composed" +$env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC = "http://:38677/openclaw-composed-.tgz" +$env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 = "" +$env:OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO = "OpenClawE2EPackageHost-" .\scripts\validate-mxc-e2e.ps1 # Accessibility scan, matching the CI quality gate. diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 915e0ea69..ee51517de 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -86,7 +86,7 @@ These features need the gateway to send `node.invoke` commands: | `location.get` | Get Windows location | Uses Windows location permission/settings | | `device.info` / `device.status` | Device metadata/status | Returns host/app/locale plus battery/storage/network/uptime payloads | | `browser.proxy` | Proxy browser-control host requests | Requires Browser proxy bridge enabled, a compatible browser-control host listening on gateway port + 2, and matching browser-control auth | -| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in `gateway.nodes.allowCommands` | +| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in the version-appropriate allowlist (`gateway.nodes.commands.allow` current/frozen; `gateway.nodes.allowCommands` on `2026.6.11` and `2026.7.2-beta.3`) | | `stt.transcribe` | Bounded microphone transcription | Requires Speech-to-text enabled in Settings; uses local Whisper.net | | `stt.listen` | Voice-activity microphone transcription | Returns when the user stops speaking or timeout expires | | `stt.status` | Speech-to-text readiness | Returns Whisper.net model download/readiness state | @@ -174,8 +174,8 @@ Local MCP clients also see MCP-only `app.*` commands such as `app.navigate`, `ap - The focused E2E below provisions a fresh WSL Gateway, starts an isolated tray instance, sets a local exec approval rule through MCP, invokes `system.run` through the real Gateway `node.invoke` path, and verifies tray MXC diagnostics show contained `mxc-direct-appc` execution for both allowed execution and denied writes to the tray data directory. - Run it when validating the Gateway/Windows node runtime path, not just direct MCP or shared library behavior. - GitHub-hosted Actions runners do not provide a working MXC/AppContainer runtime. The regular cloud E2E matrix should report these MXC proofs as skipped while still running the rest of setup-connect. Run the proof on a local MXC-enabled Windows machine. Only set `OPENCLAW_RUN_MXC_E2E=1` in GitHub Actions when using an MXC-enabled self-hosted runner. -- Use `.\scripts\validate-mxc-e2e.ps1` for normal local validation. It sets `OPENCLAW_RUN_E2E` and `OPENCLAW_RUN_MXC_E2E`, runs the real Gateway MXC proofs, and fails if the MXC proof skips. `-AllowSkip` is only for documenting a non-MXC host, not for merge validation of MXC-related work. -- When reproducing this manually against an existing Gateway, make sure `gateway.nodes.allowCommands` includes `system.run`, `system.run.prepare`, and `system.which`, then approve any `pending-reapproval` request with `openclaw nodes approve `. The node can advertise `system.run` while the Gateway still blocks it until both gates are updated. +- Use `.\scripts\validate-mxc-e2e.ps1` for normal local validation of a composed-main package. Set `OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC`, `OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256`, and `OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO` from `Start-E2EGatewayPackageHost.ps1`; the harness sets source `composed`, carries the reviewed digest into Setup Engine, verifies the downloaded HTTP response, and only then invokes the official installer on that same local `.tgz`. The script sets `OPENCLAW_RUN_E2E` and `OPENCLAW_RUN_MXC_E2E`, runs the real Gateway MXC proofs, and fails if the MXC proof skips. `-AllowSkip` is only for documenting a non-MXC host, not for merge validation of MXC-related work. +- When reproducing this manually against an existing Gateway, make sure its version-appropriate allowlist includes `system.run`, `system.run.prepare`, and `system.which`: use `gateway.nodes.commands.allow` for current/frozen packages, or `gateway.nodes.allowCommands` for `2026.6.11` and `2026.7.2-beta.3`. Then approve any `pending-reapproval` request with `openclaw nodes approve `. The node can advertise `system.run` while the Gateway still blocks it until both gates are updated. ```powershell .\build.ps1 diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md index 58397a3bc..92acd7d59 100644 --- a/docs/gateway-node-integration.md +++ b/docs/gateway-node-integration.md @@ -50,7 +50,10 @@ REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"] SMS_DANGEROUS_COMMANDS = ["sms.send", "sms.search"] ``` -Even macOS doesn't get `camera.snap` or `camera.clip` by default! They must be added via `gateway.nodes.allowCommands`. +Even macOS doesn't get `camera.snap` or `camera.clip` by default! They must be +added through the version-appropriate allowlist: current/frozen +`gateway.nodes.commands.allow`, or legacy `gateway.nodes.allowCommands` on +`2026.6.11` and `2026.7.2-beta.3`. ### 1.3 How to Enable Privacy-Sensitive Commands for Windows @@ -63,16 +66,22 @@ allow camera capture or screen recording: { gateway: { nodes: { - allowCommands: [ - "camera.snap", - "camera.clip", - "screen.record", - ] + commands: { + allow: [ + "camera.snap", + "camera.clip", + "screen.record", + ] + } } } } ``` +The example above is the current/frozen schema. On `2026.6.11` or +`2026.7.2-beta.3`, put the same array directly under +`gateway.nodes.allowCommands`. + After changing config: ```bash openclaw gateway restart @@ -98,13 +107,16 @@ openclaw nodes approve Then reconnect the node and verify the effective command and capability counts update. Pending declarations are never effective before approval. Older gateways that do not report pending reapproval fields may still require rejecting and re-pairing the node. -### 1.5 `denyCommands` +### 1.5 Explicit deny list You can also explicitly deny commands: ```json5 -{ gateway: { nodes: { denyCommands: ["system.run"] } } } +{ gateway: { nodes: { commands: { deny: ["system.run"] } } } } ``` -`denyCommands` wins over `allowCommands`. +The current/frozen `gateway.nodes.commands.deny` key wins over +`gateway.nodes.commands.allow`. On `2026.6.11` and `2026.7.2-beta.3`, use the +legacy equivalents `gateway.nodes.denyCommands` and +`gateway.nodes.allowCommands`. --- @@ -195,9 +207,12 @@ defaults as a stricter, canonical-platform path: conservative allowlist. Our node should therefore send canonical Windows metadata. SetupEngine also -writes `gateway.nodes.allowCommands` from its enabled capability configuration +writes `gateway.nodes.commands.allow` from its enabled capability configuration for local WSL gateway installs so the first-party Windows companion flow has an explicit gateway policy matching the node's advertised commands. +The production-pinned `2026.6.11` LKG and CI-pinned `2026.7.2-beta.3` predate +that schema migration, so SetupEngine writes their equivalent +`gateway.nodes.allowCommands` key only when either exact version is selected. --- @@ -314,7 +329,7 @@ Recommended gateway defaults: | Command bucket | Windows default? | Reason | |----------------|------------------|--------| | Safe declared companion commands: `canvas.*`, `camera.list`, `location.get`, `screen.snapshot`, `device.info`, `device.status` | Yes | Matches macOS parity and only applies when declared by the node | -| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` | +| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires the version-appropriate explicit allowlist | | Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior | For the first-party Windows companion node, the practical local solution is: @@ -326,13 +341,17 @@ For the first-party Windows companion node, the practical local solution is: ### 5.1 Gateway Node Allowlist Configuration -`gateway.nodes.allowCommands` is the explicit opt-in list the gateway uses after -platform defaults. It should contain exact command names, not broad wildcard -grants, and should not be needed for the normal first-party Windows companion -commands that are allowed by canonical Windows platform policy and declared by -the live node. +`gateway.nodes.commands.allow` is the current/frozen explicit opt-in list the +gateway uses after platform defaults. Exact legacy versions `2026.6.11` and +`2026.7.2-beta.3` use `gateway.nodes.allowCommands` instead. Either form should +contain exact command names, not broad wildcard grants, and should not be needed +for normal first-party Windows companion commands allowed by canonical Windows +platform policy and declared by the live node. -`gateway.nodes.denyCommands` can be used as a final explicit blocklist when you want to suppress a command even if a platform default or allowlist entry would otherwise allow it. +The current/frozen `gateway.nodes.commands.deny` key can be used as a final +explicit blocklist; those two legacy versions use `gateway.nodes.denyCommands`. +The deny list suppresses a command even if a platform default or allowlist entry +would otherwise allow it. Privacy-sensitive commands should stay out of the default safe list and should only be added deliberately: @@ -342,7 +361,12 @@ camera.clip screen.record ``` -After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyCommands`, check Command Center for `pending-reapproval`. Copy and run its exact `openclaw nodes approve ` command, reconnect the Windows node, and verify the effective command and capability counts update. A gateway restart alone does not approve pending declarations. Older gateways without pending reapproval diagnostics may still require re-pairing. +After changing the version-appropriate allow or deny key, check Command Center +for `pending-reapproval`. Copy and run its exact +`openclaw nodes approve ` command, reconnect the Windows node, +and verify the effective command and capability counts update. A gateway restart +alone does not approve pending declarations. Older gateways without pending +reapproval diagnostics may still require re-pairing. ### 5.2 Immediate Code Fixes (This Branch) @@ -365,7 +389,8 @@ After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyComman `platform: "windows"` and `deviceFamily: "Windows"` so the gateway can apply desktop command policy without a global allowlist workaround. - [x] **Keep privacy-sensitive commands explicit opt-in** — `camera.snap`, - `camera.clip`, and `screen.record` remain behind `gateway.nodes.allowCommands`. + `camera.clip`, and `screen.record` remain behind the version-appropriate + explicit allowlist. - [x] **Add `canvas.a2ui.pushJSONL`** — current Mac supports it as a legacy JSONL alias; Windows routes it through the same A2UI push handler The gateway still enforces both gates: the node must declare a command in @@ -377,8 +402,8 @@ only declare `system.run` / `system.which` remain exec-only. When shipping the Windows node, README/wiki should tell users that normal first-party companion commands are available after pairing when the node reports canonical Windows metadata. Users should add `camera.snap`, `camera.clip`, and -`screen.record` to `gateway.nodes.allowCommands` only when they explicitly want -to allow privacy-sensitive camera or screen capture. +`screen.record` to the version-appropriate explicit allowlist only when they +want to allow privacy-sensitive camera or screen capture. > The Windows tray Command Center (`openclaw://commandcenter`) surfaces policy > problems directly, including pending pairing approval and privacy-sensitive > opt-ins. diff --git a/scripts/Start-E2EGatewayPackageHost.ps1 b/scripts/Start-E2EGatewayPackageHost.ps1 new file mode 100644 index 000000000..471797188 --- /dev/null +++ b/scripts/Start-E2EGatewayPackageHost.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Hosts an immutable OpenClaw package inside a disposable WSL distro. + +.DESCRIPTION + Verifies the package SHA-256, creates a named Ubuntu distro, copies the + package into that distro, and starts a loopback-free HTTP server reachable + by the separate app-owned distro provisioned by the Windows E2E fixture. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$PackagePath, + [Parameter(Mandatory = $true)][ValidatePattern("^[a-fA-F0-9]{64}$")][string]$ExpectedSha256, + [Parameter(Mandatory = $true)][ValidatePattern("^OpenClawE2EPackageHost-[A-Za-z0-9-]+$")][string]$DistroName, + [Parameter(Mandatory = $true)][string]$InstallLocation, + [Parameter(Mandatory = $true)][ValidatePattern("^[A-Za-z0-9._-]+$")][string]$OwnershipToken, + [Parameter(Mandatory = $true)][string]$GitHubOutput, + [ValidateRange(1024, 65535)][int]$Port = 38677 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-RegisteredWslBasePath { + param([Parameter(Mandatory = $true)][string]$DistributionName) + + $registrations = @( + Get-ChildItem -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss" -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty -LiteralPath $_.PSPath } | + Where-Object { $_.DistributionName -eq $DistributionName } + ) + if ($registrations.Count -ne 1 -or [string]::IsNullOrWhiteSpace($registrations[0].BasePath)) { + throw "Could not prove the registered base path for WSL distro '$DistributionName'." + } + + $basePath = [Environment]::ExpandEnvironmentVariables([string]$registrations[0].BasePath) + if ($basePath.StartsWith("\\?\", [StringComparison]::Ordinal)) { + $basePath = $basePath.Substring(4) + } + return [System.IO.Path]::GetFullPath($basePath).TrimEnd("\") +} + +function Write-OwnershipMarker { + param( + [Parameter(Mandatory = $true)][string]$MarkerPath, + [Parameter(Mandatory = $true)][string]$Token, + [Parameter(Mandatory = $true)][bool]$RegistrationProven, + [AllowNull()][string]$RegisteredBasePath + ) + + $temporaryMarkerPath = "$MarkerPath.tmp" + [ordered]@{ + ownership_token = $Token + registration_proven = $RegistrationProven + registered_base_path = $RegisteredBasePath + } | ConvertTo-Json -Compress | Set-Content -LiteralPath $temporaryMarkerPath -NoNewline + Move-Item -LiteralPath $temporaryMarkerPath -Destination $MarkerPath -Force +} + +function Read-OwnershipMarker { + param( + [Parameter(Mandatory = $true)][string]$MarkerPath, + [Parameter(Mandatory = $true)][string]$Token + ) + + if (-not (Test-Path -LiteralPath $MarkerPath)) { + throw "Ownership marker is missing: '$MarkerPath'." + } + $marker = Get-Content -LiteralPath $MarkerPath -Raw | ConvertFrom-Json + if ($marker.PSObject.Properties.Name -notcontains "ownership_token" -or + $marker.PSObject.Properties.Name -notcontains "registration_proven" -or + $marker.PSObject.Properties.Name -notcontains "registered_base_path") { + throw "Ownership marker is invalid: '$MarkerPath'." + } + if ([string]$marker.ownership_token -ne $Token) { + throw "Ownership marker belongs to a different workflow attempt." + } + return $marker +} + +$package = Get-Item -LiteralPath $PackagePath +$actualSha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { + throw "Gateway composed package SHA-256 mismatch: expected $ExpectedSha256, got $actualSha256." +} + +$existingDistros = @(& wsl.exe --list --quiet) -replace "`0", "" | ForEach-Object { $_.Trim() } +if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate existing WSL distros (exit $LASTEXITCODE)." +} +if ($existingDistros -contains $DistroName) { + throw "Refusing to reuse existing WSL distro '$DistroName'." +} + +$installLocationFull = [System.IO.Path]::GetFullPath($InstallLocation) +if ((Split-Path -Leaf $installLocationFull) -ne $DistroName) { + throw "Install location leaf must match the disposable distro name '$DistroName'." +} +$installParent = Split-Path -Parent $installLocationFull +if (-not (Test-Path -LiteralPath $installParent -PathType Container)) { + throw "Install location parent does not exist: '$installParent'." +} +if (Test-Path -LiteralPath $installLocationFull) { + throw "Refusing to reuse existing install location '$installLocationFull'." +} +$preRegistrationMarkerPath = Join-Path $installParent ".$DistroName.openclaw-e2e-owner.json" +if (Test-Path -LiteralPath $preRegistrationMarkerPath) { + throw "Refusing to reuse existing pre-registration marker '$preRegistrationMarkerPath'." +} + +$preRegistrationMarkerCreated = $false +$installAttempted = $false +$installSucceeded = $false +try { + Write-OwnershipMarker -MarkerPath $preRegistrationMarkerPath -Token $OwnershipToken -RegistrationProven $false -RegisteredBasePath $null + $preRegistrationMarkerCreated = $true + $installAttempted = $true + & wsl.exe --install --distribution Ubuntu-24.04 --name $DistroName --location $installLocationFull --no-launch --web-download + if ($LASTEXITCODE -ne 0) { + throw "Failed to create package-host distro '$DistroName' (exit $LASTEXITCODE)." + } + $registeredBasePath = Get-RegisteredWslBasePath -DistributionName $DistroName + if (-not [string]::Equals($registeredBasePath, $installLocationFull.TrimEnd("\"), [StringComparison]::OrdinalIgnoreCase)) { + throw "Registered base path for '$DistroName' does not match its requested install location." + } + $markerPath = Join-Path $installLocationFull ".openclaw-e2e-owner.json" + Write-OwnershipMarker -MarkerPath $markerPath -Token $OwnershipToken -RegistrationProven $true -RegisteredBasePath $registeredBasePath + Remove-Item -LiteralPath $preRegistrationMarkerPath -Force + $preRegistrationMarkerCreated = $false + $installSucceeded = $true + + $drive = $package.FullName.Substring(0, 1).ToLowerInvariant() + $tail = $package.FullName.Substring(2).Replace("\", "/") + $mountedPackagePath = "/mnt/$drive$tail" + $hostDirectory = "/tmp/openclaw-e2e-package-host" + $hostPackageName = "openclaw-composed-$actualSha256.tgz" + $hostPackagePath = "$hostDirectory/$hostPackageName" + + & wsl.exe -d $DistroName -u root -- mkdir -p $hostDirectory + if ($LASTEXITCODE -ne 0) { throw "Failed to create package-host directory." } + & wsl.exe -d $DistroName -u root -- cp -- $mountedPackagePath $hostPackagePath + if ($LASTEXITCODE -ne 0) { throw "Failed to copy the composed gateway package into the package-host distro." } + $hostHashOutput = [string](& wsl.exe -d $DistroName -u root -- sha256sum -- $hostPackagePath) + if ($LASTEXITCODE -ne 0 -or $hostHashOutput -notmatch "^([a-fA-F0-9]{64})\s") { + throw "Failed to verify the copied composed gateway package inside the package-host distro." + } + $hostSha256 = $Matches[1].ToLowerInvariant() + if ($hostSha256 -ne $actualSha256) { + throw "Copied composed gateway package SHA-256 mismatch: expected $actualSha256, got $hostSha256." + } + & wsl.exe -d $DistroName -u root -- chmod 0444 $hostPackagePath + if ($LASTEXITCODE -ne 0) { throw "Failed to make the hosted composed gateway package read-only." } + & wsl.exe -d $DistroName -u root -- chmod 0555 $hostDirectory + if ($LASTEXITCODE -ne 0) { throw "Failed to make the composed gateway package host directory read-only." } + + $hostAddresses = @( + ((& wsl.exe -d $DistroName -u root -- hostname -I) -split "\s+") | + Where-Object { + $address = $null + [System.Net.IPAddress]::TryParse($_, [ref]$address) -and + $address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork + } + ) + $hostAddress = $hostAddresses[0] + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($hostAddress)) { + throw "Failed to resolve package-host distro address." + } + + $serverCommand = "setsid -f python3 -m http.server $Port --bind 0.0.0.0 --directory $hostDirectory >/tmp/openclaw-e2e-package-host.log 2>&1" + & wsl.exe -d $DistroName -u root -- bash -lc $serverCommand + if ($LASTEXITCODE -ne 0) { throw "Failed to start the package-host HTTP server." } + + $packageSpec = "http://${hostAddress}:$Port/$hostPackageName" + $readinessDeadline = [DateTime]::UtcNow.AddSeconds(30) + $lastReadinessError = $null + do { + try { + $response = Invoke-WebRequest -Uri $packageSpec -Method Head -TimeoutSec 5 -UseBasicParsing + if ($response.StatusCode -eq 200) { + $lastReadinessError = $null + break + } + $lastReadinessError = "HTTP $($response.StatusCode)" + } catch { + $lastReadinessError = $_.Exception.Message + } + Start-Sleep -Milliseconds 500 + } while ([DateTime]::UtcNow -lt $readinessDeadline) + if ($null -ne $lastReadinessError) { + throw "Package-host readiness probe failed for the advertised URL: $lastReadinessError" + } + + "package_spec=$packageSpec" | Out-File -FilePath $GitHubOutput -Encoding utf8 -Append + "package_sha256=$actualSha256" | Out-File -FilePath $GitHubOutput -Encoding utf8 -Append + "distro_name=$DistroName" | Out-File -FilePath $GitHubOutput -Encoding utf8 -Append + Write-Host "Gateway E2E composed-package host ready: distro=$DistroName sha256=$actualSha256" +} catch { + $startupError = $_ + $cleanupComplete = $false + if ($installAttempted -and -not $installSucceeded) { + try { + $registeredDistros = @(& wsl.exe --list --quiet) -replace "`0", "" | ForEach-Object { $_.Trim() } + if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate WSL state after the install failure." + } + $preRegistrationMarker = Read-OwnershipMarker -MarkerPath $preRegistrationMarkerPath -Token $OwnershipToken + if ([bool]$preRegistrationMarker.registration_proven) { + throw "Pre-registration marker unexpectedly claims completed registration." + } + if ($registeredDistros -notcontains $DistroName) { + if (Test-Path -LiteralPath $installLocationFull) { + Remove-Item -LiteralPath $installLocationFull -Recurse -Force + } + Remove-Item -LiteralPath $preRegistrationMarkerPath -Force + $preRegistrationMarkerCreated = $false + $cleanupComplete = $true + } else { + $registeredBasePath = Get-RegisteredWslBasePath -DistributionName $DistroName + if (-not [string]::Equals($registeredBasePath, $installLocationFull.TrimEnd("\"), [StringComparison]::OrdinalIgnoreCase)) { + throw "Registered base path after the failed install does not match the owned install location." + } + $markerPath = Join-Path $installLocationFull ".openclaw-e2e-owner.json" + Write-OwnershipMarker -MarkerPath $markerPath -Token $OwnershipToken -RegistrationProven $true -RegisteredBasePath $registeredBasePath + Remove-Item -LiteralPath $preRegistrationMarkerPath -Force + $preRegistrationMarkerCreated = $false + } + } catch { + Write-Warning "Could not prove that pre-registration cleanup was safe: $($_.Exception.Message)" + } + } + if ($installAttempted -and -not $cleanupComplete) { + try { + & (Join-Path $PSScriptRoot "Stop-E2EGatewayPackageHost.ps1") ` + -DistroName $DistroName ` + -InstallLocation $installLocationFull ` + -OwnershipToken $OwnershipToken + } catch { + Write-Warning "Package-host cleanup after failed startup also failed: $($_.Exception.Message)" + } + } elseif ($preRegistrationMarkerCreated) { + Write-Warning "Preserving pre-registration marker because cleanup ownership could not be proven: '$preRegistrationMarkerPath'." + } + throw $startupError +} diff --git a/scripts/Stop-E2EGatewayPackageHost.ps1 b/scripts/Stop-E2EGatewayPackageHost.ps1 new file mode 100644 index 000000000..0d2a40eaa --- /dev/null +++ b/scripts/Stop-E2EGatewayPackageHost.ps1 @@ -0,0 +1,97 @@ +<# +.SYNOPSIS + Removes the exact disposable WSL package-host distro created for E2E. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidatePattern("^OpenClawE2EPackageHost-[A-Za-z0-9-]+$")][string]$DistroName, + [Parameter(Mandatory = $true)][string]$InstallLocation, + [Parameter(Mandatory = $true)][ValidatePattern("^[A-Za-z0-9._-]+$")][string]$OwnershipToken +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-RegisteredWslBasePath { + param([Parameter(Mandatory = $true)][string]$DistributionName) + + $registrations = @( + Get-ChildItem -LiteralPath "HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss" -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty -LiteralPath $_.PSPath } | + Where-Object { $_.DistributionName -eq $DistributionName } + ) + if ($registrations.Count -ne 1 -or [string]::IsNullOrWhiteSpace($registrations[0].BasePath)) { + throw "Could not prove the registered base path for WSL distro '$DistributionName'." + } + + $basePath = [Environment]::ExpandEnvironmentVariables([string]$registrations[0].BasePath) + if ($basePath.StartsWith("\\?\", [StringComparison]::Ordinal)) { + $basePath = $basePath.Substring(4) + } + return [System.IO.Path]::GetFullPath($basePath).TrimEnd("\") +} + +$installLocationFull = [System.IO.Path]::GetFullPath($InstallLocation) +if ((Split-Path -Leaf $installLocationFull) -ne $DistroName) { + throw "Install location leaf must match the disposable distro name '$DistroName'." +} + +$existingDistros = @(& wsl.exe --list --quiet) -replace "`0", "" | ForEach-Object { $_.Trim() } +if ($LASTEXITCODE -ne 0) { + throw "Failed to enumerate existing WSL distros (exit $LASTEXITCODE); no cleanup was attempted." +} +$isRegistered = $existingDistros -contains $DistroName +if (-not (Test-Path -LiteralPath $installLocationFull)) { + if ($isRegistered) { + throw "Refusing to remove registered distro '$DistroName' without its ownership marker." + } + Write-Host "Gateway E2E package-host state already absent: $DistroName" + exit 0 +} + +$markerPath = Join-Path $installLocationFull ".openclaw-e2e-owner.json" +if (-not (Test-Path -LiteralPath $markerPath)) { + throw "Refusing to clean install location without ownership marker: '$installLocationFull'." +} +$marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json +if ($marker.PSObject.Properties.Name -notcontains "ownership_token" -or + $marker.PSObject.Properties.Name -notcontains "registration_proven" -or + $marker.PSObject.Properties.Name -notcontains "registered_base_path") { + throw "Refusing to clean install location with an invalid ownership marker." +} +if ([string]$marker.ownership_token -ne $OwnershipToken) { + throw "Refusing to clean install location owned by a different workflow attempt." +} + +if ($isRegistered) { + $registeredBasePath = Get-RegisteredWslBasePath -DistributionName $DistroName + if (-not [string]::Equals($registeredBasePath, $installLocationFull.TrimEnd("\"), [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to unregister '$DistroName': its registered base path does not match the owned install location." + } + & wsl.exe --terminate $DistroName + if ($LASTEXITCODE -ne 0) { throw "Failed to terminate package-host distro '$DistroName'." } + & wsl.exe --unregister $DistroName + if ($LASTEXITCODE -ne 0) { throw "Failed to unregister package-host distro '$DistroName'." } + + $remainingDistros = @(& wsl.exe --list --quiet) -replace "`0", "" | ForEach-Object { $_.Trim() } + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify WSL state after unregistering '$DistroName'; install storage was preserved." + } + if ($remainingDistros -contains $DistroName) { + throw "WSL distro '$DistroName' is still registered; install storage was preserved." + } +} else { + $recordedBasePath = if ($null -eq $marker.registered_base_path) { + "" + } else { + [System.IO.Path]::GetFullPath([string]$marker.registered_base_path).TrimEnd("\") + } + if (-not [bool]$marker.registration_proven -or + -not [string]::Equals($recordedBasePath, $installLocationFull.TrimEnd("\"), [StringComparison]::OrdinalIgnoreCase)) { + Write-Warning "Preserving unregistered install location because no durable BasePath proof exists: '$installLocationFull'." + exit 0 + } +} + +Remove-Item -LiteralPath $installLocationFull -Recurse -Force +Write-Host "Gateway E2E package-host state removed: $DistroName" diff --git a/scripts/validate-mxc-e2e.ps1 b/scripts/validate-mxc-e2e.ps1 index cdd2b8be4..99af0467f 100644 --- a/scripts/validate-mxc-e2e.ps1 +++ b/scripts/validate-mxc-e2e.ps1 @@ -148,10 +148,65 @@ function Set-ProcessEnv { [Environment]::SetEnvironmentVariable($Name, $Value, "Process") } +function Assert-ReviewedComposedGatewayPackage { + param( + [Parameter(Mandatory = $true)][string]$PackageSpec, + [Parameter(Mandatory = $true)][string]$ExpectedSha256, + [Parameter(Mandatory = $true)][string]$HostDistroName + ) + + $composedUri = $null + if (-not [Uri]::TryCreate($PackageSpec, [UriKind]::Absolute, [ref]$composedUri) -or + ($composedUri.Scheme -ne [Uri]::UriSchemeHttp -and $composedUri.Scheme -ne [Uri]::UriSchemeHttps) -or + -not $composedUri.AbsolutePath.EndsWith(".tgz", [StringComparison]::OrdinalIgnoreCase)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must be an absolute HTTP(S) URL for a reviewed composed .tgz package." + } + if (-not [string]::IsNullOrEmpty($composedUri.UserInfo)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC cannot contain credentials." + } + if ($ExpectedSha256 -notmatch "^[a-fA-F0-9]{64}$") { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 must be the reviewed composed-package SHA-256." + } + if ($HostDistroName -notmatch "^OpenClawE2EPackageHost-[A-Za-z0-9-]+$") { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO must identify the disposable package host." + } + + $normalizedSha256 = $ExpectedSha256.ToLowerInvariant() + $expectedFileName = "openclaw-composed-$normalizedSha256.tgz" + if (-not [string]::Equals( + [IO.Path]::GetFileName($composedUri.AbsolutePath), + $expectedFileName, + [StringComparison]::Ordinal)) { + throw "Composed gateway package URL is not bound to the reviewed SHA-256. Use Start-E2EGatewayPackageHost.ps1 output." + } + + $hostPackagePath = "/tmp/openclaw-e2e-package-host/$expectedFileName" + $hostHashOutput = [string](& wsl.exe -d $HostDistroName -u root -- sha256sum -- $hostPackagePath) + if ($LASTEXITCODE -ne 0 -or $hostHashOutput -notmatch "^([a-fA-F0-9]{64})\s" -or + $Matches[1].ToLowerInvariant() -ne $normalizedSha256) { + throw "The disposable package host does not contain the reviewed composed gateway package." + } + $hostMode = [string](& wsl.exe -d $HostDistroName -u root -- stat -c "%a" -- $hostPackagePath) + if ($LASTEXITCODE -ne 0 -or $hostMode.Trim() -ne "444") { + throw "The reviewed composed gateway package is not read-only in the disposable package host." + } + $hostAddresses = @( + ((& wsl.exe -d $HostDistroName -u root -- hostname -I) -split "\s+") | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if ($LASTEXITCODE -ne 0 -or $hostAddresses -notcontains $composedUri.Host) { + throw "The composed gateway package URL is not served by the proven disposable package host." + } + + Write-Host "Composed gateway package identity proven: distro=$HostDistroName sha256=$normalizedSha256" -ForegroundColor Green +} + $trackedEnvVars = @( "OPENCLAW_REPO_ROOT", "OPENCLAW_RUN_E2E", - "OPENCLAW_RUN_MXC_E2E" + "OPENCLAW_RUN_MXC_E2E", + "OPENCLAW_E2E_GATEWAY_SOURCE", + "OPENCLAW_E2E_GATEWAY_VERSION" ) $previousEnv = @{} foreach ($name in $trackedEnvVars) { @@ -159,9 +214,26 @@ foreach ($name in $trackedEnvVars) { } try { + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must name the reviewed composed HTTP(S) .tgz package before formal MXC validation." + } + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 must identify the reviewed composed package before formal MXC validation." + } + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO must identify the disposable package host before formal MXC validation." + } + + Assert-ReviewedComposedGatewayPackage ` + -PackageSpec $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC ` + -ExpectedSha256 $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 ` + -HostDistroName $env:OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO + Set-ProcessEnv -Name "OPENCLAW_REPO_ROOT" -Value $repoRoot Set-ProcessEnv -Name "OPENCLAW_RUN_E2E" -Value "1" Set-ProcessEnv -Name "OPENCLAW_RUN_MXC_E2E" -Value "1" + Set-ProcessEnv -Name "OPENCLAW_E2E_GATEWAY_VERSION" -Value $null + Set-ProcessEnv -Name "OPENCLAW_E2E_GATEWAY_SOURCE" -Value "composed" Write-Host "OpenClaw MXC validation" Write-Host " Repo: $repoRoot" diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 3e7c4c57a..8fb93e945 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -57,6 +57,7 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private readonly IClock _clock; private readonly Func? _shouldStartNodeConnection; private readonly Func _reconnectDelay; + private readonly TimeSpan _sharedTokenValidationTimeout; private readonly SemaphoreSlim _transitionSemaphore = new(1, 1); private readonly SemaphoreSlim _nodeStartSemaphore = new(1, 1); private readonly object _nodeOperationLock = new(); @@ -117,7 +118,8 @@ public GatewayConnectionManager( ConnectionDiagnostics? diagnostics = null, ISshTunnelManager? tunnelManager = null, Func? shouldStartNodeConnection = null, - Func? reconnectDelay = null) + Func? reconnectDelay = null, + TimeSpan? sharedTokenValidationTimeout = null) { _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); @@ -130,6 +132,7 @@ public GatewayConnectionManager( _clock = clock ?? SystemClock.Instance; _shouldStartNodeConnection = shouldStartNodeConnection; _reconnectDelay = reconnectDelay ?? Task.Delay; + _sharedTokenValidationTimeout = sharedTokenValidationTimeout ?? TimeSpan.FromSeconds(15); _diagnostics = diagnostics ?? new ConnectionDiagnostics(clock: clock); _diagnostics.EventRecorded += (_, e) => DiagnosticEvent?.Invoke(this, e); @@ -832,11 +835,20 @@ private async Task ValidateSharedTokenBeforeReplacementAsync( try { - await client.ConnectAsync(); - var completed = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(15))); - if (completed != completion.Task) + var connectTask = client.ConnectAsync(); + var timeoutTask = Task.Delay(_sharedTokenValidationTimeout); + var completed = await Task.WhenAny(connectTask, completion.Task, timeoutTask); + if (completed == timeoutTask) return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, "Timed out validating shared gateway token"); + if (completed == connectTask) + { + await connectTask; + completed = await Task.WhenAny(completion.Task, timeoutTask); + if (completed == timeoutTask) + return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, "Timed out validating shared gateway token"); + } + return await completion.Task; } catch (Exception ex) diff --git a/src/OpenClaw.Connection/GatewayRollbackPointManager.cs b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs new file mode 100644 index 000000000..2180f65d4 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs @@ -0,0 +1,1314 @@ +using OpenClaw.Shared.Mcp; +using System.Security.Cryptography; +using System.Security; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace OpenClaw.Connection; + +public enum GatewayRollbackPointPhase +{ + Creating, + DistroStopped, + Verified, + UpdateInProgress, + PostUpdateHealthy, + RestoreStaged, + UnregisterPending, + DistroUnregistered, + ImportPending, + Imported, + RestoreCancelled, + RestoreHealthy, + Failed +} + +public enum GatewayRollbackPointVerificationStatus +{ + Pending, + Verified, + Failed +} + +public sealed record GatewayRollbackPointManifest +{ + public int SchemaVersion { get; init; } = 3; + public required string Id { get; init; } + public required string DistroName { get; init; } + public required string GatewayId { get; init; } + public required string OpenClawVersion { get; init; } + public required string TargetOpenClawVersion { get; init; } + public required string InternalIdentitySha256 { get; init; } + public required uint RegistrationVersion { get; init; } + public required uint RegistrationDefaultUid { get; init; } + public required uint RegistrationFlags { get; init; } + public required string DefaultUserName { get; init; } + public required uint DefaultUserUid { get; init; } + public required DateTimeOffset CreatedAtUtc { get; init; } + public required DateTimeOffset UpdatedAtUtc { get; init; } + public required string VhdSha256 { get; init; } + public required long VhdSizeBytes { get; init; } + public required GatewayRollbackPointVerificationStatus VerificationStatus { get; init; } + public required GatewayRollbackPointPhase Phase { get; init; } + public required bool WasKnownGood { get; init; } + public required bool RestoreEligible { get; init; } + public string? NodeCommandAllowSnapshotJson { get; init; } + public string? LastFailureCode { get; init; } +} + +public sealed record GatewayRollbackPointInfo( + string Id, + string DistroName, + string OpenClawVersion, + DateTimeOffset CreatedAtUtc, + GatewayRollbackPointVerificationStatus VerificationStatus, + GatewayRollbackPointPhase Phase, + long ApproximateSizeBytes, + bool RestoreEligible, + string? FailureCode); + +public sealed record GatewayRollbackRetentionPolicy(int RetainPreviousVersions, TimeSpan? RetainYoungerThan) +{ + public static GatewayRollbackRetentionPolicy Default { get; } = new(1, null); + + public bool RetainIndefinitely => RetainPreviousVersions == -1; + + public void Validate() + { + if (RetainPreviousVersions is not (1 or 2 or -1)) + throw new ArgumentOutOfRangeException(nameof(RetainPreviousVersions), "Retention must be 1, 2, or -1 for indefinitely."); + if (RetainYoungerThan is { } age && age <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(RetainYoungerThan), "Age retention must be positive when enabled."); + } +} + +public enum GatewayRollbackOperationState +{ + Created, + Restored, + ConfirmationRequired, + NotFound, + VerificationFailed, + OwnershipMismatch, + SameNameCollision, + InstallPathCollision, + TerminationFailed, + UnregisterFailed, + ImportPending, + Cancelled, + ResumeRequired, + AmbiguousRecovery, + Failed +} + +public sealed record GatewayRollbackOperationResult( + GatewayRollbackOperationState State, + GatewayRollbackPointManifest? Point = null, + string? FailureCode = null, + int? ExitCode = null) +{ + public bool Success => State is GatewayRollbackOperationState.Created + or GatewayRollbackOperationState.Restored + or GatewayRollbackOperationState.Cancelled; +} + +/// +/// Owns immutable, offline VHD rollback points for one Companion-owned WSL distro. +/// Normal update never unregisters or imports. Those lifecycle operations are +/// reachable only through the explicit restore method and a matching point id. +/// +public sealed partial class GatewayRollbackPointManager +{ + private const string ManifestFileName = "manifest.json"; + private const string RollbackVhdFileName = "rollback.vhdx"; + private const string LiveVhdFileName = "ext4.vhdx"; + private const string VhdxSignature = "vhdxfile"; + private readonly IWslCommandRunner _commandRunner; + private readonly string _ownedDistroName; + private readonly string _localDataRoot; + private readonly string _pointsRoot; + private readonly string _stagingRoot; + private readonly Func _utcNow; + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public GatewayRollbackPointManager( + IWslCommandRunner commandRunner, + string localDataRoot, + string ownedDistroName, + Func? utcNow = null) + { + ArgumentNullException.ThrowIfNull(commandRunner); + ArgumentException.ThrowIfNullOrWhiteSpace(localDataRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(ownedDistroName); + if (!DistroNameRegex().IsMatch(ownedDistroName)) + throw new ArgumentException("Owned distro name is not a safe WSL/path name.", nameof(ownedDistroName)); + + _commandRunner = commandRunner; + _ownedDistroName = ownedDistroName; + _localDataRoot = NormalizePath(localDataRoot); + _pointsRoot = NormalizePath(Path.Combine(_localDataRoot, "gateway-rollback-points", ownedDistroName)); + _stagingRoot = NormalizePath(Path.Combine(_localDataRoot, "gateway-rollback-staging", ownedDistroName)); + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + public string OwnedDistroName => _ownedDistroName; + + public bool HasUnreadableReceipt() + { + if (!Directory.Exists(_pointsRoot)) + return false; + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return true; + + try + { + foreach (var pointDirectory in Directory.EnumerateDirectories(_pointsRoot, "*", SearchOption.TopDirectoryOnly)) + { + var pointId = Path.GetFileName(pointDirectory); + if (!PointIdRegex().IsMatch(pointId)) + continue; + if (!HasSafePathBoundary(_pointsRoot, pointDirectory)) + return true; + + var manifestPath = Path.Combine(pointDirectory, ManifestFileName); + if (!File.Exists(manifestPath) || + IsReparsePoint(manifestPath) || + !TryReadManifest(manifestPath, out var manifest) || + manifest is null || + !string.Equals(manifest.Id, pointId, StringComparison.Ordinal) || + !IsManifestOwned(manifest)) + { + return true; + } + } + + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + return true; + } + } + + public IReadOnlyList List() + { + if (!Directory.Exists(_pointsRoot) || + !HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return []; + + var points = new List(); + foreach (var pointDirectory in Directory.EnumerateDirectories(_pointsRoot, "*", SearchOption.TopDirectoryOnly)) + { + if (!PointIdRegex().IsMatch(Path.GetFileName(pointDirectory)) || + !HasSafePathBoundary(_pointsRoot, pointDirectory)) + { + continue; + } + var manifestPath = Path.Combine(pointDirectory, ManifestFileName); + if (!File.Exists(manifestPath) || IsReparsePoint(manifestPath)) + continue; + if (!TryReadManifest(manifestPath, out var manifest) || manifest is null) + continue; + points.Add(ToInfo(manifest)); + } + + return points.OrderByDescending(point => point.CreatedAtUtc).ThenByDescending(point => point.Id, StringComparer.Ordinal).ToArray(); + } + + public async Task CreateVerifiedAsync( + string distroName, + string gatewayId, + string openClawVersion, + string targetOpenClawVersion, + CancellationToken cancellationToken = default) + { + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (!ExactVersionRegex().IsMatch(openClawVersion) || !ExactVersionRegex().IsMatch(targetOpenClawVersion)) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "version_attestation_invalid"); + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "rollback_receipt_unreadable"); + + var liveRegistration = await ValidateOwnedLiveRegistrationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!liveRegistration.Valid) + return new(liveRegistration.State, FailureCode: liveRegistration.FailureCode); + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || registration.Configuration is null || registration.Configuration.Version != 2) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "registration_configuration_probe_failed"); + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "default_user_probe_failed"); + var identity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (identity is null) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "identity_probe_failed"); + var versionBeforeStop = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(versionBeforeStop, openClawVersion, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "version_attestation_changed"); + + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + EnsurePrivateDirectory(_pointsRoot); + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + var now = _utcNow(); + var pointId = $"{now:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; + var pointDirectory = GetPointDirectory(pointId); + EnsurePrivateDirectory(pointDirectory); + var vhdPath = Path.Combine(pointDirectory, RollbackVhdFileName); + var partialPath = vhdPath + ".partial"; + if (!HasSafePathBoundary(_pointsRoot, pointDirectory) || + !HasSafePathBoundary(pointDirectory, vhdPath) || + !HasSafePathBoundary(pointDirectory, partialPath)) + { + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_point_reparse_boundary"); + } + var manifest = new GatewayRollbackPointManifest + { + Id = pointId, + DistroName = distroName, + GatewayId = gatewayId, + OpenClawVersion = openClawVersion, + TargetOpenClawVersion = targetOpenClawVersion, + InternalIdentitySha256 = identity, + RegistrationVersion = registration.Configuration.Version, + RegistrationDefaultUid = registration.Configuration.DefaultUid, + RegistrationFlags = registration.Configuration.Flags, + DefaultUserName = defaultUser.Name, + DefaultUserUid = defaultUser.Uid, + CreatedAtUtc = now, + UpdatedAtUtc = now, + VhdSha256 = string.Empty, + VhdSizeBytes = 0, + VerificationStatus = GatewayRollbackPointVerificationStatus.Pending, + Phase = GatewayRollbackPointPhase.Creating, + WasKnownGood = true, + RestoreEligible = false + }; + WriteManifest(manifest); + var verifiedManifestCommitted = false; + + try + { + var terminate = await _commandRunner.TerminateDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!terminate.Success) + return Fail(manifest, GatewayRollbackOperationState.TerminationFailed, "terminate_failed", terminate.ExitCode); + + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.DistroStopped); + var export = await _commandRunner.RunAsync( + ["--export", distroName, partialPath, "--vhd"], + cancellationToken).ConfigureAwait(false); + if (!export.Success) + return Fail(manifest, GatewayRollbackOperationState.Failed, "vhd_export_failed", export.ExitCode); + + if (!File.Exists(partialPath)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "vhd_export_missing"); + + File.Move(partialPath, vhdPath, overwrite: false); + var verification = await VerifyVhdAsync(vhdPath, expectedSha256: null, expectedSize: null, cancellationToken) + .ConfigureAwait(false); + if (!verification.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, verification.FailureCode ?? "vhd_verify_failed"); + + // The retained VHD is opaque to the host. Attest the package version and + // distro identity immediately on both sides of the stop/export boundary; + // any concurrent mutation makes the point ineligible before update. + var versionAfterExport = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + var identityAfterExport = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + var registrationAfterExport = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + var defaultUserAfterExport = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(versionAfterExport, openClawVersion, StringComparison.Ordinal) || + !string.Equals(identityAfterExport, identity, StringComparison.Ordinal) || + !registrationAfterExport.Success || + registrationAfterExport.Configuration != registration.Configuration || + defaultUserAfterExport != defaultUser) + { + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "exported_state_attestation_changed"); + } + + manifest = manifest with + { + UpdatedAtUtc = _utcNow(), + VhdSha256 = verification.Sha256!, + VhdSizeBytes = verification.SizeBytes, + VerificationStatus = GatewayRollbackPointVerificationStatus.Verified, + Phase = GatewayRollbackPointPhase.Verified, + RestoreEligible = true, + LastFailureCode = null + }; + WriteManifest(manifest); + verifiedManifestCommitted = true; + return new(GatewayRollbackOperationState.Created, manifest); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or CryptographicException) + { + return Fail(manifest, GatewayRollbackOperationState.Failed, $"{ex.GetType().Name.ToLowerInvariant()}"); + } + finally + { + TryDeleteGeneratedFile(partialPath); + if (!verifiedManifestCommitted) + TryDeleteGeneratedFile(vhdPath); + } + } + + public IReadOnlyList FindPendingUpdates(string? targetOpenClawVersion = null) => + LoadOwnedManifests() + .Where(point => + point.Phase == GatewayRollbackPointPhase.UpdateInProgress && + (targetOpenClawVersion is null || + string.Equals(point.TargetOpenClawVersion, targetOpenClawVersion, StringComparison.Ordinal))) + .OrderByDescending(point => point.CreatedAtUtc) + .ThenByDescending(point => point.Id, StringComparer.Ordinal) + .ToArray(); + + public GatewayRollbackPointManifest? FindPendingUpdate(string gatewayId, string? targetOpenClawVersion = null) => + FindPendingUpdates(targetOpenClawVersion).FirstOrDefault(); + + public IReadOnlyList FindUnresolvedRestores() => + LoadOwnedManifests() + .Where(point => + IsUnresolvedRestorePhase(point.Phase)) + .OrderBy(point => point.CreatedAtUtc) + .ThenBy(point => point.Id, StringComparer.Ordinal) + .ToArray(); + + public async Task VerifyAsync(string pointId, CancellationToken cancellationToken = default) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + return false; + if (manifest.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !manifest.WasKnownGood || !manifest.RestoreEligible) + return false; + + var result = await VerifyVhdAsync( + GetRollbackVhdPath(pointId), manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!result.Verified) + { + WriteManifest(manifest with + { + UpdatedAtUtc = _utcNow(), + VerificationStatus = GatewayRollbackPointVerificationStatus.Failed, + RestoreEligible = false, + LastFailureCode = result.FailureCode ?? "point_verify_failed" + }); + } + return result.Verified; + } + + public async Task AttestLiveDistroAsync( + string pointId, + string distroName, + string expectedExactVersion, + CancellationToken cancellationToken = default) + { + var normalizedExpectedVersion = expectedExactVersion?.Trim(); + if (!IsOwnedDistro(distroName) || + normalizedExpectedVersion is null || + !ExactVersionRegex().IsMatch(normalizedExpectedVersion) || + !TryLoadPoint(pointId, out var manifest) || + manifest is null || + !IsManifestOwned(manifest)) + { + return false; + } + + var registrations = await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false); + var matching = registrations + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matching.Length != 1) + return false; + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + if (!string.Equals(registeredBasePath, GetInstallDirectory(), StringComparison.OrdinalIgnoreCase)) + return false; + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || + registration.Configuration is null || + registration.Configuration.Version != manifest.RegistrationVersion || + registration.Configuration.DefaultUid != manifest.RegistrationDefaultUid || + registration.Configuration.Flags != manifest.RegistrationFlags) + { + return false; + } + + var identity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(identity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return false; + + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null || + defaultUser.Name != manifest.DefaultUserName || + defaultUser.Uid != manifest.DefaultUserUid) + { + return false; + } + + var exactVersion = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + return string.Equals(exactVersion, normalizedExpectedVersion, StringComparison.Ordinal); + } + + public void MarkUpdateInProgress(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.UpdateInProgress); + + public void MarkPostUpdateHealthy(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.PostUpdateHealthy); + + public void MarkRestoreHealthy(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.RestoreHealthy); + + public void MarkImported(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.Imported); + + public GatewayRollbackPointManifest RecordNodeCommandAllowSnapshot(string pointId, string normalizedArrayJson) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + throw new InvalidOperationException("Rollback point is missing or is not owned by this Companion distro."); + if (manifest.Phase != GatewayRollbackPointPhase.Verified || + !IsCompleteCommandArrayJson(normalizedArrayJson)) + { + throw new InvalidOperationException("The node command policy snapshot is invalid for this rollback point."); + } + + var updated = manifest with + { + NodeCommandAllowSnapshotJson = normalizedArrayJson, + UpdatedAtUtc = _utcNow(), + LastFailureCode = null + }; + WriteManifest(updated); + return updated; + } + + public GatewayRollbackOperationResult CancelRestore( + string distroName, + string gatewayId, + string pointId) + { + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "rollback_receipt_unreadable"); + + var unresolved = FindUnresolvedRestores(); + if (unresolved.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "multiple_unresolved_restores"); + if (unresolved.Count == 1 && !string.Equals(unresolved[0].Id, pointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ResumeRequired, unresolved[0], "restore_resume_required"); + if (!TryLoadPoint(pointId, out var manifest) || manifest is null) + return new(GatewayRollbackOperationState.NotFound, FailureCode: "point_not_found"); + if (!IsManifestOwned(manifest) || + (!IsRecoveryReceiptPhase(manifest.Phase) && + !string.Equals(manifest.GatewayId, gatewayId, StringComparison.Ordinal))) + { + return new(GatewayRollbackOperationState.OwnershipMismatch, manifest, "point_ownership_mismatch"); + } + if (manifest.Phase != GatewayRollbackPointPhase.RestoreStaged) + return new(GatewayRollbackOperationState.ResumeRequired, manifest, "restore_resume_required"); + + var stagePath = GetStageVhdPath(pointId); + if (!DeleteGeneratedFile(stagePath, _stagingRoot)) + return new(GatewayRollbackOperationState.VerificationFailed, manifest, "restore_stage_cancel_cleanup_failed"); + + var cancelled = UpdateManifest(manifest, GatewayRollbackPointPhase.RestoreCancelled); + return new(GatewayRollbackOperationState.Cancelled, cancelled); + } + + public async Task RestoreExplicitAsync( + string distroName, + string gatewayId, + string pointId, + string confirmedPointId, + CancellationToken cancellationToken = default) + { + if (!string.Equals(pointId, confirmedPointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ConfirmationRequired, FailureCode: "confirmation_required"); + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "rollback_receipt_unreadable"); + if (!TryLoadPoint(pointId, out var manifest) || manifest is null) + return new(GatewayRollbackOperationState.NotFound, FailureCode: "point_not_found"); + if (!IsManifestOwned(manifest) || + (!IsRecoveryReceiptPhase(manifest.Phase) && + !string.Equals(manifest.GatewayId, gatewayId, StringComparison.Ordinal))) + return new(GatewayRollbackOperationState.OwnershipMismatch, manifest, "point_ownership_mismatch"); + + var pendingUpdates = FindPendingUpdates(); + if (pendingUpdates.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, manifest, "multiple_pending_updates"); + if (pendingUpdates.Count == 1 && + !string.Equals(pendingUpdates[0].Id, pointId, StringComparison.Ordinal)) + { + return new(GatewayRollbackOperationState.ResumeRequired, pendingUpdates[0], "update_recovery_resume_required"); + } + + var unresolved = FindUnresolvedRestores(); + if (unresolved.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, manifest, "multiple_unresolved_restores"); + if (unresolved.Count == 1 && !string.Equals(unresolved[0].Id, pointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ResumeRequired, unresolved[0], "restore_resume_required"); + + if (!await VerifyAsync(pointId, cancellationToken).ConfigureAwait(false)) + { + if (TryLoadPoint(pointId, out var invalidated) && invalidated is not null) + manifest = invalidated; + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "point_verify_failed"); + } + + var stagePath = GetStageVhdPath(pointId); + var installDirectory = GetInstallDirectory(); + var liveVhdPath = Path.Combine(installDirectory, LiveVhdFileName); + + try + { + var distroList = await ListDistroNamesAsync(cancellationToken).ConfigureAwait(false); + if (!distroList.Success) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "distro_list_failed", distroList.ExitCode); + var sameNameCount = distroList.Names.Count(name => string.Equals(name, distroName, StringComparison.OrdinalIgnoreCase)); + if (sameNameCount > 1) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "duplicate_same_name_registration"); + var sameNameExists = sameNameCount == 1; + var initialPreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists, cancellationToken).ConfigureAwait(false); + if (!initialPreflight.Valid) + return Fail(manifest, initialPreflight.State, initialPreflight.FailureCode); + if (sameNameExists && manifest.Phase is GatewayRollbackPointPhase.ImportPending or GatewayRollbackPointPhase.Imported) + { + var existingIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(existingIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "same_name_identity_collision"); + return await FinalizeImportedRegistrationAsync(distroName, manifest, cancellationToken).ConfigureAwait(false); + } + if (!sameNameExists && manifest.Phase == GatewayRollbackPointPhase.Imported) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "restored_registration_missing"); + if (sameNameExists && manifest.Phase == GatewayRollbackPointPhase.DistroUnregistered) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "registration_reappeared_after_unregister"); + + if (PathExists(stagePath) && !HasSafePathBoundary(_stagingRoot, stagePath)) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "restore_stage_reparse_boundary"); + + if (File.Exists(stagePath)) + { + var staged = await VerifyVhdAsync(stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken) + .ConfigureAwait(false); + if (!staged.Verified) + { + if (!DeleteGeneratedFile(stagePath, _stagingRoot)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_invalid_delete_failed"); + var recreated = await CreateVerifiedStageAsync(stagePath, manifest, cancellationToken).ConfigureAwait(false); + if (!recreated) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_recreate_failed"); + } + } + else if (!sameNameExists && File.Exists(liveVhdPath)) + { + var movedStage = await VerifyVhdAsync( + liveVhdPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!movedStage.Verified) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "recovery_import_vhd_mismatch"); + } + else + { + if (!await CreateVerifiedStageAsync(stagePath, manifest, cancellationToken).ConfigureAwait(false)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_recreate_failed"); + } + + if (!IsDestructiveRecoveryPhase(manifest.Phase)) + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.RestoreStaged); + + if (sameNameExists) + { + var existingIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(existingIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "same_name_identity_collision"); + + var terminate = await _commandRunner.TerminateDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!terminate.Success) + return Fail(manifest, GatewayRollbackOperationState.TerminationFailed, "restore_terminate_failed", terminate.ExitCode); + + var destructivePreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists: true, cancellationToken).ConfigureAwait(false); + if (!destructivePreflight.Valid) + return Fail(manifest, destructivePreflight.State, destructivePreflight.FailureCode); + + if (!HasSafePathBoundary(_stagingRoot, stagePath)) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "restore_stage_reparse_boundary"); + var destructiveStage = await VerifyVhdAsync( + stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!destructiveStage.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_changed_before_unregister"); + + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.UnregisterPending); + var unregister = await _commandRunner.UnregisterDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!unregister.Success) + return Fail(manifest, GatewayRollbackOperationState.UnregisterFailed, "restore_unregister_failed", unregister.ExitCode); + } + + if (manifest.Phase is not GatewayRollbackPointPhase.DistroUnregistered + and not GatewayRollbackPointPhase.ImportPending) + { + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.DistroUnregistered); + } + var importPath = await PrepareCanonicalImportVhdAsync( + stagePath, liveVhdPath, manifest, cancellationToken).ConfigureAwait(false); + if (!importPath.Prepared) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, importPath.FailureCode ?? "install_path_collision"); + + var importPreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists: false, cancellationToken).ConfigureAwait(false); + if (!importPreflight.Valid) + return Fail(manifest, importPreflight.State, importPreflight.FailureCode); + + if (manifest.Phase == GatewayRollbackPointPhase.DistroUnregistered) + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.ImportPending); + var promotedVhd = await VerifyVhdAsync( + liveVhdPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!promotedVhd.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_import_vhd_mismatch", preservePhase: true); + var import = await _commandRunner.RunAsync( + ["--import-in-place", distroName, liveVhdPath], cancellationToken).ConfigureAwait(false); + if (!import.Success) + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restore_import_failed", import.ExitCode, preservePhase: true); + + return await FinalizeImportedRegistrationAsync(distroName, manifest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or SecurityException) + { + return Fail(manifest, GatewayRollbackOperationState.Failed, ex.GetType().Name.ToLowerInvariant()); + } + } + + public async Task CleanupAsync( + GatewayRollbackRetentionPolicy policy, + CancellationToken cancellationToken = default) + { + policy.Validate(); + var ownedManifests = LoadOwnedManifests(); + foreach (var unverified in ownedManifests.Where(point => + point.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !point.RestoreEligible)) + { + cancellationToken.ThrowIfCancellationRequested(); + var pointDirectory = GetPointDirectory(unverified.Id); + var rollbackVhdPath = GetRollbackVhdPath(unverified.Id); + DeleteGeneratedFile(rollbackVhdPath, pointDirectory); + DeleteGeneratedFile(rollbackVhdPath + ".partial", pointDirectory); + } + + var manifests = ownedManifests + .Where(point => point.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified && point.RestoreEligible) + .OrderByDescending(point => point.CreatedAtUtc) + .ThenByDescending(point => point.Id, StringComparer.Ordinal) + .ToArray(); + if (manifests.Length == 0 || policy.RetainIndefinitely) + return 0; + + var keep = new HashSet(StringComparer.Ordinal) { manifests[0].Id }; + foreach (var point in manifests.Take(policy.RetainPreviousVersions)) + keep.Add(point.Id); + if (policy.RetainYoungerThan is { } age) + { + var cutoff = _utcNow() - age; + foreach (var point in manifests.Where(point => point.CreatedAtUtc >= cutoff)) + keep.Add(point.Id); + } + + var removed = 0; + foreach (var point in manifests) + { + cancellationToken.ThrowIfCancellationRequested(); + if (keep.Contains(point.Id) || point.Phase is not (GatewayRollbackPointPhase.PostUpdateHealthy or GatewayRollbackPointPhase.RestoreHealthy)) + continue; + if (DeletePointFilesOnly(point.Id)) + removed++; + await Task.Yield(); + } + return removed; + } + + private async Task<(bool Prepared, string? FailureCode)> PrepareCanonicalImportVhdAsync( + string stagePath, + string liveVhdPath, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + var installDirectory = GetInstallDirectory(); + if (!HasSafePathBoundary(_localDataRoot, installDirectory) || + !HasSafePathBoundary(_localDataRoot, liveVhdPath) || + !HasSafePathBoundary(_localDataRoot, stagePath)) + { + return (false, "restore_path_reparse_boundary"); + } + if (Directory.Exists(installDirectory)) + { + var entries = Directory.GetFileSystemEntries(installDirectory); + if (entries.Length > 0) + { + if (entries.Length == 1 && + string.Equals(NormalizePath(entries[0]), NormalizePath(liveVhdPath), StringComparison.OrdinalIgnoreCase) && + File.Exists(liveVhdPath) && + (await VerifyVhdAsync( + liveVhdPath, + manifest.VhdSha256, + manifest.VhdSizeBytes, + cancellationToken).ConfigureAwait(false)).Verified) + { + return (true, null); + } + return (false, "install_path_not_empty"); + } + } + else + { + Directory.CreateDirectory(installDirectory); + if (!HasSafePathBoundary(_localDataRoot, installDirectory)) + return (false, "restore_path_reparse_boundary"); + } + + if (!File.Exists(stagePath)) + return (false, "restore_stage_missing"); + File.Move(stagePath, liveVhdPath, overwrite: false); + return (true, null); + } + + private async Task CreateVerifiedStageAsync( + string stagePath, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + EnsurePrivateDirectory(_stagingRoot); + if (!HasSafePathBoundary(_stagingRoot, stagePath)) + return false; + + var partialPath = stagePath + ".partial"; + if (File.Exists(partialPath) && !DeleteGeneratedFile(partialPath, _stagingRoot)) + return false; + + try + { + await CopyAndFlushAsync(GetRollbackVhdPath(manifest.Id), partialPath, cancellationToken).ConfigureAwait(false); + var partial = await VerifyVhdAsync( + partialPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!partial.Verified) + return false; + + File.Move(partialPath, stagePath, overwrite: false); + var staged = await VerifyVhdAsync( + stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + return staged.Verified; + } + finally + { + DeleteGeneratedFile(partialPath, _stagingRoot); + } + } + + private async Task<(bool Valid, GatewayRollbackOperationState State, string FailureCode)> ValidateRestorePreflightAsync( + string distroName, + string installDirectory, + string liveVhdPath, + bool sameNameExists, + CancellationToken cancellationToken) + { + if (!HasSafePathBoundary(_localDataRoot, installDirectory) || + !HasSafePathBoundary(_localDataRoot, liveVhdPath) || + !HasSafePathBoundary(_localDataRoot, _stagingRoot) || + !HasSafePathBoundary(_localDataRoot, _pointsRoot)) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "restore_path_reparse_boundary"); + } + + var registrations = await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false); + var matching = registrations + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (!sameNameExists) + { + return matching.Length == 0 + ? (true, GatewayRollbackOperationState.Restored, string.Empty) + : (false, GatewayRollbackOperationState.SameNameCollision, "registration_list_disagrees"); + } + + if (matching.Length != 1) + return (false, GatewayRollbackOperationState.SameNameCollision, "registration_missing_or_duplicate"); + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_invalid"); + } + + if (!string.Equals(registeredBasePath, installDirectory, StringComparison.OrdinalIgnoreCase)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_mismatch"); + if (!Directory.Exists(installDirectory) || !File.Exists(liveVhdPath)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registered_install_path_missing"); + + var entries = Directory.GetFileSystemEntries(installDirectory).Select(NormalizePath).ToArray(); + if (entries.Length != 1 || !string.Equals(entries[0], NormalizePath(liveVhdPath), StringComparison.OrdinalIgnoreCase)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "install_path_not_exclusive"); + + return (true, GatewayRollbackOperationState.Restored, string.Empty); + } + + private async Task<(bool Valid, GatewayRollbackOperationState State, string FailureCode)> ValidateOwnedLiveRegistrationAsync( + string distroName, + CancellationToken cancellationToken) + { + var installDirectory = GetInstallDirectory(); + if (!HasSafePathBoundary(_localDataRoot, installDirectory)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_reparse_boundary"); + + var matching = (await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false)) + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matching.Length != 1) + return (false, GatewayRollbackOperationState.OwnershipMismatch, "registration_missing_or_duplicate"); + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_invalid"); + } + + return string.Equals(registeredBasePath, installDirectory, StringComparison.OrdinalIgnoreCase) + ? (true, GatewayRollbackOperationState.Created, string.Empty) + : (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_mismatch"); + } + + private GatewayRollbackOperationResult Fail( + GatewayRollbackPointManifest manifest, + GatewayRollbackOperationState state, + string failureCode, + int? exitCode = null, + bool preservePhase = false) + { + var keepRecoveryPhase = preservePhase || IsRecoveryReceiptPhase(manifest.Phase); + var failed = manifest with + { + UpdatedAtUtc = _utcNow(), + VerificationStatus = manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified + ? manifest.VerificationStatus + : GatewayRollbackPointVerificationStatus.Failed, + Phase = keepRecoveryPhase ? manifest.Phase : GatewayRollbackPointPhase.Failed, + RestoreEligible = manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified && manifest.WasKnownGood, + LastFailureCode = failureCode + }; + WriteManifest(failed); + return new(state, failed, failureCode, exitCode); + } + + private static bool IsDestructiveRecoveryPhase(GatewayRollbackPointPhase phase) => + phase is GatewayRollbackPointPhase.UnregisterPending + or GatewayRollbackPointPhase.DistroUnregistered + or GatewayRollbackPointPhase.ImportPending + or GatewayRollbackPointPhase.Imported; + + private static bool IsUnresolvedRestorePhase(GatewayRollbackPointPhase phase) => + phase is GatewayRollbackPointPhase.RestoreStaged + or GatewayRollbackPointPhase.UnregisterPending + or GatewayRollbackPointPhase.DistroUnregistered + or GatewayRollbackPointPhase.ImportPending + or GatewayRollbackPointPhase.Imported; + + private static bool IsRecoveryReceiptPhase(GatewayRollbackPointPhase phase) => + phase == GatewayRollbackPointPhase.UpdateInProgress || IsUnresolvedRestorePhase(phase); + + private GatewayRollbackPointManifest UpdateManifest( + GatewayRollbackPointManifest manifest, + GatewayRollbackPointPhase phase) + { + var updated = manifest with { Phase = phase, UpdatedAtUtc = _utcNow(), LastFailureCode = null }; + WriteManifest(updated); + return updated; + } + + private void UpdatePhase(string pointId, GatewayRollbackPointPhase phase) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + throw new InvalidOperationException("Rollback point is missing or is not owned by this Companion distro."); + UpdateManifest(manifest, phase); + } + + private async Task ProbeInternalIdentityHashAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, ["sh", "-lc", "cat /etc/machine-id"], cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var identity = result.StandardOutput.Trim(); + if (!MachineIdRegex().IsMatch(identity)) + return null; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + } + + private async Task ProbeDefaultUserAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, + ["sh", "-lc", "printf '%s\\n%s\\n' \"$(id -un)\" \"$(id -u)\""], + cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var lines = result.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length != 2 || + !DefaultUserNameRegex().IsMatch(lines[0]) || + !uint.TryParse(lines[1], out var uid)) + { + return null; + } + return new(lines[0], uid); + } + + private async Task FinalizeImportedRegistrationAsync( + string distroName, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + var configure = await _commandRunner.ConfigureDistroRegistrationAsync( + distroName, + manifest.RegistrationDefaultUid, + manifest.RegistrationFlags, + cancellationToken).ConfigureAwait(false); + if (!configure.Success) + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restore_registration_configure_failed", configure.ExitCode, preservePhase: true); + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || + registration.Configuration is null || + registration.Configuration.Version != manifest.RegistrationVersion || + registration.Configuration.DefaultUid != manifest.RegistrationDefaultUid || + registration.Configuration.Flags != manifest.RegistrationFlags) + { + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restored_registration_mismatch", preservePhase: true); + } + + var importedIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(importedIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "restored_identity_mismatch", preservePhase: true); + + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null || + defaultUser.Name != manifest.DefaultUserName || + defaultUser.Uid != manifest.DefaultUserUid) + { + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restored_default_user_mismatch", preservePhase: true); + } + + var imported = UpdateManifest(manifest, GatewayRollbackPointPhase.Imported); + return new(GatewayRollbackOperationState.Restored, imported); + } + + private async Task ProbeExactVersionAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, GatewayVersionAlignmentCommandBuilder.BuildProbe(), cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var match = VersionOutputRegex().Match(result.StandardOutput ?? string.Empty); + return match.Success ? match.Groups["version"].Value : null; + } + + private async Task<(bool Success, IReadOnlyList Names, int ExitCode)> ListDistroNamesAsync( + CancellationToken cancellationToken) + { + var result = await _commandRunner.RunAsync(["--list", "--quiet"], cancellationToken).ConfigureAwait(false); + if (!result.Success) + return (false, Array.Empty(), result.ExitCode); + var names = result.StandardOutput + .Replace("\0", string.Empty, StringComparison.Ordinal) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(name => name.Length > 0) + .ToArray(); + return (true, names, result.ExitCode); + } + + private async Task VerifyVhdAsync( + string path, + string? expectedSha256, + long? expectedSize, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return new(false, null, 0, "vhd_missing"); + + var header = new byte[8]; + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var size = stream.Length; + if (size < header.Length || (expectedSize.HasValue && size != expectedSize.Value)) + return new(false, null, size, "vhd_size_mismatch"); + + var read = await stream.ReadAsync(header, cancellationToken).ConfigureAwait(false); + if (read != header.Length || !string.Equals(Encoding.ASCII.GetString(header), VhdxSignature, StringComparison.Ordinal)) + return new(false, null, size, "vhdx_signature_invalid"); + + stream.Position = 0; + var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)) + .ToLowerInvariant(); + if (stream.Length != size) + return new(false, hash, stream.Length, "vhd_size_changed"); + if (expectedSha256 is not null && !string.Equals(hash, expectedSha256, StringComparison.Ordinal)) + return new(false, hash, size, "vhd_hash_mismatch"); + return new(true, hash, size, null); + } + + private static async Task CopyAndFlushAsync(string source, string destination, CancellationToken cancellationToken) + { + await using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.Asynchronous | FileOptions.WriteThrough); + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + private void WriteManifest(GatewayRollbackPointManifest manifest) + { + var directory = GetPointDirectory(manifest.Id); + EnsurePrivateDirectory(directory); + var path = Path.Combine(directory, ManifestFileName); + var temp = path + ".tmp"; + DeleteGeneratedFile(temp, directory); + var bytes = new UTF8Encoding(false).GetBytes(JsonSerializer.Serialize(manifest, _jsonOptions)); + using (var stream = new FileStream( + temp, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(path)) + File.Replace(temp, path, destinationBackupFileName: null, ignoreMetadataErrors: true); + else + File.Move(temp, path, overwrite: false); + + using var committed = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + committed.Flush(flushToDisk: true); + } + + private bool TryReadManifest(string path, out GatewayRollbackPointManifest? manifest) + { + manifest = null; + try + { + manifest = JsonSerializer.Deserialize(File.ReadAllText(path), _jsonOptions); + return manifest is not null && PointIdRegex().IsMatch(manifest.Id); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return false; + } + } + + private bool TryLoadPoint(string pointId, out GatewayRollbackPointManifest? manifest) + { + manifest = null; + if (string.IsNullOrWhiteSpace(pointId) || !PointIdRegex().IsMatch(pointId)) + return false; + return TryReadManifest(Path.Combine(GetPointDirectory(pointId), ManifestFileName), out manifest); + } + + private GatewayRollbackPointManifest[] LoadOwnedManifests() => + List().Select(info => TryLoadPoint(info.Id, out var manifest) ? manifest : null) + .Where(manifest => manifest is not null && IsManifestOwned(manifest)) + .Cast() + .ToArray(); + + private bool DeletePointFilesOnly(string pointId) + { + var directory = GetPointDirectory(pointId); + if (!Directory.Exists(directory) || + !HasSafePathBoundary(_pointsRoot, directory)) + return false; + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) + { + NormalizePath(Path.Combine(directory, ManifestFileName)), + NormalizePath(Path.Combine(directory, RollbackVhdFileName)) + }; + var entries = Directory.GetFileSystemEntries(directory).Select(NormalizePath).ToArray(); + if (entries.Any(entry => !allowed.Contains(entry) || IsReparsePoint(entry))) + return false; + foreach (var entry in entries) + File.Delete(entry); + Directory.Delete(directory, recursive: false); + return true; + } + + private bool IsManifestOwned(GatewayRollbackPointManifest manifest) => + manifest.SchemaVersion == 3 && + IsOwnedDistro(manifest.DistroName) && + PointIdRegex().IsMatch(manifest.Id) && + ExactVersionRegex().IsMatch(manifest.OpenClawVersion) && + ExactVersionRegex().IsMatch(manifest.TargetOpenClawVersion) && + Sha256Regex().IsMatch(manifest.InternalIdentitySha256) && + manifest.RegistrationVersion == 2 && + DefaultUserNameRegex().IsMatch(manifest.DefaultUserName) && + (manifest.NodeCommandAllowSnapshotJson is null || + IsCompleteCommandArrayJson(manifest.NodeCommandAllowSnapshotJson)) && + (string.IsNullOrEmpty(manifest.VhdSha256) || Sha256Regex().IsMatch(manifest.VhdSha256)); + + private static bool IsCompleteCommandArrayJson(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Array && + document.RootElement.EnumerateArray().All(item => + item.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(item.GetString())); + } + catch (JsonException) + { + return false; + } + } + + private bool IsOwnedDistro(string? distroName) => + string.Equals(distroName?.Trim(), _ownedDistroName, StringComparison.Ordinal); + + private string GetPointDirectory(string pointId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pointId); + if (!PointIdRegex().IsMatch(pointId)) + throw new ArgumentException("Invalid rollback point id.", nameof(pointId)); + var path = NormalizePath(Path.Combine(_pointsRoot, pointId)); + if (!string.Equals(Path.GetDirectoryName(path), _pointsRoot, StringComparison.OrdinalIgnoreCase)) + throw new IOException("Rollback point escaped its owned root."); + return path; + } + + private string GetRollbackVhdPath(string pointId) => Path.Combine(GetPointDirectory(pointId), RollbackVhdFileName); + private string GetStageVhdPath(string pointId) => NormalizePath(Path.Combine(_stagingRoot, $"{pointId}.vhdx")); + private string GetInstallDirectory() => NormalizePath(Path.Combine(_localDataRoot, "wsl", _ownedDistroName)); + + private static GatewayRollbackPointInfo ToInfo(GatewayRollbackPointManifest manifest) => new( + manifest.Id, + manifest.DistroName, + manifest.OpenClawVersion, + manifest.CreatedAtUtc, + manifest.VerificationStatus, + manifest.Phase, + manifest.VhdSizeBytes, + manifest.RestoreEligible && manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified, + manifest.LastFailureCode); + + private static void EnsurePrivateDirectory(string path) + { + Directory.CreateDirectory(path); + McpAuthToken.TryRestrictDataDirectoryAcl(path); + } + + private static void TryDeleteGeneratedFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static bool DeleteGeneratedFile(string path, string ownedRoot) + { + if (!File.Exists(path)) + return true; + if (!HasSafePathBoundary(ownedRoot, path) || IsReparsePoint(path)) + return false; + try + { + File.Delete(path); + return !File.Exists(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static bool HasSafePathBoundary(string root, string target) + { + var normalizedRoot = NormalizePath(root); + var normalizedTarget = NormalizePath(target); + if (!string.Equals(normalizedTarget, normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !normalizedTarget.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var current = normalizedRoot; + if (PathExists(current) && IsReparsePoint(current)) + return false; + + var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); + foreach (var segment in relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (PathExists(current) && IsReparsePoint(current)) + return false; + } + return true; + } + + private static bool PathExists(string path) => File.Exists(path) || Directory.Exists(path); + + private static bool IsReparsePoint(string path) => + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + + private static string NormalizePath(string path) + { + var fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + fullPath = @"\\" + fullPath[8..]; + else if (fullPath.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + fullPath = fullPath[4..]; + return Path.TrimEndingDirectorySeparator(fullPath); + } + + private sealed record VhdVerification(bool Verified, string? Sha256, long SizeBytes, string? FailureCode); + private sealed record DefaultUserIdentity(string Name, uint Uid); + + [GeneratedRegex(@"\A[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?\z", RegexOptions.CultureInvariant)] + private static partial Regex DistroNameRegex(); + + [GeneratedRegex(@"\A\d{8}T\d{9}Z-[0-9a-f]{32}\z", RegexOptions.CultureInvariant)] + private static partial Regex PointIdRegex(); + + [GeneratedRegex(@"\A[0-9a-fA-F]{32}\z", RegexOptions.CultureInvariant)] + private static partial Regex MachineIdRegex(); + + [GeneratedRegex(@"\A[0-9a-f]{64}\z", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Regex(); + + [GeneratedRegex(@"\A[a-z_][a-z0-9_-]{0,31}\z", RegexOptions.CultureInvariant)] + private static partial Regex DefaultUserNameRegex(); + + [GeneratedRegex(@"\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z", RegexOptions.CultureInvariant)] + private static partial Regex ExactVersionRegex(); + + [GeneratedRegex( + @"\A\s*(?:OpenClaw\s+)?(?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)(?:\s+\([^\r\n()]+\))?\s*\z", + RegexOptions.CultureInvariant)] + private static partial Regex VersionOutputRegex(); +} diff --git a/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs b/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs new file mode 100644 index 000000000..7f8623835 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs @@ -0,0 +1,999 @@ +using System.Numerics; +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +public enum GatewayVersionAlignmentState +{ + Ineligible, + Busy, + ProbeFailed, + Aligned, + Mismatch, + NewerThanRequired, + VersionOrderUnknown, + PreUpdateHealthFailed, + RollbackPointFailed, + UpdateFailed, + VerificationFailed, + SynchronizationFailed, + RecoveryAvailable, + RestoreConfirmationRequired, + RestoreFailed, + RestoreVerificationFailed, + RestoreCancelled, + Restored, + Updated +} + +public sealed record GatewayVersionAlignmentResult( + GatewayVersionAlignmentState State, + string RequiredVersion, + string? InstalledVersion = null, + string? PreviousVersion = null, + string? RollbackPointId = null, + int? ExitCode = null, + string? FailureSummary = null) +{ + public bool IsAligned => State is GatewayVersionAlignmentState.Aligned or GatewayVersionAlignmentState.Updated; +} + +/// +/// Aligns OpenClaw inside an existing Companion-owned WSL distro. Normal update +/// exports an offline rollback point and runs only the native pinned updater in +/// the existing distro. WSL unregister/import is isolated to RestoreAsync and +/// requires an explicit rollback-point confirmation. +/// +public sealed partial class GatewayVersionAlignmentCoordinator +{ + private readonly IWslCommandRunner _commandRunner; + private readonly GatewayRollbackPointManager _rollbackPoints; + private readonly string _requiredVersion; + private readonly Func _synchronizeAsync; + private readonly Func _retentionPolicy; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public GatewayVersionAlignmentCoordinator( + IWslCommandRunner commandRunner, + string requiredVersion, + GatewayRollbackPointManager rollbackPoints, + Func? synchronizeAsync = null, + Func? retentionPolicy = null) + { + ArgumentNullException.ThrowIfNull(commandRunner); + ArgumentNullException.ThrowIfNull(rollbackPoints); + + var normalizedVersion = requiredVersion?.Trim(); + if (normalizedVersion is null || !ExactVersionRegex().IsMatch(normalizedVersion)) + { + throw new ArgumentException( + "Required gateway version must be a strict exact semantic version without a leading 'v'.", + nameof(requiredVersion)); + } + + _commandRunner = commandRunner; + _rollbackPoints = rollbackPoints; + _requiredVersion = normalizedVersion; + _synchronizeAsync = synchronizeAsync ?? ((_, _) => Task.CompletedTask); + _retentionPolicy = retentionPolicy ?? (() => GatewayRollbackRetentionPolicy.Default); + } + + public string RequiredVersion => _requiredVersion; + + public IReadOnlyList ListRollbackPoints() => _rollbackPoints.List(); + + public bool HasVerifiedPendingUpdate(string gatewayId) => + !string.IsNullOrWhiteSpace(gatewayId) && + _rollbackPoints.FindPendingUpdates().Count > 0; + + public async Task ProbeAsync( + GatewayHostAccessPlan accessPlan, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not a proven Companion-owned WSL gateway."); + } + var restoreGate = GetUnresolvedRestoreGate(); + if (restoreGate is not null) + return restoreGate; + var pendingUpdateGate = GetPendingUpdateProbeGate(accessPlan.GatewayId!); + if (pendingUpdateGate is not null) + return pendingUpdateGate; + return await ProbeCoreAsync(distroName, cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task UpdateAsync( + GatewayHostAccessPlan accessPlan, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var gatewayId = accessPlan.GatewayId!; + var restoreGate = GetUnresolvedRestoreGate(); + if (restoreGate is not null) + return restoreGate; + + var pendingUpdates = _rollbackPoints.FindPendingUpdates(); + if (pendingUpdates.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple verified pending Gateway update receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe or update was attempted."); + } + + var before = await ProbeCoreAsync(distroName, cancellationToken).ConfigureAwait(false); + var pending = pendingUpdates.SingleOrDefault(); + if (pending is not null && + !string.Equals(pending.GatewayId, gatewayId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + "A verified pending update belongs to an earlier Gateway record for this Companion-owned distro. Explicit recovery is required before another update."); + } + if (pending is not null && + !string.Equals(pending.TargetOpenClawVersion, _requiredVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + $"A verified pending update targets OpenClaw {pending.TargetOpenClawVersion}; explicit recovery is required before aligning to {_requiredVersion}."); + } + if (before.State == GatewayVersionAlignmentState.Aligned && pending is not null) + { + if (!await _rollbackPoints.VerifyAsync(pending.Id, cancellationToken).ConfigureAwait(false) || + !await _rollbackPoints.AttestLiveDistroAsync( + pending.Id, distroName, _requiredVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + "The aligned Companion-owned distro no longer matches its pending rollback receipt, so finalization was blocked."); + } + return await FinalizePostUpdateAsync( + distroName, gatewayId, before.InstalledVersion!, pending.OpenClawVersion, pending.Id, + pending.NodeCommandAllowSnapshotJson, before.ExitCode, cancellationToken) + .ConfigureAwait(false); + } + if (before.State != GatewayVersionAlignmentState.Mismatch || before.InstalledVersion is null) + return before; + + var previousVersion = pending?.OpenClawVersion ?? before.InstalledVersion; + + GatewayRollbackPointManifest rollbackPoint; + if (pending is not null) + { + if (!await _rollbackPoints.VerifyAsync(pending.Id, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pending.Id, + failureSummary: "The pending update's integral rollback point no longer verifies, so retry was blocked."); + } + if (!await _rollbackPoints.AttestLiveDistroAsync( + pending.Id, distroName, pending.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pending.Id, + failureSummary: "The live Companion-owned distro no longer matches the pending rollback receipt, so retry was blocked."); + } + rollbackPoint = pending; + } + else + { + try + { + await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.PreUpdateHealthFailed, + previousVersion, + previousVersion, + failureSummary: $"Pre-update Gateway, Companion, Node, or pairing health failed: {ex.GetType().Name}."); + } + + var policySnapshot = await CaptureNodeCommandPolicyAsync( + distroName, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + if (policySnapshot.Failure is not null) + { + return Result( + GatewayVersionAlignmentState.PreUpdateHealthFailed, + previousVersion, + previousVersion, + failureSummary: policySnapshot.Failure); + } + + GatewayRollbackOperationResult rollback; + try + { + rollback = await _rollbackPoints.CreateVerifiedAsync( + distroName, gatewayId, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await TryRestoreExistingRuntimeAvailabilityAsync( + distroName, gatewayId, CancellationToken.None).ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + await TryRestoreExistingRuntimeAvailabilityAsync( + distroName, gatewayId, CancellationToken.None).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RollbackPointFailed, + previousVersion, + previousVersion, + failureSummary: $"Rollback point creation stopped before update: {ex.GetType().Name}."); + } + if (!rollback.Success || rollback.Point is null || + !await _rollbackPoints.VerifyAsync(rollback.Point.Id, cancellationToken).ConfigureAwait(false)) + { + await TryRestoreExistingRuntimeAvailabilityAsync(distroName, gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RollbackPointFailed, + previousVersion, + previousVersion, + rollback.Point?.Id, + rollback.ExitCode, + "A verified integral rollback point could not be created, so no update was attempted."); + } + rollbackPoint = rollback.Point; + if (policySnapshot.NormalizedArrayJson is not null) + { + rollbackPoint = _rollbackPoints.RecordNodeCommandAllowSnapshot( + rollbackPoint.Id, policySnapshot.NormalizedArrayJson); + } + } + + var pointId = rollbackPoint.Id; + if (pending is null) + _rollbackPoints.MarkUpdateInProgress(pointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, before.InstalledVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pointId, + failureSummary: "The live Companion-owned distro changed before package mutation, so the update was blocked."); + } + if (rollbackPoint.NodeCommandAllowSnapshotJson is { } expectedPolicy) + { + var currentPolicy = await CaptureNodeCommandPolicyAsync( + distroName, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + if (currentPolicy.Failure is not null || + !string.Equals(currentPolicy.NormalizedArrayJson, expectedPolicy, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pointId, + failureSummary: "The complete Gateway node command allowlist changed before package mutation. The update receipt was preserved and no updater command was invoked."); + } + } + var update = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildUpdate(_requiredVersion), + cancellationToken).ConfigureAwait(false); + if (!update.Success || !IsStructuredUpdateSuccess(update.StandardOutput, _requiredVersion)) + { + var current = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + current.Version, + previousVersion, + pointId, + update.ExitCode, + update.Success + ? "The updater did not return a trusted exact-version result. The verified rollback point is available for explicit recovery." + : $"The update failed with exit code {update.ExitCode}. The verified rollback point is available for explicit recovery."); + } + + var after = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (after.Failure is not null || !string.Equals(after.Version, _requiredVersion, StringComparison.Ordinal)) + { + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + after.Version, + previousVersion, + pointId, + after.ExitCode, + "The installed version could not be verified exactly after update. The verified rollback point is available for explicit recovery."); + } + + return await FinalizePostUpdateAsync( + distroName, gatewayId, after.Version!, previousVersion, pointId, + rollbackPoint.NodeCommandAllowSnapshotJson, after.ExitCode, cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task RestoreAsync( + GatewayHostAccessPlan accessPlan, + string rollbackPointId, + string confirmedRollbackPointId, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var point = _rollbackPoints.List().SingleOrDefault(item => string.Equals(item.Id, rollbackPointId, StringComparison.Ordinal)); + if (point is null) + return Result(GatewayVersionAlignmentState.RestoreFailed, failureSummary: "The selected rollback point no longer exists."); + + var restore = await _rollbackPoints.RestoreExplicitAsync( + distroName, + accessPlan.GatewayId!, + rollbackPointId, + confirmedRollbackPointId, + cancellationToken).ConfigureAwait(false); + if (restore.State == GatewayRollbackOperationState.ConfirmationRequired) + return Result(GatewayVersionAlignmentState.RestoreConfirmationRequired, rollbackPointId: rollbackPointId); + if (!restore.Success || restore.Point is null) + { + var requiredPointId = restore.Point?.Id ?? rollbackPointId; + return Result( + GatewayVersionAlignmentState.RestoreFailed, + previousVersion: point.OpenClawVersion, + rollbackPointId: requiredPointId, + exitCode: restore.ExitCode, + failureSummary: restore.State switch + { + GatewayRollbackOperationState.ImportPending => + "The old registration was removed but import did not complete. The verified rollback point and durable recovery receipt were preserved for retry.", + GatewayRollbackOperationState.ResumeRequired => + $"Recovery must resume exact rollback point {requiredPointId}; the selected point was not mutated.", + GatewayRollbackOperationState.AmbiguousRecovery => + "Multiple mandatory recovery receipts exist. Restore is ambiguous and no lifecycle mutation was attempted.", + _ => $"Emergency restore stopped safely: {restore.FailureCode ?? restore.State.ToString()}." + }); + } + + var restored = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (restored.Failure is not null || !string.Equals(restored.Version, restore.Point.OpenClawVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The restored distro registration exists, but its exact OpenClaw version could not be verified."); + } + + try + { + await _synchronizeAsync(accessPlan.GatewayId!, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + failureSummary: $"The full state was restored, but Gateway, Companion, Node, or pairing health failed: {ex.GetType().Name}."); + } + + if (!await VerifyRestoredNodeCommandPolicyAsync( + distroName, restore.Point, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The restored distro version is exact, but its complete Gateway node command policy does not match the rollback receipt."); + } + + if (!await _rollbackPoints.VerifyAsync(rollbackPointId, cancellationToken).ConfigureAwait(false) || + !await _rollbackPoints.AttestLiveDistroAsync( + rollbackPointId, distroName, restore.Point.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "Restore health passed, but the live distro no longer matches the exact rollback receipt. Restore finalization was blocked."); + } + + _rollbackPoints.MarkRestoreHealthy(rollbackPointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + rollbackPointId, distroName, restore.Point.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + _rollbackPoints.MarkImported(rollbackPointId); + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The live distro changed immediately before retention cleanup. The imported recovery receipt was preserved and cleanup was blocked."); + } + await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.Restored, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode); + } + finally + { + _operationGate.Release(); + } + } + + public GatewayVersionAlignmentResult CancelRestore( + GatewayHostAccessPlan accessPlan, + string rollbackPointId, + string confirmedRollbackPointId) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!string.Equals(rollbackPointId, confirmedRollbackPointId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RestoreConfirmationRequired, + rollbackPointId: rollbackPointId); + } + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.Ineligible, + failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var cancelled = _rollbackPoints.CancelRestore( + distroName, accessPlan.GatewayId!, rollbackPointId); + return cancelled.State == GatewayRollbackOperationState.Cancelled + ? Result( + GatewayVersionAlignmentState.RestoreCancelled, + previousVersion: cancelled.Point?.OpenClawVersion, + rollbackPointId: rollbackPointId) + : Result( + GatewayVersionAlignmentState.RestoreFailed, + previousVersion: cancelled.Point?.OpenClawVersion, + rollbackPointId: rollbackPointId, + failureSummary: $"Staged restore cancellation stopped safely: {cancelled.FailureCode ?? cancelled.State.ToString()}."); + } + finally + { + _operationGate.Release(); + } + } + + private async Task FinalizePostUpdateAsync( + string distroName, + string gatewayId, + string installedVersion, + string previousVersion, + string pointId, + string? nodeCommandAllowSnapshotJson, + int? exitCode, + CancellationToken cancellationToken) + { + var policyMigration = await ApplyNodeCommandPolicyMigrationAsync( + distroName, + previousVersion, + installedVersion, + nodeCommandAllowSnapshotJson, + cancellationToken).ConfigureAwait(false); + if (policyMigration is not null) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + installedVersion, + previousVersion, + pointId, + exitCode, + policyMigration); + } + + try + { + await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + installedVersion, + previousVersion, + pointId, + exitCode, + $"Post-update synchronization failed: {ex.GetType().Name}. The verified rollback point is available for explicit recovery."); + } + + if (!await _rollbackPoints.VerifyAsync(pointId, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "Post-update health passed, but the rollback point no longer verifies. Retention cleanup was not run."); + } + + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "Post-update health passed, but the live distro no longer matches the exact expected version and rollback receipt. Finalization and cleanup were blocked."); + } + + _rollbackPoints.MarkPostUpdateHealthy(pointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false)) + { + _rollbackPoints.MarkUpdateInProgress(pointId); + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "The live distro changed immediately before retention cleanup. The update receipt was preserved and cleanup was blocked."); + } + await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false); + return Result(GatewayVersionAlignmentState.Updated, installedVersion, previousVersion, pointId, exitCode); + } + + private async Task CaptureNodeCommandPolicyAsync( + string distroName, + string sourceVersion, + string targetVersion, + CancellationToken cancellationToken) + { + if (string.Equals( + GatewayNodeCommandPolicyConfig.ResolveAllowKey(sourceVersion), + GatewayNodeCommandPolicyConfig.ResolveAllowKey(targetVersion), + StringComparison.Ordinal)) + { + return new(null, null); + } + + var result = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(sourceVersion), + cancellationToken).ConfigureAwait(false); + if (!result.Success || !TryNormalizeCompleteCommandArray(result.StandardOutput, out var normalized)) + { + return new( + null, + "The complete Gateway node command allowlist could not be captured before update, so no package mutation was attempted."); + } + + return new(normalized, null); + } + + private async Task ApplyNodeCommandPolicyMigrationAsync( + string distroName, + string sourceVersion, + string targetVersion, + string? normalizedArrayJson, + CancellationToken cancellationToken) + { + var sourceKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(sourceVersion); + var targetKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(targetVersion); + if (string.Equals(sourceKey, targetKey, StringComparison.Ordinal)) + return null; + if (!TryNormalizeCompleteCommandArray(normalizedArrayJson, out var expected)) + return "The update receipt does not contain a valid complete Gateway node command allowlist, so policy migration and finalization were blocked."; + + var set = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildSetNodeCommandAllow(targetVersion, expected), + cancellationToken).ConfigureAwait(false); + if (!set.Success) + return "The complete Gateway node command policy could not be written to the target-version path. The update receipt was preserved for retry or rollback."; + + var verify = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(targetVersion), + cancellationToken).ConfigureAwait(false); + if (!verify.Success || + !TryNormalizeCompleteCommandArray(verify.StandardOutput, out var observed) || + !string.Equals(observed, expected, StringComparison.Ordinal)) + { + return "The migrated Gateway node command policy did not preserve the complete array exactly. The source policy remained in place, the update receipt was preserved, and finalization was blocked."; + } + + var unset = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildUnsetNodeCommandAllow(sourceVersion), + cancellationToken).ConfigureAwait(false); + return unset.Success + ? null + : "The target Gateway node command policy is verified, but the legacy path could not be removed. Both policy copies remain available and the update receipt was preserved for retry."; + } + + private async Task VerifyRestoredNodeCommandPolicyAsync( + string distroName, + GatewayRollbackPointManifest point, + CancellationToken cancellationToken) + { + if (point.NodeCommandAllowSnapshotJson is null) + return true; + + var result = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(point.OpenClawVersion), + cancellationToken).ConfigureAwait(false); + return result.Success && + TryNormalizeCompleteCommandArray(point.NodeCommandAllowSnapshotJson, out var expected) && + TryNormalizeCompleteCommandArray(result.StandardOutput, out var observed) && + string.Equals(observed, expected, StringComparison.Ordinal); + } + + private static bool TryNormalizeCompleteCommandArray(string? value, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + using var document = JsonDocument.Parse(value); + if (document.RootElement.ValueKind != JsonValueKind.Array) + return false; + var commands = document.RootElement.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null) + .ToArray(); + if (commands.Any(string.IsNullOrWhiteSpace)) + return false; + normalized = JsonSerializer.Serialize(commands); + return true; + } + catch (JsonException) + { + return false; + } + } + + private async Task TryRestoreExistingRuntimeAvailabilityAsync( + string distroName, + string gatewayId, + CancellationToken cancellationToken) + { + await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + + private async Task TrySynchronizeAsync(string gatewayId, CancellationToken cancellationToken) + { + try { await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) { } + } + + private GatewayVersionAlignmentResult? GetUnresolvedRestoreGate() + { + if (_rollbackPoints.HasUnreadableReceipt()) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "A Gateway rollback receipt directory cannot be read or validated. Recovery is ambiguous and no package probe or lifecycle mutation was attempted."); + } + + var unresolvedRestores = _rollbackPoints.FindUnresolvedRestores(); + if (unresolvedRestores.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple unresolved Gateway restore receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe or update was attempted."); + } + if (unresolvedRestores.Count == 0) + return null; + + var unresolved = unresolvedRestores[0]; + var action = unresolved.Phase == GatewayRollbackPointPhase.RestoreStaged + ? "Resume or durably cancel this pre-destructive restore before updating." + : "Resume this exact restore point before updating."; + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: unresolved.OpenClawVersion, + rollbackPointId: unresolved.Id, + failureSummary: $"An unresolved Gateway restore is in phase {unresolved.Phase}. {action}"); + } + + private GatewayVersionAlignmentResult? GetPendingUpdateProbeGate(string gatewayId) + { + var pendingUpdates = _rollbackPoints.FindPendingUpdates(); + if (pendingUpdates.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple pending Gateway update receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe was attempted."); + } + if (pendingUpdates.Count == 0) + return null; + + var pending = pendingUpdates[0]; + if (pending.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !pending.RestoreEligible) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: "The mandatory pending Gateway update receipt no longer has an eligible verified rollback point. Recovery must be resolved before probing ordinary alignment."); + } + if (!string.Equals(pending.GatewayId, gatewayId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: "A mandatory pending update belongs to an earlier Gateway record for this Companion-owned distro. Resume or explicitly restore that exact point before ordinary alignment."); + } + if (!string.Equals(pending.TargetOpenClawVersion, _requiredVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: $"A mandatory pending update targets OpenClaw {pending.TargetOpenClawVersion}. Resolve exact point {pending.Id} before aligning to {_requiredVersion}."); + } + return null; + } + + private async Task ProbeCoreAsync(string distroName, CancellationToken cancellationToken) + { + var probe = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (probe.Failure is not null) + return Result(GatewayVersionAlignmentState.ProbeFailed, exitCode: probe.ExitCode, failureSummary: probe.Failure); + + if (string.Equals(probe.Version, _requiredVersion, StringComparison.Ordinal)) + return Result(GatewayVersionAlignmentState.Aligned, probe.Version, exitCode: probe.ExitCode); + + var comparison = CompareSemanticVersions(probe.Version!, _requiredVersion); + return comparison switch + { + > 0 => Result(GatewayVersionAlignmentState.NewerThanRequired, probe.Version, exitCode: probe.ExitCode), + < 0 => Result(GatewayVersionAlignmentState.Mismatch, probe.Version, exitCode: probe.ExitCode), + _ => Result( + GatewayVersionAlignmentState.VersionOrderUnknown, + probe.Version, + exitCode: probe.ExitCode, + failureSummary: "Installed and required OpenClaw versions differ, but their build metadata cannot be safely ordered. No update was attempted.") + }; + } + + private async Task ProbeInstalledVersionAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, GatewayVersionAlignmentCommandBuilder.BuildProbe(), cancellationToken).ConfigureAwait(false); + if (!result.Success) + return new(null, result.ExitCode, $"OpenClaw version probe failed with exit code {result.ExitCode}."); + + var match = InstalledVersionRegex().Match(result.StandardOutput ?? string.Empty); + return match.Success + ? new(match.Groups["version"].Value, result.ExitCode, null) + : new(null, result.ExitCode, "OpenClaw version probe returned an unrecognized version."); + } + + private GatewayVersionAlignmentResult Result( + GatewayVersionAlignmentState state, + string? installedVersion = null, + string? previousVersion = null, + string? rollbackPointId = null, + int? exitCode = null, + string? failureSummary = null) => + new(state, _requiredVersion, installedVersion, previousVersion, rollbackPointId, exitCode, failureSummary); + + private static bool TryGetEligibleDistro(GatewayHostAccessPlan? accessPlan, out string distroName) + { + distroName = accessPlan?.DistroName?.Trim() ?? string.Empty; + return accessPlan is not null && + !string.IsNullOrWhiteSpace(accessPlan.GatewayId) && + accessPlan.TerminalTarget == GatewayTerminalTarget.Wsl && + accessPlan.CanControlWslGateway && + distroName.Length > 0; + } + + private static bool IsStructuredUpdateSuccess(string output, string requiredVersion) + { + try + { + using var document = JsonDocument.Parse(output); + var root = document.RootElement; + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.String && + string.Equals(status.GetString(), "ok", StringComparison.Ordinal) && + root.TryGetProperty("after", out var after) && after.ValueKind == JsonValueKind.Object && + after.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.String && + string.Equals(version.GetString(), requiredVersion, StringComparison.Ordinal); + } + catch (JsonException) + { + return false; + } + } + + private static int? CompareSemanticVersions(string left, string right) + { + var leftParts = ParseSemanticVersion(left); + var rightParts = ParseSemanticVersion(right); + var core = leftParts.Core.Zip(rightParts.Core, (a, b) => a.CompareTo(b)).FirstOrDefault(value => value != 0); + if (core != 0) + return core; + if (leftParts.PreRelease.Count == 0 && rightParts.PreRelease.Count > 0) + return 1; + if (leftParts.PreRelease.Count > 0 && rightParts.PreRelease.Count == 0) + return -1; + + for (var i = 0; i < Math.Min(leftParts.PreRelease.Count, rightParts.PreRelease.Count); i++) + { + var leftNumeric = IsDecimalIdentifier(leftParts.PreRelease[i]); + var rightNumeric = IsDecimalIdentifier(rightParts.PreRelease[i]); + var comparison = leftNumeric && rightNumeric + ? BigInteger.Parse(leftParts.PreRelease[i]).CompareTo(BigInteger.Parse(rightParts.PreRelease[i])) + : leftNumeric ? -1 + : rightNumeric ? 1 + : string.Compare(leftParts.PreRelease[i], rightParts.PreRelease[i], StringComparison.Ordinal); + if (comparison != 0) + return comparison; + } + var preReleaseCount = leftParts.PreRelease.Count.CompareTo(rightParts.PreRelease.Count); + if (preReleaseCount != 0) + return preReleaseCount; + + if (leftParts.BuildMetadata.SequenceEqual(rightParts.BuildMetadata, StringComparer.Ordinal)) + return 0; + if (leftParts.BuildMetadata.Count == 0 || + rightParts.BuildMetadata.Count == 0 || + leftParts.BuildMetadata.Count != rightParts.BuildMetadata.Count) + { + return null; + } + + int? orderedComparison = null; + for (var i = 0; i < leftParts.BuildMetadata.Count; i++) + { + if (string.Equals(leftParts.BuildMetadata[i], rightParts.BuildMetadata[i], StringComparison.Ordinal)) + continue; + if (!IsDecimalIdentifier(leftParts.BuildMetadata[i]) || + !IsDecimalIdentifier(rightParts.BuildMetadata[i])) + { + return null; + } + var comparison = BigInteger.Parse(leftParts.BuildMetadata[i]) + .CompareTo(BigInteger.Parse(rightParts.BuildMetadata[i])); + if (comparison != 0) + orderedComparison ??= comparison; + } + return orderedComparison; + } + + private static bool IsDecimalIdentifier(string value) => + value.Length > 0 && value.All(character => character is >= '0' and <= '9'); + + private static SemanticVersionParts ParseSemanticVersion(string version) + { + var versionAndBuild = version.Split('+', 2); + var coreAndPreRelease = versionAndBuild[0].Split('-', 2); + return new( + coreAndPreRelease[0].Split('.').Select(BigInteger.Parse).ToArray(), + coreAndPreRelease.Length == 2 ? coreAndPreRelease[1].Split('.').ToArray() : [], + versionAndBuild.Length == 2 ? versionAndBuild[1].Split('.').ToArray() : []); + } + + private sealed record InstalledVersionProbe(string? Version, int ExitCode, string? Failure); + private sealed record NodeCommandPolicySnapshot(string? NormalizedArrayJson, string? Failure); + private sealed record SemanticVersionParts( + IReadOnlyList Core, + IReadOnlyList PreRelease, + IReadOnlyList BuildMetadata); + + private const string ExactVersionPattern = + @"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + + @"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + + @"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"; + + [GeneratedRegex(@"\A" + ExactVersionPattern + @"\z", RegexOptions.CultureInvariant)] + private static partial Regex ExactVersionRegex(); + + [GeneratedRegex( + @"\A\s*(?:OpenClaw\s+)?(?" + ExactVersionPattern + @")(?:\s+\([^\r\n()]+\))?\s*\z", + RegexOptions.CultureInvariant)] + private static partial Regex InstalledVersionRegex(); +} + +internal static class GatewayVersionAlignmentCommandBuilder +{ + public static IReadOnlyList BuildProbe() => + ["bash", "-lc", $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw --version"]; + + public static IReadOnlyList BuildUpdate(string requiredVersion) => + [ + "bash", + "-lc", + $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw update --tag {requiredVersion} --yes --json" + ]; + + public static IReadOnlyList BuildGetNodeCommandAllow(string gatewayVersion) => + BuildConfigCommand($"get {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)} --json"); + + public static IReadOnlyList BuildUnsetNodeCommandAllow(string gatewayVersion) => + BuildConfigCommand($"unset {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)}"); + + public static IReadOnlyList BuildSetNodeCommandAllow(string gatewayVersion, string completeArrayJson) => + BuildConfigCommand( + $"set {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)} {QuotePosix(completeArrayJson)}"); + + private static IReadOnlyList BuildConfigCommand(string operation) => + [ + "bash", + "-lc", + $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw config {operation}" + ]; + + private static string QuotePosix(string value) => $"'{value.Replace("'", "'\\''", StringComparison.Ordinal)}'"; +} diff --git a/src/OpenClaw.Connection/WslCommandRunner.cs b/src/OpenClaw.Connection/WslCommandRunner.cs index 8dfd3ef50..607fc7f42 100644 --- a/src/OpenClaw.Connection/WslCommandRunner.cs +++ b/src/OpenClaw.Connection/WslCommandRunner.cs @@ -1,6 +1,8 @@ using OpenClaw.Shared; +using Microsoft.Win32; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; namespace OpenClaw.Connection; @@ -11,6 +13,12 @@ public sealed record WslCommandResult(int ExitCode, string StandardOutput, strin } public sealed record WslDistroInfo(string Name, string State, int Version); +public sealed record WslDistroRegistration(string Name, string BasePath); +public sealed record WslDistroConfiguration(uint Version, uint DefaultUid, uint Flags); +public sealed record WslDistroConfigurationResult( + bool Success, + WslDistroConfiguration? Configuration, + int HResult = 0); public interface IWslCommandRunner { @@ -21,6 +29,21 @@ Task RunAsync( Task> ListDistrosAsync(CancellationToken cancellationToken = default); + Task> ListRegistrationsAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + Task GetDistroConfigurationAsync( + string name, + CancellationToken cancellationToken = default) => + Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + + Task ConfigureDistroRegistrationAsync( + string name, + uint defaultUid, + uint flags, + CancellationToken cancellationToken = default) => + Task.FromResult(new WslCommandResult(-1, string.Empty, "WSL registration configuration is unavailable.")); + Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default); Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default); @@ -37,6 +60,7 @@ Task RunInDistroAsync( /// public sealed class WslExeCommandRunner : IWslCommandRunner { + private const string LxssRegistryPath = @"Software\Microsoft\Windows\CurrentVersion\Lxss"; private readonly IOpenClawLogger _logger; private readonly TimeSpan _defaultTimeout; @@ -54,6 +78,97 @@ public async Task> ListDistrosAsync(CancellationTok return result.Success ? ParseDistroList(result.StandardOutput) : []; } + public Task> ListRegistrationsAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var registrations = new List(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult>(registrations); + using var root = Registry.CurrentUser.OpenSubKey(LxssRegistryPath, writable: false); + if (root is null) + return Task.FromResult>(registrations); + + foreach (var subKeyName in root.GetSubKeyNames()) + { + cancellationToken.ThrowIfCancellationRequested(); + using var distro = root.OpenSubKey(subKeyName, writable: false); + if (distro?.GetValue("DistributionName") is not string name || + distro.GetValue("BasePath") is not string basePath || + string.IsNullOrWhiteSpace(name) || + string.IsNullOrWhiteSpace(basePath)) + { + continue; + } + + registrations.Add(new(name.Trim(), Environment.ExpandEnvironmentVariables(basePath.Trim()))); + } + + return Task.FromResult>(registrations); + } + + public Task GetDistroConfigurationAsync( + string name, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + + IntPtr environmentVariables = IntPtr.Zero; + uint environmentVariableCount = 0; + try + { + var hresult = WslGetDistributionConfiguration( + name, + out var version, + out var defaultUid, + out var flags, + out environmentVariables, + out environmentVariableCount); + return Task.FromResult(hresult >= 0 + ? new WslDistroConfigurationResult(true, new(version, defaultUid, flags), hresult) + : new WslDistroConfigurationResult(false, null, hresult)); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + } + finally + { + for (uint index = 0; environmentVariables != IntPtr.Zero && index < environmentVariableCount; index++) + { + var value = Marshal.ReadIntPtr(environmentVariables, checked((int)(index * IntPtr.Size))); + if (value != IntPtr.Zero) + Marshal.FreeCoTaskMem(value); + } + if (environmentVariables != IntPtr.Zero) + Marshal.FreeCoTaskMem(environmentVariables); + } + } + + public Task ConfigureDistroRegistrationAsync( + string name, + uint defaultUid, + uint flags, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult(new WslCommandResult(-1, string.Empty, "WSL registration configuration requires Windows.")); + + try + { + var hresult = WslConfigureDistribution(name, defaultUid, flags); + return Task.FromResult(hresult >= 0 + ? new WslCommandResult(0, string.Empty, string.Empty) + : new WslCommandResult(hresult, string.Empty, "WslConfigureDistribution failed.")); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return Task.FromResult(new WslCommandResult(-1, string.Empty, "WslConfigureDistribution is unavailable.")); + } + } + public Task RunAsync( IReadOnlyList arguments, CancellationToken cancellationToken = default, @@ -174,4 +289,19 @@ private async Task RunProcessAsync( ? new WslCommandResult(-1, stdout, "wsl.exe timed out") : new WslCommandResult(process.ExitCode, stdout, stderr); } + + [DllImport("api-ms-win-wsl-api-l1-1-0.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int WslGetDistributionConfiguration( + string distributionName, + out uint distributionVersion, + out uint defaultUid, + out uint wslDistributionFlags, + out IntPtr defaultEnvironmentVariables, + out uint defaultEnvironmentVariableCount); + + [DllImport("api-ms-win-wsl-api-l1-1-0.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int WslConfigureDistribution( + string distributionName, + uint defaultUid, + uint wslDistributionFlags); } diff --git a/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs b/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs new file mode 100644 index 000000000..104a283f9 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +namespace OpenClaw.SetupEngine; + +internal sealed record GatewayReloadRecoveryState( + int Version, + string DistroName, + string? ReloadMode, + DateTimeOffset CreatedAtUtc); + +internal static class GatewayReloadRecoveryStore +{ + internal const int CurrentVersion = 1; + internal const string FileName = "setup-gateway-reload-recovery.json"; + + internal static string GetPath(SetupContext ctx) => Path.Combine(ctx.LocalDataDir, FileName); + + internal static GatewayReloadRecoveryState? Load(SetupContext ctx) + { + var path = GetPath(ctx); + GatewayReloadRecoveryState? state; + try + { + state = JsonSerializer.Deserialize( + File.ReadAllText(path), + SetupConfig.JsonOptions); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + throw new InvalidDataException($"Gateway reload recovery marker is unreadable: {ex.Message}", ex); + } + + if (state is null) + throw new InvalidDataException("Gateway reload recovery marker must contain a JSON object."); + if (state.Version != CurrentVersion) + throw new InvalidDataException($"Gateway reload recovery marker version {state.Version} is unsupported."); + if (string.IsNullOrWhiteSpace(state.DistroName)) + throw new InvalidDataException("Gateway reload recovery marker is missing the distro name."); + if (state.ReloadMode is not null && !IsSupportedReloadMode(state.ReloadMode)) + throw new InvalidDataException($"Gateway reload recovery marker contains unsupported reload mode '{state.ReloadMode}'."); + + return state; + } + + internal static void Save(SetupContext ctx, string? reloadMode) + { + var distroName = ctx.DistroName; + if (string.IsNullOrWhiteSpace(distroName)) + throw new InvalidOperationException("Cannot suspend gateway reload without a target distro name."); + if (reloadMode is not null && !IsSupportedReloadMode(reloadMode)) + throw new InvalidOperationException($"Cannot suspend gateway reload with unsupported restore mode '{reloadMode}'."); + + var state = new GatewayReloadRecoveryState( + CurrentVersion, + distroName, + reloadMode, + DateTimeOffset.UtcNow); + AtomicFile.WriteAllText( + GetPath(ctx), + JsonSerializer.Serialize(state, SetupConfig.JsonWriteOptions)); + } + + internal static void Clear(SetupContext ctx) + { + try + { + File.Delete(GetPath(ctx)); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // Clearing an optional recovery marker is idempotent on a fresh install. + } + } + + internal static bool IsSupportedReloadMode(string? reloadMode) => reloadMode is + "off" or "hot" or "restart" or "hybrid"; +} diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index d73234c5c..4f3c01dea 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -154,8 +154,9 @@ public sealed class GatewayConfig public string Bind { get; set; } = "loopback"; public string? InstallUrl { get; set; } public string? Version { get; set; } + public string? ExpectedPackageSha256 { get; set; } public int HealthTimeoutSeconds { get; set; } = 90; - public string ReloadMode { get; set; } = "hot"; + public string ReloadMode { get; set; } = "hybrid"; public string AuthMode { get; set; } = "token"; public Dictionary? ExtraConfig { get; set; } } diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 1161b6a21..20f312d1b 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -40,6 +40,7 @@ public static class SetupStepFactory { public static List BuildWizardOnlySteps() => [ + new RecoverGatewayReloadStep(), new RunGatewayWizardStep(), new WindowsNodeBootstrapContextStep(), ]; @@ -61,9 +62,11 @@ public static List BuildDefaultSteps() new InstallCliStep(), new InstallTailscaleStep(), new AuthorizeTailscaleStep(), + new RecoverGatewayReloadStep(), new ConfigureGatewayStep(), new InstallGatewayServiceStep(), - new StartGatewayStep(), + new WaitForGatewayHealthStep(), + new StartKeepaliveStep(), new FinalizeTailscaleServeStep(), new MintBootstrapTokenStep(), new PairOperatorStep(), @@ -71,7 +74,6 @@ public static List BuildDefaultSteps() new VerifyEndToEndStep(), new RunGatewayWizardStep(), new WindowsNodeBootstrapContextStep(), - new StartKeepaliveStep(), ]; } } diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 66b2a851a..b10b272c2 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.RegularExpressions; using OpenClaw.Connection; using OpenClaw.Shared; @@ -287,6 +288,9 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return delete; } ctx.Logger.Decision("No stale distro found", "skip cleanup"); + var recoveryResult = DiscardRecoveryForRemovedDistro(ctx); + if (!recoveryResult.IsSuccess) + return recoveryResult; return StepResult.Ok("No stale distro to clean"); } @@ -315,12 +319,39 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati // Wait for port to be released ctx.Logger.Info("Waiting for port release after distro termination..."); await PreflightPortStep.WaitForPortFreeAsync(ctx.Config.GatewayPort, ctx.Config.Gateway.Bind, ctx.Logger, ct); + var recoveryResult = DiscardRecoveryForRemovedDistro(ctx); + if (!recoveryResult.IsSuccess) + return recoveryResult; return StepResult.Ok($"Unregistered stale distro '{distro}'"); } return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}"); } + private static StepResult DiscardRecoveryForRemovedDistro(SetupContext ctx) + { + GatewayReloadRecoveryState? recovery; + try + { + recovery = GatewayReloadRecoveryStore.Load(ctx); + } + catch (Exception ex) + { + return StepResult.Fail($"Cannot inspect gateway reload recovery state during cleanup: {ex.Message}", ex); + } + + if (recovery is not null && + !string.Equals(recovery.DistroName, ctx.DistroName, StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail( + $"Gateway reload recovery marker targets distro '{recovery.DistroName}', " + + $"but cleanup targets '{ctx.DistroName}'. Refusing to discard recovery state."); + } + + GatewayReloadRecoveryStore.Clear(ctx); + return StepResult.Ok("Gateway reload recovery state discarded for removed distro"); + } + internal static async Task DeleteDistroDirectoryWithRetries( SetupContext ctx, string distroName, @@ -1249,14 +1280,23 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati string installScript; try { - installScript = BuildInstallCommand(installUrl, ctx.Config.Gateway.Version); + installScript = BuildInstallCommand( + installUrl, + ctx.Config.Gateway.Version, + ctx.Config.Gateway.ExpectedPackageSha256); } catch (ArgumentException ex) { return StepResult.Fail(ex.Message); } - var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct); + var inputViaStdin = !string.IsNullOrWhiteSpace(ctx.Config.Gateway.ExpectedPackageSha256); + var result = await ctx.Commands.RunInWslAsync( + distro, + installScript, + TimeSpan.FromMinutes(5), + ct: ct, + inputViaStdin: inputViaStdin); if (result.ExitCode != 0) return StepResult.Fail($"CLI install failed (exit {result.ExitCode}): {result.Stderr}"); @@ -1289,9 +1329,15 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Fail("CLI installed but not found in any known location"); } - internal static string BuildInstallCommand(string installUrl, string? requestedVersion) + internal static string BuildInstallCommand( + string installUrl, + string? requestedVersion, + string? expectedPackageSha256 = null) { var escapedUrl = WslShellQuoting.EscapePosixSingleQuoteInner(installUrl); + if (!string.IsNullOrWhiteSpace(expectedPackageSha256)) + return BuildVerifiedPackageInstallCommand(escapedUrl, requestedVersion, expectedPackageSha256); + if (string.IsNullOrWhiteSpace(requestedVersion)) return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; @@ -1303,6 +1349,44 @@ internal static string BuildInstallCommand(string installUrl, string? requestedV return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'"; } + private static string BuildVerifiedPackageInstallCommand( + string escapedInstallUrl, + string? requestedVersion, + string expectedPackageSha256) + { + var normalizedSha256 = expectedPackageSha256.Trim().ToLowerInvariant(); + if (normalizedSha256.Length != 64 || !normalizedSha256.All(Uri.IsHexDigit)) + throw new ArgumentException("Expected gateway package SHA-256 must contain exactly 64 hexadecimal characters."); + + var packageSpec = requestedVersion?.Trim(); + if (string.IsNullOrWhiteSpace(packageSpec) || + packageSpec.Contains('\n') || + packageSpec.Contains('\r') || + !Uri.TryCreate(packageSpec, UriKind.Absolute, out var packageUri) || + (packageUri.Scheme != Uri.UriSchemeHttp && packageUri.Scheme != Uri.UriSchemeHttps) || + !packageUri.AbsolutePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(packageUri.UserInfo)) + { + throw new ArgumentException( + "Expected gateway package SHA-256 requires Version to be a credential-free HTTP(S) .tgz URL."); + } + + var escapedPackageSpec = WslShellQuoting.EscapePosixSingleQuoteInner(packageSpec); + var packageCurlOptions = packageUri.Scheme == Uri.UriSchemeHttps + ? "--proto '=https' --tlsv1.2" + : "--proto '=http'"; + + return + "download_dir=\"$(mktemp -d /tmp/openclaw-install.XXXXXX)\"" + + " && trap 'rm -rf -- \"$download_dir\"' EXIT" + + " && package_path=\"$download_dir/openclaw.tgz\"" + + " && installer_path=\"$download_dir/install-cli.sh\"" + + $" && curl -fsSL {packageCurlOptions} '{escapedPackageSpec}' -o \"$package_path\"" + + $" && printf '%s %s\\n' '{normalizedSha256}' \"$package_path\" | sha256sum --check --strict -" + + $" && curl -fsSL --proto '=https' --tlsv1.2 '{escapedInstallUrl}' -o \"$installer_path\"" + + " && bash \"$installer_path\" --version \"$package_path\""; + } + private static async Task EnsureCliOnDefaultPathAsync( SetupContext ctx, string distro, @@ -1357,9 +1441,37 @@ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) } } +public sealed class RecoverGatewayReloadStep : SetupStep +{ + public override string Id => "recover-gateway-reload"; + public override string DisplayName => "Recover gateway reload state"; + public override bool CanRetry => false; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) => + new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); +} + +internal static class GatewayReloadModeConfig +{ + internal static string Resolve(string? gatewayVersion, string configuredMode) + { + var version = gatewayVersion?.Trim(); + var usesLegacySchema = + string.Equals(version, GatewayLkgVersion.LkgVersion, StringComparison.OrdinalIgnoreCase) || + string.Equals(version, "2026.7.2-beta.3", StringComparison.OrdinalIgnoreCase); + + if (usesLegacySchema) + return configuredMode; + + return configuredMode is "hot" or "restart" ? "hybrid" : configuredMode; + } +} + public sealed class ConfigureGatewayStep : SetupStep { internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl"; + internal const string CurrentNodeCommandAllowConfigKey = GatewayNodeCommandPolicyConfig.CurrentAllowKey; + internal const string LegacyNodeCommandAllowConfigKey = GatewayNodeCommandPolicyConfig.LegacyAllowKey; internal const string DevicePairEnabledKey = "plugins.entries.device-pair.enabled"; // Each `openclaw config set` emitted below spawns the Node CLI fresh inside WSL; on a // newly created distro with a cold cache that is ~4-5s apiece. Budget the step by how @@ -1392,7 +1504,9 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var allowedCommandsJson = JsonSerializer.Serialize(ctx.Config.Capabilities.GetEnabledCommandIds()); var escapedAllowedCommands = WslShellQuoting.QuotePosixSingleQuote(allowedCommandsJson); - var extraConfigOverridesAllowCommands = gw.ExtraConfig?.ContainsKey("gateway.nodes.allowCommands") == true; + var nodeCommandAllowConfigKey = ResolveNodeCommandAllowConfigKey(gw); + var extraConfigOverridesAllowCommands = + gw.ExtraConfig?.ContainsKey(nodeCommandAllowConfigKey) == true; if (gw.ExtraConfig is { Count: > 0 }) { foreach (var key in gw.ExtraConfig.Keys) @@ -1404,9 +1518,10 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var configCommands = BuildConfigCommands(gw, port, escapedAllowedCommands, ctx.Config.Tailscale); - ctx.Logger.Info($"Gateway node allowCommands derived from setup capabilities: {allowedCommandsJson}"); + ctx.Logger.Info( + $"Gateway node command allowlist ({nodeCommandAllowConfigKey}) derived from setup capabilities: {allowedCommandsJson}"); if (extraConfigOverridesAllowCommands) - ctx.Logger.Warn("Gateway.ExtraConfig overrides derived gateway.nodes.allowCommands"); + ctx.Logger.Warn($"Gateway.ExtraConfig overrides derived {nodeCommandAllowConfigKey}"); if (GetDefaultDevicePairPublicUrl(gw, port, ctx.Config.Tailscale.Enabled) is { } defaultPublicUrl && gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true) { @@ -1445,14 +1560,14 @@ internal static string BuildConfigCommands( string escapedAllowedCommands, TailscaleConfig? tailscale = null) { + var nodeCommandAllowConfigKey = ResolveNodeCommandAllowConfigKey(gw); var configCommands = $""" openclaw config set gateway.mode local openclaw config set gateway.port {port} openclaw config set gateway.bind {gw.Bind} openclaw config set gateway.auth.mode {gw.AuthMode} openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" - openclaw config set gateway.reload.mode {gw.ReloadMode} - openclaw config set gateway.nodes.allowCommands {escapedAllowedCommands} + openclaw config set {nodeCommandAllowConfigKey} {escapedAllowedCommands} """; if (tailscale?.Enabled == true) @@ -1496,14 +1611,26 @@ openclaw config set gateway.auth.allowTailscale {trustTailscaleAuth} if (!IsSafeExtraConfigKey(key)) throw new ArgumentException($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.", nameof(gw)); - var escapedValue = WslShellQuoting.QuotePosixSingleQuote(value); + var compatibleValue = string.Equals(key, "gateway.reload.mode", StringComparison.Ordinal) + ? GatewayReloadModeConfig.Resolve(gw.Version, value) + : value; + var escapedValue = WslShellQuoting.QuotePosixSingleQuote(compatibleValue); configCommands += $"\n openclaw config set {key} {escapedValue}"; } } + if (gw.ExtraConfig?.ContainsKey("gateway.reload.mode") != true) + { + var reloadMode = GatewayReloadModeConfig.Resolve(gw.Version, gw.ReloadMode); + configCommands += $"\n openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(reloadMode)}"; + } + return configCommands; } + internal static string ResolveNodeCommandAllowConfigKey(GatewayConfig gw) + => GatewayNodeCommandPolicyConfig.ResolveAllowKey(gw.Version); + // Budget = base + per-command, floored. Scales the WSL timeout with the number of // `openclaw config set` invocations the step emits so it cannot silently regress as // BuildConfigCommands grows. @@ -1528,6 +1655,13 @@ private static int CountConfigSetCommands(string configCommands) internal static string? GetDefaultDevicePairPublicUrl(GatewayConfig gw, int port, bool tailscaleEnabled = false) => gw.Bind == "loopback" && !tailscaleEnabled ? $"http://127.0.0.1:{port}" : null; + internal static string GetEffectiveReloadMode(GatewayConfig gw) => + GatewayReloadModeConfig.Resolve( + gw.Version, + gw.ExtraConfig?.TryGetValue("gateway.reload.mode", out var overrideMode) == true + ? overrideMode + : gw.ReloadMode); + internal static bool IsSafeExtraConfigKey(string value) => System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9._-]+$"); } @@ -1556,37 +1690,62 @@ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) } } +public sealed class WaitForGatewayHealthStep : SetupStep +{ + public override string Id => "wait-gateway-health"; + public override string DisplayName => "Wait for gateway health"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var portOwnership = await StartGatewayStep.CheckPortOwnershipAsync(ctx, ct); + return portOwnership.Failure ?? await StartGatewayStep.WaitForHealthAsync(ctx, ct); + } +} + public sealed class StartGatewayStep : SetupStep { + internal sealed record PortOwnershipCheck( + bool IsManagedGateway, + StepResult? Failure); + public override string Id => "start-gateway"; public override string DisplayName => "Start gateway"; public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); - public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) => + StartOrRestartAndWaitForHealthAsync(ctx, restart: false, ct); + + internal static Task RestartAndWaitForHealthAsync(SetupContext ctx, CancellationToken ct) => + StartOrRestartAndWaitForHealthAsync(ctx, restart: true, ct); + + private static async Task StartOrRestartAndWaitForHealthAsync( + SetupContext ctx, + bool restart, + CancellationToken ct) { var distro = ctx.DistroName!; var pathCmd = ctx.WslPathPrefix; + var action = restart ? "restart" : "start"; - // Check for port conflicts before starting - var portCheck = await ctx.Commands.RunInWslAsync( - distro, $"ss -tlnp 2>/dev/null | grep ':{ctx.Config.GatewayPort}\\b' || true", - TimeSpan.FromSeconds(10), ct: ct); + var portOwnership = await CheckPortOwnershipAsync(ctx, ct); + if (portOwnership.Failure is not null) + return portOwnership.Failure; - if (!string.IsNullOrWhiteSpace(portCheck.Stdout) && portCheck.Stdout.Contains($":{ctx.Config.GatewayPort}")) + if (!restart && portOwnership.IsManagedGateway) { - if (!portCheck.Stdout.Contains("openclaw", StringComparison.OrdinalIgnoreCase)) - { - ctx.Logger.Warn($"Port {ctx.Config.GatewayPort} is in use by another process:\n{portCheck.Stdout.Trim()}"); - return StepResult.Fail( - $"Port {ctx.Config.GatewayPort} is already in use by another process. Either stop the conflicting process or change GatewayPort in the setup config."); - } + ctx.Logger.Info( + $"Managed OpenClaw Gateway already owns port {ctx.Config.GatewayPort}; reusing it instead of issuing a redundant start"); + return await WaitForHealthAsync(ctx, ct); + } - ctx.Logger.Info($"Port {ctx.Config.GatewayPort} appears to be in use by openclaw — proceeding"); + if (restart && portOwnership.IsManagedGateway) + { + ctx.Logger.Info( + $"Managed OpenClaw Gateway owns port {ctx.Config.GatewayPort}; delegating its stop/start lifecycle to openclaw gateway restart"); } - // Start the service var start = await ctx.Commands.RunInWslAsync( - distro, $"{pathCmd} && openclaw gateway start", TimeSpan.FromSeconds(30), ct: ct); + distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct); if (start.ExitCode != 0) { @@ -1600,17 +1759,106 @@ await ctx.Commands.RunInWslAsync( TimeSpan.FromSeconds(10), ct: ct); await Task.Delay(2000, ct); - start = await ctx.Commands.RunInWslAsync(distro, $"{pathCmd} && openclaw gateway start", TimeSpan.FromSeconds(30), ct: ct); + start = await ctx.Commands.RunInWslAsync(distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct); if (start.ExitCode != 0) - return StepResult.Fail($"Gateway start failed after reset: {start.Stderr}"); + return StepResult.Fail($"Gateway {action} failed after reset: {start.Stderr}"); } else { - return StepResult.Fail($"Gateway start failed (exit {start.ExitCode}): {start.Stderr}"); + return StepResult.Fail($"Gateway {action} failed (exit {start.ExitCode}): {start.Stderr}"); } } - // Wait for health endpoint + return await WaitForHealthAsync(ctx, ct); + } + + internal static async Task CheckPortOwnershipAsync( + SetupContext ctx, + CancellationToken ct) + { + var portCheck = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, "ss -tlnp", + TimeSpan.FromSeconds(10), ct: ct); + + if (portCheck.ExitCode != 0) + { + var probeError = string.IsNullOrWhiteSpace(portCheck.Stderr) + ? portCheck.Stdout.Trim() + : portCheck.Stderr.Trim(); + ctx.Logger.Warn( + $"Could not inspect port {ctx.Config.GatewayPort} ownership with ss (exit {portCheck.ExitCode}): {probeError}"); + return new PortOwnershipCheck( + false, + StepResult.Fail( + $"Gateway could not inspect port ownership for port {ctx.Config.GatewayPort}. Refusing to start or restart without listener ownership proof.")); + } + + var selectedPortListeners = portCheck.Stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => + { + var columns = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return columns.Length >= 4 && + string.Equals(columns[0], "LISTEN", StringComparison.OrdinalIgnoreCase) && + columns[3].EndsWith($":{ctx.Config.GatewayPort}", StringComparison.Ordinal); + }) + .ToArray(); + if (selectedPortListeners.Length == 0) + return new PortOwnershipCheck(false, null); + + var selectedPortOutput = string.Join('\n', selectedPortListeners); + var listenerPidsByLine = selectedPortListeners + .Select(line => Regex.Matches(line, @"\bpid=(\d+)\b") + .Select(match => int.TryParse(match.Groups[1].Value, out var pid) ? pid : 0) + .Where(pid => pid > 0) + .Distinct() + .ToArray()) + .ToArray(); + if (listenerPidsByLine.Any(pids => pids.Length == 0)) + { + ctx.Logger.Warn( + $"Port {ctx.Config.GatewayPort} has a listener whose process ownership could not be attributed:\n{selectedPortOutput}"); + return new PortOwnershipCheck( + false, + StepResult.Fail( + $"Port {ctx.Config.GatewayPort} is already in use, but its owning process could not be attributed. Refusing to stop or replace an unknown listener.")); + } + + var listenerPids = listenerPidsByLine + .SelectMany(pids => pids) + .Distinct() + .ToArray(); + var servicePidResult = await ctx.Commands.RunInWslAsync( + ctx.DistroName!, + "systemctl --user show openclaw-gateway.service --property=MainPID --value", + TimeSpan.FromSeconds(10), + ct: ct); + var managedServicePid = 0; + var hasManagedServicePid = + servicePidResult.ExitCode == 0 && + int.TryParse(servicePidResult.Stdout.Trim(), out managedServicePid) && + managedServicePid > 0; + var allListenersBelongToManagedGateway = + hasManagedServicePid && listenerPids.All(pid => pid == managedServicePid); + + if (!allListenersBelongToManagedGateway) + { + ctx.Logger.Warn( + $"Port {ctx.Config.GatewayPort} is in use by a listener not owned by the managed OpenClaw Gateway service:\n{selectedPortOutput}"); + return new PortOwnershipCheck( + false, + StepResult.Fail( + $"Port {ctx.Config.GatewayPort} is already in use by another or unattributable process. Either stop the conflicting process through its owner or change GatewayPort in the setup config.")); + } + + ctx.Logger.Info( + $"Port {ctx.Config.GatewayPort} is owned by managed OpenClaw Gateway service PID {managedServicePid}"); + return new PortOwnershipCheck(true, null); + } + + internal static async Task WaitForHealthAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; ctx.Logger.Info("Waiting for gateway health endpoint..."); var healthDeadline = DateTimeOffset.UtcNow.Add(TimeSpan.FromSeconds(ctx.Config.Gateway.HealthTimeoutSeconds)); @@ -2090,25 +2338,24 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri internal enum ConnectionOutcome { Connected, PairingRequired, Error, Timeout } internal static async Task WaitForConnectionOrPairing( - OpenClawGatewayClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct) + OpenClawGatewayClient client, + SetupContext ctx, + TimeSpan timeout, + CancellationToken ct, + bool waitThroughTransientErrors = false) { var tcs = new TaskCompletionSource(); void OnStatusChanged(object? sender, ConnectionStatus status) { ctx.Logger.Debug($"Operator connection status: {status}"); - if (status == ConnectionStatus.Connected) - tcs.TrySetResult(ConnectionOutcome.Connected); - else if (status == ConnectionStatus.Error) - tcs.TrySetResult(ConnectionOutcome.Error); - else if (status == ConnectionStatus.Disconnected) - { - // Check if pairing was required — client sets IsPairingRequired before disconnect - if (client.IsPairingRequired) - tcs.TrySetResult(ConnectionOutcome.PairingRequired); - else - tcs.TrySetResult(ConnectionOutcome.Error); - } + var outcome = ConnectionOutcomeForStatus( + status, + client.IsPairingRequired, + client.IsAuthFailed, + waitThroughTransientErrors); + if (outcome is not null) + tcs.TrySetResult(outcome.Value); } client.StatusChanged += OnStatusChanged; @@ -2142,6 +2389,23 @@ void OnStatusChanged(object? sender, ConnectionStatus status) } } + internal static ConnectionOutcome? ConnectionOutcomeForStatus( + ConnectionStatus status, + bool pairingRequired, + bool authFailed, + bool waitThroughTransientErrors) + { + return status switch + { + ConnectionStatus.Connected => ConnectionOutcome.Connected, + ConnectionStatus.Disconnected when pairingRequired => ConnectionOutcome.PairingRequired, + ConnectionStatus.Error or ConnectionStatus.Disconnected when authFailed => ConnectionOutcome.Error, + ConnectionStatus.Error or ConnectionStatus.Disconnected when waitThroughTransientErrors => null, + ConnectionStatus.Error or ConnectionStatus.Disconnected => ConnectionOutcome.Error, + _ => null, + }; + } + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) { var registry = new GatewayRegistry(ctx.DataDir, logger: new SetupOpenClawLogger(ctx.Logger)); diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 39a24719e..183924150 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.RegularExpressions; +using System.Runtime.ExceptionServices; using OpenClaw.Connection; using OpenClaw.Shared; @@ -15,6 +16,8 @@ public sealed class SetupWizardRunner // so setup fails with a diagnostic instead of hanging. private readonly SetupContext _ctx; + private bool _reloadSuspended; + private bool _reloadRecoveryRecorded; public SetupWizardRunner(SetupContext ctx) { @@ -22,6 +25,184 @@ public SetupWizardRunner(SetupContext ctx) } public async Task RunAsync(CancellationToken ct) + { + var recoveryResult = await ReconcilePendingReloadRecoveryAsync(); + if (!recoveryResult.IsSuccess) + return recoveryResult; + + if (_ctx.Config.SkipWizard) + return StepResult.Skip("Gateway wizard skipped by configuration"); + + return await RunWithReloadRestorationAsync(() => RunCoreAsync(ct)); + } + + internal async Task RunWithReloadRestorationAsync(Func> runWizard) + { + StepResult? wizardResult = null; + Exception? wizardException = null; + StepResult? restoreResult = null; + try + { + wizardResult = await runWizard(); + } + catch (Exception ex) + { + wizardException = ex; + } + + if (_reloadSuspended) + { + restoreResult = await RestoreReloadModeIfNeededAsync(); + if (!restoreResult.IsSuccess) + _ctx.Logger.Error($"Gateway reload restoration failed after setup wizard exit: {restoreResult.Message}"); + } + + if (restoreResult?.IsSuccess == false) + { + if (wizardException is null) + return restoreResult; + + var restorationException = new InvalidOperationException(restoreResult.Message, restoreResult.Error); + return StepResult.Fail( + $"{restoreResult.Message} The wizard also exited with {wizardException.GetType().Name}.", + new AggregateException(restorationException, wizardException)); + } + + if (wizardException is not null) + ExceptionDispatchInfo.Capture(wizardException).Throw(); + + return wizardResult!; + } + + internal void MarkReloadSuspended() => _reloadSuspended = true; + + internal async Task RestoreReloadModeIfNeededAsync() + { + if (!_reloadSuspended) + return StepResult.Ok("Gateway reload was not suspended"); + + var result = await RestoreReloadModeAsync(); + if (result.IsSuccess) + { + _reloadSuspended = false; + _reloadRecoveryRecorded = false; + } + return result; + } + + internal async Task SuspendReloadModeAsync() + { + try + { + var readResult = await _ctx.Commands.RunInWslAsync( + _ctx.DistroName!, + $"{_ctx.WslPathPrefix}\nopenclaw config get gateway.reload.mode --json", + TimeSpan.FromSeconds(15), + ct: CancellationToken.None, + inputViaStdin: true); + if (readResult.TimedOut) + return StepResult.Fail("Failed to read gateway.reload.mode before wizard suspension."); + + var pathWasAbsent = readResult.ExitCode != 0 && readResult.Stderr.Contains( + "Config path not found: gateway.reload.mode", + StringComparison.Ordinal); + var reloadMode = readResult.ExitCode == 0 ? ExtractReloadMode(readResult.Stdout) : null; + if (!pathWasAbsent && (reloadMode is null || !GatewayReloadRecoveryStore.IsSupportedReloadMode(reloadMode))) + return StepResult.Fail("Failed to resolve gateway.reload.mode before wizard suspension."); + + GatewayReloadRecoveryStore.Save(_ctx, reloadMode); + _reloadRecoveryRecorded = true; + + var result = await _ctx.Commands.RunInWslAsync( + _ctx.DistroName!, + $"{_ctx.WslPathPrefix} && openclaw config set gateway.reload.mode off", + TimeSpan.FromSeconds(15), + ct: CancellationToken.None); + + if (result.ExitCode != 0) + return StepResult.Fail($"Failed to suspend gateway reload for wizard (exit {result.ExitCode}): {result.Stderr.Trim()}"); + + _ctx.Logger.Info("Configured gateway reload suspension; restarting gateway before wizard"); + return await StartGatewayStep.RestartAndWaitForHealthAsync(_ctx, CancellationToken.None); + } + catch (Exception ex) + { + return StepResult.Fail($"Failed to suspend gateway reload for wizard: {ex.Message}", ex); + } + } + + internal static string? ExtractReloadMode(string stdout) + { + foreach (var line in stdout.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Reverse()) + { + var candidate = line.Trim(); + if (candidate.StartsWith('"') && candidate.EndsWith('"')) + { + try + { + candidate = JsonSerializer.Deserialize(candidate) ?? string.Empty; + } + catch (JsonException) + { + continue; + } + } + + if (GatewayReloadRecoveryStore.IsSupportedReloadMode(candidate)) + return candidate; + } + + return null; + } + + internal async Task BeginReloadSuspensionAsync() + { + var recoveryResult = await ReconcilePendingReloadRecoveryAsync(); + if (!recoveryResult.IsSuccess) + return recoveryResult; + + var suspendResult = await SuspendReloadModeAsync(); + if (suspendResult.IsSuccess) + { + MarkReloadSuspended(); + return suspendResult; + } + + if (!_reloadRecoveryRecorded) + return suspendResult; + + MarkReloadSuspended(); + var restoreResult = await RestoreReloadModeIfNeededAsync(); + return restoreResult.IsSuccess ? suspendResult : restoreResult; + } + + internal async Task ReconcilePendingReloadRecoveryAsync() + { + GatewayReloadRecoveryState? recovery; + try + { + recovery = GatewayReloadRecoveryStore.Load(_ctx); + } + catch (Exception ex) + { + return StepResult.Fail($"Cannot reconcile gateway reload recovery state: {ex.Message}", ex); + } + + if (recovery is null) + return StepResult.Ok("No pending gateway reload recovery state"); + + if (!string.Equals(recovery.DistroName, _ctx.DistroName, StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail( + $"Gateway reload recovery marker targets distro '{recovery.DistroName}', " + + $"but setup targets '{_ctx.DistroName}'. Refusing to mutate either distro."); + } + + _ctx.Logger.Warn("Pending gateway reload recovery state found; restoring before setup continues"); + return await RestoreReloadModeAsync(recovery); + } + + private async Task RunCoreAsync(CancellationToken ct) { var registry = new GatewayRegistry(_ctx.DataDir, logger: new SetupOpenClawLogger(_ctx.Logger)); registry.Load(); @@ -60,10 +241,19 @@ public async Task RunAsync(CancellationToken ct) var wizardCompleted = false; var discoveredSteps = new List(); + var suspendResult = await BeginReloadSuspensionAsync(); + if (!suspendResult.IsSuccess) + return suspendResult; + try { client = CreateWizardClient(credential, identityPath, wsLogger); - var connection = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(20), ct); + var connection = await PairOperatorStep.WaitForConnectionOrPairing( + client, + _ctx, + TimeSpan.FromSeconds(20), + ct, + waitThroughTransientErrors: true); if (connection == PairOperatorStep.ConnectionOutcome.PairingRequired && _ctx.Config.AutoApprovePairing) { _ctx.Logger.Info("Wizard operator pairing required — auto-approving"); @@ -83,7 +273,9 @@ public async Task RunAsync(CancellationToken ct) return StepResult.Fail($"Cannot run gateway wizard because operator connection failed: {connection}"); _ctx.Logger.Info("Starting gateway wizard"); - var payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); + var payload = await client.SendWizardRequestAsync( + "wizard.start", + timeoutMs: 30_000); wizardStarted = true; var visits = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -111,7 +303,12 @@ async Task SendWizardNextAsync(object parameters, int timeoutMs) await Task.Delay(TimeSpan.FromSeconds(3), ct); client = CreateWizardClient(credential, identityPath, wsLogger); - var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct); + var reconnect = await PairOperatorStep.WaitForConnectionOrPairing( + client, + _ctx, + TimeSpan.FromSeconds(30), + ct, + waitThroughTransientErrors: true); if (reconnect != PairOperatorStep.ConnectionOutcome.Connected) throw new WizardFatalException($"Gateway wizard reconnect failed after restart: {reconnect}"); @@ -122,7 +319,9 @@ async Task SendWizardNextAsync(object parameters, int timeoutMs) progressPolls = 0; totalProgressPolls = 0; lastProgressStepId = ""; - return await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); + return await client.SendWizardRequestAsync( + "wizard.start", + timeoutMs: 30_000); } } @@ -253,9 +452,6 @@ async Task SendWizardNextAsync(object parameters, int timeoutMs) if (client is not null && wizardStarted && !wizardCompleted && !string.IsNullOrWhiteSpace(sessionId)) await TryCancelWizardAsync(client, sessionId); - if (wizardStarted) - await TryResetReloadModeAsync(); - if (client != null) { await client.DisconnectAsync(); @@ -286,27 +482,63 @@ private async Task TryCancelWizardAsync(OpenClawGatewayClient client, string ses } } - private async Task TryResetReloadModeAsync() + internal async Task RestoreReloadModeAsync(GatewayReloadRecoveryState? recoveryOverride = null) { try { + var recovery = recoveryOverride ?? GatewayReloadRecoveryStore.Load(_ctx); + if (recovery is not null && !string.Equals(recovery.DistroName, _ctx.DistroName, StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail( + $"Gateway reload recovery marker targets distro '{recovery.DistroName}', " + + $"but setup targets '{_ctx.DistroName}'. Refusing to mutate either distro."); + } + + var hadExplicitReloadMode = recovery is null || recovery.ReloadMode is not null; + var reloadMode = recovery?.ReloadMode is { } recoveredReloadMode + ? GatewayReloadModeConfig.Resolve(_ctx.Config.Gateway.Version, recoveredReloadMode) + : ConfigureGatewayStep.GetEffectiveReloadMode(_ctx.Config.Gateway); + var restoreCommand = hadExplicitReloadMode + ? $"openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(reloadMode)}" + : "openclaw config unset gateway.reload.mode"; var result = await _ctx.Commands.RunInWslAsync( _ctx.DistroName!, - $"{_ctx.WslPathPrefix} && openclaw config set gateway.reload.mode hybrid", + $"{_ctx.WslPathPrefix} && {restoreCommand}", TimeSpan.FromSeconds(15), ct: CancellationToken.None); - if (result.ExitCode == 0) - _ctx.Logger.Info("Reset gateway.reload.mode to hybrid after wizard"); - else - _ctx.Logger.Warn($"Failed to reset gateway.reload.mode after wizard (exit {result.ExitCode}): {result.Stderr.Trim()}"); + var absentStateAlreadyRestored = !hadExplicitReloadMode && IsMissingReloadModePath(result); + if (result.ExitCode != 0 && !absentStateAlreadyRestored) + return StepResult.Fail($"Failed to restore gateway.reload.mode after wizard (exit {result.ExitCode}): {result.Stderr.Trim()}"); + + var restoredState = hadExplicitReloadMode ? reloadMode : "its previous absent state"; + _ctx.Logger.Info($"Restored gateway.reload.mode to {restoredState} after wizard; restarting gateway to apply wizard configuration"); + var restartResult = await StartGatewayStep.RestartAndWaitForHealthAsync(_ctx, CancellationToken.None); + if (!restartResult.IsSuccess) + return StepResult.Fail($"Gateway restart after wizard failed: {restartResult.Message}", restartResult.Error); + + GatewayReloadRecoveryStore.Clear(_ctx); + _reloadSuspended = false; + _reloadRecoveryRecorded = false; + + return StepResult.Ok($"Restored gateway.reload.mode to {restoredState} and restarted gateway"); } catch (Exception ex) { - _ctx.Logger.Warn($"Failed to reset gateway.reload.mode after wizard: {ex.Message}"); + return StepResult.Fail($"Failed to restore gateway.reload.mode after wizard: {ex.Message}", ex); } } + private static bool IsMissingReloadModePath(CommandResult result) + { + if (result.ExitCode == 0) + return false; + + var output = $"{result.Stdout}\n{result.Stderr}"; + return output.Contains("Config path not found", StringComparison.OrdinalIgnoreCase) + && output.Contains("gateway.reload.mode", StringComparison.Ordinal); + } + private string WriteAnswerTemplate(IReadOnlyList discoveredSteps, WizardPayload? missingStep) { var logPath = _ctx.Config.LogPath; diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 084a18948..b7349ff79 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -85,10 +85,13 @@ "InstallUrl": null, // Pin gateway version (null = use GatewayLkgVersion.cs embedded LKG) "Version": null, + // Expected SHA-256 for an HTTP(S) .tgz Version; verified before the package is installed + "ExpectedPackageSha256": null, // Seconds to wait for gateway health endpoint before failing "HealthTimeoutSeconds": 90, - // Config reload strategy: "hot" (no restart), "restart" - "ReloadMode": "hot", + // Config reload strategy. "hybrid" and "off" work on current and legacy gateways. + // Legacy 2026.6.11 and 2026.7.2-beta.3 also accept "hot" and "restart". + "ReloadMode": "hybrid", // Auth mode: "token" (shared secret) "AuthMode": "token", // Additional gateway config key/value pairs (applied via `openclaw config set`) diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 93baa963d..913d9cf34 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -987,7 +987,9 @@ public class NodeCapabilityHealthInfo public List DisabledBySettingsCommands { get; set; } = new(); public List Warnings { get; set; } = new(); - public static NodeCapabilityHealthInfo FromNode(GatewayNodeInfo node) + public static NodeCapabilityHealthInfo FromNode( + GatewayNodeInfo node, + string? gatewayVersion = null) { var commandSet = new HashSet(node.Commands, StringComparer.OrdinalIgnoreCase); var platform = node.Platform ?? ""; @@ -1051,11 +1053,13 @@ public static NodeCapabilityHealthInfo FromNode(GatewayNodeInfo node) .ToList(); } - info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info); + info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info, gatewayVersion); return info; } - public static NodeCapabilityHealthInfo FromLocalDeclarations(GatewayNodeInfo node) + public static NodeCapabilityHealthInfo FromLocalDeclarations( + GatewayNodeInfo node, + string? gatewayVersion = null) { var info = new NodeCapabilityHealthInfo { @@ -1075,7 +1079,7 @@ public static NodeCapabilityHealthInfo FromLocalDeclarations(GatewayNodeInfo nod .ToList() }; - info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info); + info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info, gatewayVersion); return info; } } @@ -1228,6 +1232,21 @@ public static class CommandCenterCommandGroups ]; } +public static class GatewayNodeCommandPolicyConfig +{ + public const string CurrentAllowKey = "gateway.nodes.commands.allow"; + public const string LegacyAllowKey = "gateway.nodes.allowCommands"; + + public static string ResolveAllowKey(string? gatewayVersion) + { + var version = gatewayVersion?.Trim(); + return string.Equals(version, "2026.6.11", StringComparison.OrdinalIgnoreCase) || + string.Equals(version, "2026.7.2-beta.3", StringComparison.OrdinalIgnoreCase) + ? LegacyAllowKey + : CurrentAllowKey; + } +} + public static class CommandCenterDiagnostics { private static readonly IReadOnlyDictionary s_severityPriority = @@ -1248,17 +1267,29 @@ public static List SortAndDedupeWarnings(IEnumerable w.Title, StringComparer.OrdinalIgnoreCase) .ToList(); - public static string BuildAllowCommandsRepairCommand(IEnumerable commands) + public static string BuildAllowCommandsMergeGuidance( + IEnumerable commands, + string? gatewayVersion = null) { - var json = "[" + string.Join(",", commands + var commandList = string.Join(", ", commands .Where(command => !string.IsNullOrWhiteSpace(command)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Order(StringComparer.OrdinalIgnoreCase) - .Select(command => $"\"{command}\"")) + "]"; - return $"openclaw config set gateway.nodes.allowCommands '{json}'"; + .Order(StringComparer.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(commandList)) + commandList = "none"; + var allowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); + return string.Join(Environment.NewLine, [ + "Read the complete current allowlist before changing it:", + $"openclaw config get {allowKey} --json", + $"Preserve every existing entry and add only: {commandList}", + "Then write the complete updated array:", + $"openclaw config set {allowKey} ''" + ]); } - public static string BuildDangerousCommandOptInGuidance(IEnumerable commands) + public static string BuildDangerousCommandOptInGuidance( + IEnumerable commands, + string? gatewayVersion = null) { var commandList = string.Join(", ", commands .Where(command => !string.IsNullOrWhiteSpace(command)) @@ -1266,12 +1297,13 @@ public static string BuildDangerousCommandOptInGuidance(IEnumerable comm .Order(StringComparer.OrdinalIgnoreCase)); if (string.IsNullOrWhiteSpace(commandList)) commandList = "none"; + var allowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); return string.Join(Environment.NewLine, [ "Privacy-sensitive OpenClaw command opt-in guidance", $"Commands: {commandList}", "Leave these commands blocked unless you explicitly want the connected gateway to use this device's camera, microphone, or screen recording surfaces.", - "If you opt in, add only the exact commands you need to gateway.nodes.allowCommands, then re-approve or re-pair the node so the gateway refreshes its command snapshot.", + $"If you opt in, add only the exact commands you need to {allowKey}, then re-approve or re-pair the node so the gateway refreshes its command snapshot.", "Do not use wildcards for privacy-sensitive commands." ]); } @@ -1394,9 +1426,12 @@ public static List BuildTopologyWarnings( return warnings; } - public static List BuildNodeWarnings(NodeCapabilityHealthInfo node) + public static List BuildNodeWarnings( + NodeCapabilityHealthInfo node, + string? gatewayVersion = null) { var warnings = new List(); + var nodeCommandAllowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); var isPendingApproval = node.ApprovalState is GatewayNodeApprovalState.PendingApproval or GatewayNodeApprovalState.PendingReapproval; @@ -1482,9 +1517,11 @@ GatewayNodeApprovalState.PendingApproval or Severity = GatewayDiagnosticSeverity.Warning, Category = "allowlist", Title = "Safe node commands are filtered by gateway policy", - Detail = $"{missing} {(node.MissingSafeAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. After changing allowCommands, re-approve or re-pair the device if the gateway keeps an older command snapshot.", - RepairAction = "Copy safe allowlist repair command", - CopyText = BuildAllowCommandsRepairCommand(CommandCenterCommandGroups.SafeCompanionCommands) + Detail = $"{missing} {(node.MissingSafeAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. After changing {nodeCommandAllowKey}, re-approve or re-pair the device if the gateway keeps an older command snapshot.", + RepairAction = "Copy safe allowlist merge guidance", + CopyText = BuildAllowCommandsMergeGuidance( + CommandCenterCommandGroups.SafeCompanionCommands, + gatewayVersion) }); } @@ -1495,9 +1532,11 @@ GatewayNodeApprovalState.PendingApproval or Severity = GatewayDiagnosticSeverity.Info, Category = "allowlist", Title = "Privacy-sensitive commands require explicit opt-in", - Detail = string.Join(", ", node.PrivacySensitiveApprovedCommands) + " should only be available when explicitly allowed by gateway.nodes.allowCommands.", + Detail = string.Join(", ", node.PrivacySensitiveApprovedCommands) + $" should only be available when explicitly allowed by {nodeCommandAllowKey}.", RepairAction = "Copy opt-in guidance", - CopyText = BuildDangerousCommandOptInGuidance(node.PrivacySensitiveApprovedCommands) + CopyText = BuildDangerousCommandOptInGuidance( + node.PrivacySensitiveApprovedCommands, + gatewayVersion) }); } @@ -1511,7 +1550,9 @@ GatewayNodeApprovalState.PendingApproval or Title = "Privacy-sensitive commands are currently blocked", Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.", RepairAction = "Copy opt-in guidance", - CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands) + CopyText = BuildDangerousCommandOptInGuidance( + node.MissingDangerousAllowlistCommands, + gatewayVersion) }); } @@ -1524,8 +1565,10 @@ GatewayNodeApprovalState.PendingApproval or Category = "allowlist", Title = "Browser proxy command is filtered by gateway policy", Detail = $"{blocked} {(node.MissingBrowserAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. Add the exact browser command and re-approve or re-pair the node if the gateway keeps an older command snapshot.", - RepairAction = "Copy browser proxy allowlist repair command", - CopyText = BuildAllowCommandsRepairCommand(node.MissingBrowserAllowlistCommands) + RepairAction = "Copy browser proxy allowlist merge guidance", + CopyText = BuildAllowCommandsMergeGuidance( + node.MissingBrowserAllowlistCommands, + gatewayVersion) }); } diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index cc5d4a997..796e128b4 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -118,6 +118,10 @@ public record class SettingsData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? McpOnlyMode { get; set; } public string? PreferredGatewayId { get; set; } + /// Rollback points retained by count: 1 (default), 2, or -1 for indefinitely. + public int GatewayRollbackRetentionCount { get; set; } = 1; + /// Optional additional age window in days. Zero disables age retention. + public int GatewayRollbackRetentionAgeDays { get; set; } = 0; public bool HasSeenActivityStreamTip { get; set; } = false; public string? SkippedUpdateTag { get; set; } public bool NotifyChatResponses { get; set; } = true; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 44a27e439..6a3e8576c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -190,6 +190,10 @@ public IntPtr GetHubWindowHandle() private AppState? _appState; internal AppState? AppState => _appState; private UpdateCoordinator? _updateCoordinator; + private GatewayVersionAlignmentCoordinator? _gatewayVersionAlignmentCoordinator; + private string? _gatewayVersionAlignmentPromptKey; + private int _gatewayVersionAlignmentInFlight; + private int _gatewayVersionAlignmentFollowUpQueued; private GatewayService? _gatewayService; private PairingApprovalCoordinator? _pairingApprovalCoordinator; private OpenClawTray.Dialogs.PairingApprovalDialog? _pairingApprovalDialog; @@ -779,6 +783,17 @@ _dispatcherQueue is null tunnelManager: _sshTunnelService); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; + var gatewayAlignmentRunner = new WslExeCommandRunner(appLogger, defaultTimeout: TimeSpan.FromMinutes(35)); + var gatewayRollbackPoints = new GatewayRollbackPointManager( + gatewayAlignmentRunner, + AppIdentity.ResolveSetupLocalDataDirectory(), + AppIdentity.SetupDistroName); + _gatewayVersionAlignmentCoordinator = new GatewayVersionAlignmentCoordinator( + gatewayAlignmentRunner, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + gatewayRollbackPoints, + SynchronizeCompanionGatewayAsync, + ResolveGatewayRollbackRetentionPolicy); // First-run check (also supports forced onboarding for testing). // Wrapped in try/catch so a wizard construction failure cannot tear @@ -820,6 +835,7 @@ _dispatcherQueue is null var wslKeepAlive = new WslGatewayKeepAliveService(() => _settings, () => _gatewayRegistry); _ = Task.Run(wslKeepAlive.TryEnsureAsync); InitializeGatewayClient(); + _ = CheckCompanionGatewayVersionAsync(_gatewayRegistry.GetActive()?.Id); // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) if (_settings != null && @@ -2055,6 +2071,7 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _lastManagerConnectedSideEffectsKey = connectedSideEffectsKey; _ = RunHealthCheckAsync(); _ = TryConnectLocalNodeServiceAsync(); + _ = CheckCompanionGatewayVersionAsync(snap.GatewayId); } } else @@ -2063,6 +2080,340 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna } } + private async Task CheckCompanionGatewayVersionAsync(string? connectedGatewayId) + { + if (Interlocked.CompareExchange(ref _gatewayVersionAlignmentInFlight, 1, 0) != 0) + { + Interlocked.Exchange(ref _gatewayVersionAlignmentFollowUpQueued, 1); + return; + } + + try + { + var activeRecord = _gatewayRegistry?.GetActive(); + if (activeRecord == null || + !string.Equals(activeRecord.Id, connectedGatewayId, StringComparison.Ordinal) || + _gatewayVersionAlignmentCoordinator == null) + { + return; + } + + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + var probe = await _gatewayVersionAlignmentCoordinator.ProbeAsync(accessPlan); + if (probe.State is GatewayVersionAlignmentState.RecoveryAvailable + or GatewayVersionAlignmentState.VerificationFailed + or GatewayVersionAlignmentState.VersionOrderUnknown) + { + var blockedKey = $"blocked|{activeRecord.Id}|{probe.State}|{probe.RollbackPointId}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (!string.Equals(_gatewayVersionAlignmentPromptKey, blockedKey, StringComparison.Ordinal)) + { + _gatewayVersionAlignmentPromptKey = blockedKey; + ReportGatewayAlignmentResult(probe); + } + return; + } + if (probe.State == GatewayVersionAlignmentState.NewerThanRequired && + probe.InstalledVersion != null) + { + var newerKey = $"newer|{activeRecord.Id}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (!string.Equals(_gatewayVersionAlignmentPromptKey, newerKey, StringComparison.Ordinal)) + { + _gatewayVersionAlignmentPromptKey = newerKey; + ShowGatewayAlignmentToast( + "Local Gateway is newer than Companion", + $"OpenClaw {probe.InstalledVersion} was not downgraded to {probe.RequiredVersion}. Your existing WSL installation was left unchanged."); + } + return; + } + + if (probe.State == GatewayVersionAlignmentState.Aligned && + _gatewayVersionAlignmentCoordinator.HasVerifiedPendingUpdate(activeRecord.Id)) + { + var resumed = await _gatewayVersionAlignmentCoordinator.UpdateAsync(accessPlan); + ReportGatewayAlignmentResult(resumed); + if (!resumed.IsAligned) + _gatewayVersionAlignmentPromptKey = null; + return; + } + + if (probe.State != GatewayVersionAlignmentState.Mismatch || probe.InstalledVersion == null) + return; + + var promptKey = $"{activeRecord.Id}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (string.Equals(_gatewayVersionAlignmentPromptKey, promptKey, StringComparison.Ordinal)) + return; + + var accepted = await ConfirmCompanionGatewayUpdateAsync(probe); + if (!accepted) + { + _gatewayVersionAlignmentPromptKey = null; + return; + } + + var confirmedRecord = _gatewayRegistry?.GetActive(); + var confirmedPlan = GatewayHostAccessClassifier.Classify(confirmedRecord); + if (confirmedRecord == null || + !string.Equals(confirmedRecord.Id, activeRecord.Id, StringComparison.Ordinal) || + confirmedPlan.TerminalTarget != GatewayTerminalTarget.Wsl || + !confirmedPlan.CanControlWslGateway || + !string.Equals(confirmedPlan.DistroName, accessPlan.DistroName, StringComparison.Ordinal)) + { + Logger.Warn("Local Gateway update canceled because the active Companion-owned Gateway changed while confirmation was open."); + ShowGatewayAlignmentToast( + "Local Gateway update canceled", + "The active Gateway changed before the update started. No package change was made."); + return; + } + + _gatewayVersionAlignmentPromptKey = promptKey; + + ShowGatewayAlignmentToast( + "Updating local OpenClaw Gateway", + "Creating a verified offline rollback point, updating the existing WSL installation, and reconnecting Companion."); + + var result = await _gatewayVersionAlignmentCoordinator.UpdateAsync(confirmedPlan); + ReportGatewayAlignmentResult(result); + if (!result.IsAligned) + _gatewayVersionAlignmentPromptKey = null; + } + catch (Exception ex) + { + _gatewayVersionAlignmentPromptKey = null; + Logger.Warn($"Companion-owned Gateway version alignment failed: {ex.GetType().Name}: {ex.Message}"); + ShowGatewayAlignmentToast( + "Local Gateway update failed", + "OpenClaw could not complete the in-place update. The existing distro was not recreated."); + } + finally + { + Interlocked.Exchange(ref _gatewayVersionAlignmentInFlight, 0); + if (Interlocked.Exchange(ref _gatewayVersionAlignmentFollowUpQueued, 0) != 0) + { + _ = CheckCompanionGatewayVersionAsync(_gatewayRegistry?.GetActive()?.Id); + } + } + } + + private async Task ConfirmCompanionGatewayUpdateAsync(GatewayVersionAlignmentResult probe) + { + if (_dispatcherQueue == null) + { + Logger.Warn("Cannot prompt for local Gateway update without the UI dispatcher."); + return false; + } + + return await RunOnUiThreadAsync(async () => + { + ShowHub("settings"); + var hubWindow = _hubWindow + ?? throw new InvalidOperationException("The Hub window could not be created."); + await hubWindow.WaitForCurrentContentReadyAsync(); + var root = hubWindow.Content as FrameworkElement; + if (root?.XamlRoot == null) + throw new InvalidOperationException("The Hub window has no active XAML root."); + + var dialog = new ContentDialog + { + Title = "Update local OpenClaw Gateway", + Content = + $"This Companion requires OpenClaw {probe.RequiredVersion}, but its existing WSL Gateway " + + $"is {probe.InstalledVersion}. Update this same installation now?\n\n" + + "Companion will stop this distro briefly, export and verify a complete offline VHD rollback point, " + + "then update OpenClaw in place and reconnect the Gateway and Windows Node. The distro is never " + + "recreated during update. Emergency restore remains a separate explicit action.", + PrimaryButtonText = "Update now", + CloseButtonText = "Later", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = root.XamlRoot + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + }); + } + + private Task RunOnUiThreadAsync(Func> action) + { + if (_dispatcherQueue?.HasThreadAccess == true) + return action(); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (_dispatcherQueue?.TryEnqueue(async () => + { + try { completion.SetResult(await action()); } + catch (Exception ex) { completion.SetException(ex); } + }) != true) + { + completion.SetException(new InvalidOperationException("The UI dispatcher is unavailable.")); + } + + return completion.Task; + } + + private async Task SynchronizeCompanionGatewayAsync( + string expectedGatewayId, + CancellationToken cancellationToken) + { + var connectionManager = _connectionManager + ?? throw new InvalidOperationException("Gateway connection manager is unavailable."); + var activeRecord = _gatewayRegistry?.GetActive(); + if (activeRecord == null || + !string.Equals(activeRecord.Id, expectedGatewayId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The active Gateway changed during version alignment."); + } + + var operatorReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void ObserveState(object? _, GatewayConnectionSnapshot snapshot) + { + if (!string.Equals(snapshot.GatewayId, expectedGatewayId, StringComparison.Ordinal)) + return; + + switch (snapshot.OperatorState) + { + case RoleConnectionState.Connected: + operatorReady.TrySetResult(true); + break; + case RoleConnectionState.Error: + case RoleConnectionState.PairingRejected: + case RoleConnectionState.PairingRequired: + case RoleConnectionState.RateLimited: + operatorReady.TrySetException(new InvalidOperationException( + snapshot.OperatorError ?? $"Gateway operator connection reached {snapshot.OperatorState}.")); + break; + } + } + + connectionManager.StateChanged += ObserveState; + try + { + await connectionManager.ReconnectAsync(); + ObserveState(connectionManager, connectionManager.CurrentSnapshot); + await operatorReady.Task.WaitAsync(TimeSpan.FromSeconds(45), cancellationToken); + + if (_settings?.EnableNodeMode == true) + await connectionManager.EnsureNodeConnectedAsync(cancellationToken); + + var synchronized = connectionManager.CurrentSnapshot; + if (!string.Equals(synchronized.GatewayId, expectedGatewayId, StringComparison.Ordinal) || + synchronized.OperatorState != RoleConnectionState.Connected || + (_settings?.EnableNodeMode == true && + (synchronized.NodeState != RoleConnectionState.Connected || + synchronized.NodePairingStatus != PairingStatus.Paired))) + { + throw new InvalidOperationException("Companion connection state changed before synchronization completed."); + } + } + finally + { + connectionManager.StateChanged -= ObserveState; + } + } + + private void ReportGatewayAlignmentResult(GatewayVersionAlignmentResult result) + { + switch (result.State) + { + case GatewayVersionAlignmentState.Updated: + ShowGatewayAlignmentToast( + "Local OpenClaw Gateway updated", + $"Companion, Windows Node, and Gateway are synchronized on {result.RequiredVersion}."); + break; + case GatewayVersionAlignmentState.Restored: + ShowGatewayAlignmentToast( + "Local Gateway rollback restored", + $"OpenClaw {result.InstalledVersion} and its complete retained state were restored and resynchronized."); + break; + case GatewayVersionAlignmentState.RestoreCancelled: + ShowGatewayAlignmentToast( + "Staged Gateway restore cancelled", + "The non-destructive staged restore was cancelled. No WSL registration or installed Gateway state was changed."); + break; + case GatewayVersionAlignmentState.RollbackPointFailed: + ShowGatewayAlignmentToast( + "Local Gateway update not started", + "The required verified offline rollback point could not be created, so Companion made no package change."); + break; + case GatewayVersionAlignmentState.RecoveryAvailable: + ShowGatewayAlignmentToast( + "Local Gateway needs attention", + "The update did not complete healthy. A verified rollback point is available in Settings for explicit emergency restore."); + break; + default: + ShowGatewayAlignmentToast( + "Local Gateway update failed", + result.FailureSummary ?? "The existing WSL installation was left in place."); + break; + } + } + + private GatewayRollbackRetentionPolicy ResolveGatewayRollbackRetentionPolicy() + { + var count = _settings?.GatewayRollbackRetentionCount ?? 1; + var ageDays = _settings?.GatewayRollbackRetentionAgeDays ?? 0; + return new GatewayRollbackRetentionPolicy( + count, + ageDays > 0 ? TimeSpan.FromDays(ageDays) : null); + } + + internal IReadOnlyList GetGatewayRollbackPoints() => + _gatewayVersionAlignmentCoordinator?.ListRollbackPoints() ?? []; + + internal async Task RestoreGatewayRollbackPointAsync( + string rollbackPointId, + CancellationToken cancellationToken = default) + { + var coordinator = _gatewayVersionAlignmentCoordinator; + var activeRecord = _gatewayRegistry?.GetActive(); + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + if (coordinator == null || activeRecord == null) + { + return new GatewayVersionAlignmentResult( + GatewayVersionAlignmentState.Ineligible, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + FailureSummary: "No active Companion-owned Gateway is available."); + } + + var result = await coordinator.RestoreAsync( + accessPlan, + rollbackPointId, + rollbackPointId, + cancellationToken); + ReportGatewayAlignmentResult(result); + return result; + } + + internal GatewayVersionAlignmentResult CancelGatewayRollbackPointRestore( + string rollbackPointId, + string confirmedRollbackPointId) + { + var coordinator = _gatewayVersionAlignmentCoordinator; + var activeRecord = _gatewayRegistry?.GetActive(); + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + if (coordinator == null || activeRecord == null) + { + return new GatewayVersionAlignmentResult( + GatewayVersionAlignmentState.Ineligible, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + FailureSummary: "No active Companion-owned Gateway is available."); + } + + var result = coordinator.CancelRestore( + accessPlan, rollbackPointId, confirmedRollbackPointId); + ReportGatewayAlignmentResult(result); + return result; + } + + private void ShowGatewayAlignmentToast(string title, string detail) + { + try + { + _toastService?.ShowToast(new ToastContentBuilder().AddText(title).AddText(detail)); + } + catch (Exception ex) + { + Logger.Debug($"Gateway alignment toast suppressed: {ex.Message}"); + } + } + private NodeService? EnsureNodeService(SettingsManager settings) { if (_nodeService != null) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 36e3ce599..4370528d6 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -613,6 +613,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +