diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd0a3e8f7..7d58c767a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,116 @@ jobs:
exit 1
fi
+ gateway-composed:
+ needs: repo-hygiene
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ outputs:
+ sha256: ${{ steps.package.outputs.sha256 }}
+ version: ${{ steps.package.outputs.version }}
+ 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: 6277c35d9a97cb128d32ed7ab5632cfc4163c746
+ 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.15.1 --activate
+
+ - name: Compose main with exact PR 110382 patch
+ shell: bash
+ run: |
+ set -euo pipefail
+ base_sha=6277c35d9a97cb128d32ed7ab5632cfc4163c746
+ wizard_base=6277c35d9a97cb128d32ed7ab5632cfc4163c746
+ wizard_head=6edc1e40f37b833ec0493ec331eba6cd49db4fb6
+ fork_repository=https://github.com/TheAngryPit/openclaw.git
+ wizard_fetch_ref=refs/heads/codex/fix-gateway-wizard-rpc-exits
+ wizard_paths="$RUNNER_TEMP/pr110382-expected-paths.txt"
+ staged_paths="$RUNNER_TEMP/staged-paths.txt"
+ wizard_patch="$RUNNER_TEMP/pr110382.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
+ test "$(git rev-parse HEAD)" = "$base_sha"
+ git fetch --no-tags "$fork_repository" \
+ "+${wizard_fetch_ref}:refs/remotes/fork/pr-110382"
+ test "$(git rev-parse refs/remotes/fork/pr-110382)" = "$wizard_head"
+ git merge-base --is-ancestor "$wizard_base" "$wizard_head"
+ git diff --name-only "$wizard_base" "$wizard_head" | sort | diff -u "$wizard_paths" -
+ git diff --binary "$wizard_base" "$wizard_head" -- $(cat "$wizard_paths") >"$wizard_patch"
+ test -s "$wizard_patch"
+ sha256sum "$wizard_patch" >"$RUNNER_TEMP/pr110382-patch.SHA256SUM"
+ git apply --index "$wizard_patch"
+ git diff --cached --name-only | sort >"$staged_paths"
+ diff -u "$wizard_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.tgz
+ tarball="$RUNNER_TEMP/gateway-composed/openclaw-2026.7.2-main-pr110382.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")"
+ test "${#sha256}" -eq 64
+ test "${#wizard_patch_sha256}" -eq 64
+ printf '%s %s\n' "$sha256" "$(basename "$tarball")" >"$RUNNER_TEMP/gateway-composed/SHA256SUMS"
+ printf '{"source_ref":"main","source_sha":"%s","included_upstream_prs":[{"pr":111956,"merge_commit":"2296e898e13a5b250bf557e9632e000c093e61f1"}],"fixes":[{"pr":110382,"base":"%s","head":"%s","fetch_repository":"TheAngryPit/openclaw","fetch_ref":"codex/fix-gateway-wizard-rpc-exits","patch_sha256":"%s"}],"changed_paths":["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"],"package_version":"%s","package_sha256":"%s"}\n' \
+ 6277c35d9a97cb128d32ed7ab5632cfc4163c746 \
+ 6277c35d9a97cb128d32ed7ab5632cfc4163c746 \
+ 6edc1e40f37b833ec0493ec331eba6cd49db4fb6 \
+ "$wizard_patch_sha256" \
+ "$packaged_version" \
+ "$sha256" >"$RUNNER_TEMP/gateway-composed/provenance.json"
+ echo "sha256=$sha256" >>"$GITHUB_OUTPUT"
+ echo "version=$packaged_version" >>"$GITHUB_OUTPUT"
+
+ - name: Upload composed gateway package
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: openclaw-main-pr110382-composed
+ path: ${{ runner.temp }}/gateway-composed/
+ if-no-files-found: error
+ retention-days: 7
+
test:
needs: repo-hygiene
if: ${{ !cancelled() }}
@@ -338,29 +448,52 @@ 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
+ continue-on-error: ${{ matrix.continue_on_error || false }}
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
+ continue_on_error: true
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 +528,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-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.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_source == 'composed' && needs.gateway-composed.outputs.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 +587,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 +618,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/.github/workflows/gateway-lkg-update.yml b/.github/workflows/gateway-lkg-update.yml
index c5e34dbca..0026bb94d 100644
--- a/.github/workflows/gateway-lkg-update.yml
+++ b/.github/workflows/gateway-lkg-update.yml
@@ -20,23 +20,62 @@ jobs:
BRANCH_NAME: automation/gateway-lkg-update
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
+ - name: Check out exact default-branch head
+ shell: bash
+ run: |
+ git fetch origin "${DEFAULT_BRANCH}"
+ git checkout --detach "origin/${DEFAULT_BRANCH}"
+
- name: Resolve pinned and latest gateway versions
id: versions
shell: bash
run: |
pinned="$(python3 - <<'PY'
import pathlib, re
- source = pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs").read_text(encoding="utf-8")
- match = re.search(r'LkgVersion\s*=\s*"([^"]+)"', source)
- if not match:
+ lkg_path = pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs")
+ metadata_path = pathlib.Path("src/OpenClaw.Shared/OpenClaw.Shared.csproj")
+ lkg_test_path = pathlib.Path("tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs")
+ target_test_path = pathlib.Path("tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs")
+ lkg_match = re.search(r'LkgVersion\s*=\s*"([^"]+)"', lkg_path.read_text(encoding="utf-8"))
+ metadata_match = re.search(
+ r']*>([^<]+)',
+ metadata_path.read_text(encoding="utf-8"),
+ )
+ lkg_test_match = re.search(
+ r'ExpectedLkgVersion\s*=\s*"([^"]+)"',
+ lkg_test_path.read_text(encoding="utf-8"),
+ )
+ target_test_match = re.search(
+ r'BuildTargetResolver_UsesOrdinaryUpstreamAssemblyDefault\(\).*?'
+ r'Assert\.Equal\("([^"]+)", target\.ExpectedVersion\);',
+ target_test_path.read_text(encoding="utf-8"),
+ re.DOTALL,
+ )
+ if not lkg_match:
raise SystemExit("Could not parse LkgVersion constant from GatewayLkgVersion.cs")
- pinned = match.group(1)
- if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+([.\-+][A-Za-z0-9.\-]+)*', pinned):
- raise SystemExit(f"Pinned LkgVersion has unexpected format: {pinned}")
+ if not metadata_match:
+ raise SystemExit("Could not parse OpenClawGatewayPackageExpectedVersion from OpenClaw.Shared.csproj")
+ if not lkg_test_match:
+ raise SystemExit("Could not parse ExpectedLkgVersion from GatewayLkgVersionTests.cs")
+ if not target_test_match:
+ raise SystemExit("Could not parse ordinary upstream target version assertion")
+ pinned = lkg_match.group(1)
+ metadata_version = metadata_match.group(1)
+ lkg_test_version = lkg_test_match.group(1)
+ target_test_version = target_test_match.group(1)
+ if metadata_version != pinned or lkg_test_version != pinned or target_test_version != pinned:
+ raise SystemExit(
+ "Gateway package defaults disagree: "
+ f"LkgVersion={pinned}, metadata={metadata_version}, "
+ f"lkg-test={lkg_test_version}, "
+ f"target-test={target_test_version}"
+ )
+ if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', pinned):
+ raise SystemExit(f"Pinned LkgVersion must be an exact stable version: {pinned}")
print(pinned)
PY
)"
@@ -45,8 +84,8 @@ jobs:
with urllib.request.urlopen('https://registry.npmjs.org/openclaw/latest', timeout=30) as r:
payload = json.load(r)
latest = payload['version']
- if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+([.\-+][A-Za-z0-9.\-]+)*', latest):
- raise SystemExit(f"Resolved npm latest version has unexpected format: {latest}")
+ if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', latest):
+ raise SystemExit(f"Resolved npm latest must be an exact stable version: {latest}")
print(latest)
PY
)"
@@ -56,7 +95,13 @@ jobs:
echo "latest=${latest}"
} >> "$GITHUB_OUTPUT"
- if [[ "$pinned" != "$latest" ]]; then
+ if python3 - "$pinned" "$latest" <<'PY'
+ import sys
+ pinned = tuple(map(int, sys.argv[1].split(".")))
+ latest = tuple(map(int, sys.argv[2].split(".")))
+ raise SystemExit(0 if latest > pinned else 1)
+ PY
+ then
echo "drifted=true" >> "$GITHUB_OUTPUT"
else
echo "drifted=false" >> "$GITHUB_OUTPUT"
@@ -84,32 +129,76 @@ jobs:
import os
import pathlib, re
latest = os.environ["LATEST_VERSION"]
- path = pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs")
- source = path.read_text(encoding="utf-8")
- pattern = r'(LkgVersion\s*=\s*")([^"]+)(")'
- updated, count = re.subn(pattern, lambda m: f"{m.group(1)}{latest}{m.group(3)}", source, count=1)
- if count != 1:
- raise SystemExit("Failed to rewrite LkgVersion constant")
- path.write_text(updated, encoding="utf-8")
+ replacements = (
+ (
+ pathlib.Path("src/OpenClaw.SetupEngine/GatewayLkgVersion.cs"),
+ r'(LkgVersion\s*=\s*")([^"]+)(")',
+ "LkgVersion constant",
+ ),
+ (
+ pathlib.Path("src/OpenClaw.Shared/OpenClaw.Shared.csproj"),
+ r'(]*>)([^<]+)()',
+ "OpenClawGatewayPackageExpectedVersion",
+ ),
+ (
+ pathlib.Path("tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs"),
+ r'(ExpectedLkgVersion\s*=\s*")([^"]+)(")',
+ "ExpectedLkgVersion constant",
+ ),
+ (
+ pathlib.Path("tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs"),
+ r'(?s)(BuildTargetResolver_UsesOrdinaryUpstreamAssemblyDefault\(\).*?'
+ r'Assert\.Equal\(")([^"]+)(", target\.ExpectedVersion\);)',
+ "ordinary upstream target assertion",
+ ),
+ )
+ for path, pattern, label in replacements:
+ source = path.read_text(encoding="utf-8")
+ updated, count = re.subn(
+ pattern,
+ lambda m: f"{m.group(1)}{latest}{m.group(3)}",
+ source,
+ count=1,
+ )
+ if count != 1:
+ raise SystemExit(f"Failed to rewrite {label} in {path}")
+ path.write_text(updated, encoding="utf-8")
PY
- name: Commit and push branch
+ id: push_update
if: ${{ steps.versions.outputs.drifted == 'true' }}
shell: bash
run: |
- if git diff --quiet -- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs; then
+ update_paths=(
+ src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
+ src/OpenClaw.Shared/OpenClaw.Shared.csproj
+ tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs
+ tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs
+ )
+ if git diff --quiet -- "${update_paths[@]}"; then
echo "No LKG change detected after update write."
+ echo "changed_and_pushed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
+ git add "${update_paths[@]}"
git commit -m "chore(setup): bump gateway LKG to ${{ steps.versions.outputs.latest }}"
- git push --force-with-lease origin "${BRANCH_NAME}"
+ remote_head="$(git ls-remote --heads origin "refs/heads/${BRANCH_NAME}" | awk '{print $1}')"
+ if [[ -n "${remote_head}" ]]; then
+ git push \
+ --force-with-lease="refs/heads/${BRANCH_NAME}:${remote_head}" \
+ origin \
+ "HEAD:refs/heads/${BRANCH_NAME}"
+ else
+ git push origin "HEAD:refs/heads/${BRANCH_NAME}"
+ fi
+ echo "changed_and_pushed=true" >> "$GITHUB_OUTPUT"
- name: Create or update standing draft PR
- if: ${{ steps.versions.outputs.drifted == 'true' }}
+ if: ${{ steps.versions.outputs.drifted == 'true' && steps.push_update.outputs.changed_and_pushed == 'true' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@@ -121,7 +210,9 @@ jobs:
- Previous pinned LKG: \`${{ steps.versions.outputs.pinned }}\`
- Candidate latest: \`${{ steps.versions.outputs.latest }}\`
- - Updated file: \`src/OpenClaw.SetupEngine/GatewayLkgVersion.cs\`
+ - Updated files: LKG source, package metadata, and both LKG contract tests
+
+ The automation only proposes exact stable versions newer than the current pin.
This is the standing automation PR used to review and validate gateway LKG bumps before merge.
EOF
diff --git a/README.md b/README.md
index 3424a7162..5556b4651 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/custom packages use `gateway.nodes.commands.allow`; the supported legacy versions `2026.6.11`, `2026.7.1`, and `2026.7.2-beta.3` use `gateway.nodes.allowCommands`.
+
+ Current/custom 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`, `2026.7.1`, 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 11c15352a..3ae8f6bb7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -117,6 +117,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, verified protection point, and Gateway/Node/pairing health gate completion | GatewayVersionAlignmentCoordinatorTests.UpdateAsync_DefaultNativeProtectionCompletesHealthyTransaction | behavioral | - |
+| gateway-rollback-points | authoritative | none | native backup protection by default; opt-in offline VHD export; manifest receipts, retention, verification, and explicit VHD emergency restore | OpenClaw.Connection/GatewayRollbackPointManager | SettingsPage owns selection and confirmation UI only | native backups preserve portable recovery material without stopping the distro; only immutable verified FullVhd points permit unregister/import-in-place restore; 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..529c3d696 100644
--- a/docs/CONNECTION_ARCHITECTURE.md
+++ b/docs/CONNECTION_ARCHITECTURE.md
@@ -105,6 +105,39 @@ 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` resolves one immutable `GatewayPackageTarget` for the process: package source (`Official` or `Composed`), exact expected version, optional credential-free HTTP(S) `.tgz` URI, and SHA-256. The target is embedded as shared-assembly build metadata. Ordinary upstream builds resolve to the repository's Official `2026.7.1` LKG; a composed build must supply all composed-package fields without placing a private URI or digest in source.
+
+Update routing is a pure, immutable two-lane decision made before package dispatch:
+
+- **Companion installer lane:** every installed version at or below `2026.7.2` (including prereleases and older versions), every unproven source version, and every composed target. Official targets use the upstream TLS-fetched installer pinned to the exact required version; this path does not claim an artifact-digest guarantee. Composed targets carry an immutable package URI and SHA-256, and verify that package before invoking the installer. Both forms send zero `update.run` or `update.complete` RPCs and remain protected by a verified protection point plus exact post-update version verification. Sources older than `2026.7.2` predate the native backup contract and therefore use Full VHD protection even when the default preference is native backup.
+- **Core transaction lane:** only an official target from an explicitly audited future installed version. This lane sends one `update.run`, retains the accepted Core transaction provenance, and later sends `update.complete`.
+
+Both lanes share the same rollback and exact-version gates. 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 create a verified native OpenClaw backup by default, without stopping the distro; an explicitly selected `FullVhd` mode first proves host capacity, then terminates only that distro and exports a complete offline VHD;
+4. verifies the selected artifact's byte size, SHA-256, private manifest receipt, distro machine identity, and exact OpenClaw version (including build metadata); `FullVhd` additionally verifies the VHDX signature and repeats identity/version attestation after export;
+5. durably records the immutable target, selected route, and `Prepared` dispatch state before crossing either dispatch boundary;
+6. dispatches exactly once through the selected lane; Core records `Ambiguous` if the RPC result is not trusted or `Accepted` with the original request ID, transaction ID, and confirmation deadline, while the Companion lane records `Accepted` after installer dispatch returns;
+7. verifies the exact installed version, resynchronizes Gateway, Companion, Windows Node, and pairing, and re-verifies the rollback point plus live distro identity;
+8. for an accepted Core transaction only, calls `update.complete` with the persisted transaction ID and original request ID, outcome `healthy` plus the observed exact version, or outcome `failed` plus the actually observed exact version when a valid probe result is available; and
+9. marks the local update healthy, re-attests the live distro immediately before cleanup, and only then applies retention.
+
+Normal update never unregisters, imports, recreates, or directly replaces the distro's `ext4.vhdx`. The verified protection point remains the fail-closed recovery boundary until the update is healthy. A native backup is created in the distro and streamed by `wsl.exe` to a host-owned partial file before hash verification and atomic promotion; it does not depend on disabled WSL drive automounts. A native backup is portable recovery material but is not Companion-restoreable; only an explicitly selected verified `FullVhd` point supports the emergency restore operation below. Any durable `Prepared`, `Ambiguous`, or `Accepted` dispatch receipt blocks a second package dispatch and blocks cross-lane fallback. `Prepared` is deliberately treated as possibly dispatched; it is not permission to retry. `Ambiguous` requires explicit recovery. An accepted Core receipt may resume finalization after process restart only from its persisted request ID, transaction ID, and deadline; it never sends a second `update.run`. An accepted Companion-installer receipt likewise never falls through to Core or repeats the installer. 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 or a different immutable target blocks for explicit recovery.
+
+Settings resolves a pending native-backup receipt without pretending to restore it. The operator confirms the exact point, Companion re-verifies the retained backup and probes the live distro, then accepts only the exact recorded source or target version. Recovery ownership follows the fixed Companion-owned distro and exact receipt, so replacement of the mutable Gateway registry record does not strand historical recovery. The source path must also pass distro identity plus Gateway, Windows Node, and pairing synchronization before the receipt becomes `RecoveryResolved`; accepted Core transactions are completed with the observed failed/source outcome first, while the target path re-enters normal post-update finalization with its persisted dispatch provenance. Any third version, identity drift, missing provenance, Core completion failure, or health failure preserves the blocking receipt.
+
+Immediately after the durable receipt write and before dispatch, and again before Core healthy completion or retention 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.
+
+Privileged `update.run` and `update.complete` dispatch is bound to the expected Gateway record and fails closed if the live active-connection identity changes before send. Caller cancellation flows through the long-running correlated RPC wait, removes pending correlation state, releases the coordinator operation gate, and leaves the durable `UpdateInProgress` rollback receipt intact.
+
+Emergency restore is a separate confirmed operation available only for a verified `FullVhd` point. 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. The default keeps one previous verified point plus every point younger than seven days. Settings also allow two previous versions or indefinite retention, and the age window remains additive to the count floor. Points are cleanup-eligible only after successful post-update or post-restore health. Unresolved update or restore receipts retain their artifacts even if a later verification fails, so cleanup cannot destroy the evidence required to diagnose or resolve the blocking receipt.
+
## Connection state machine
`ConnectionStateMachine` (internal) drives state transitions for both operator and node roles:
diff --git a/docs/CONNECTION_PROTOCOL_RESEARCH.md b/docs/CONNECTION_PROTOCOL_RESEARCH.md
index e35151058..1d7b2d8bf 100644
--- a/docs/CONNECTION_PROTOCOL_RESEARCH.md
+++ b/docs/CONNECTION_PROTOCOL_RESEARCH.md
@@ -47,7 +47,7 @@ Public upstream gateway sources reviewed:
- `https://github.com/openclaw/openclaw/blob/main/src/gateway/server-methods/nodes.ts`
- `https://github.com/openclaw/openclaw/blob/main/packages/gateway-protocol/src/schema/frames.ts`
-The setup engine currently pins gateway LKG `2026.6.11` in
+The setup engine currently pins gateway LKG `2026.7.1` in
`src/OpenClaw.SetupEngine/GatewayLkgVersion.cs`. Before implementing behavior
that depends on newer upstream `main`, compare the installed LKG against the
reviewed upstream docs/code.
diff --git a/docs/MISSION_CONTROL.md b/docs/MISSION_CONTROL.md
index d3629853d..07b2ac985 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/custom `gateway.nodes.commands.allow`; legacy `2026.6.11`, `2026.7.1`, 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..a206ff602 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/custom packages use
+`gateway.nodes.commands.allow`; exact legacy versions `2026.6.11`, `2026.7.1`,
+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..ce37c9fba 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.7.1` LKG, supported legacy `2026.6.11`, 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..29ac1ca2a 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/custom; `gateway.nodes.allowCommands` on `2026.6.11`, `2026.7.1`, 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/custom packages, or `gateway.nodes.allowCommands` for `2026.6.11`, `2026.7.1`, 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/adr/0001-gateway-lkg-security-floor.md b/docs/adr/0001-gateway-lkg-security-floor.md
new file mode 100644
index 000000000..fea3f097a
--- /dev/null
+++ b/docs/adr/0001-gateway-lkg-security-floor.md
@@ -0,0 +1,53 @@
+# ADR 0001: Gateway LKG Security Floor
+
+## Status
+
+Accepted
+
+## Context
+
+The Windows Companion installs an official OpenClaw Gateway version when a
+composed package is not supplied. That ordinary fallback is a security and
+compatibility boundary, not a rollback convenience.
+
+OpenClaw `2026.7.1` includes requester-identity and browser-node authorization
+hardening that is absent from `2026.6.11`. Reverting the fallback to
+`2026.6.11` therefore traded known security fixes for compatibility and was the
+wrong layer in which to address later release regressions.
+
+Composed Companion builds have a separate immutable package contract: exact
+version, credential-free package URI, and SHA-256. Their target may follow a
+reviewed OpenClaw commit without changing the official fallback policy.
+
+## Decision
+
+The official Gateway LKG security floor is `2026.7.1`.
+
+The pin may change only when all of the following are true:
+
+1. The candidate is an official stable OpenClaw release.
+2. The candidate version is strictly newer than the current pin.
+3. Companion compatibility tests pass against the exact candidate.
+4. The change is reviewed through the standing LKG update pull request.
+
+The pin must not move to:
+
+- an older stable release;
+- a beta, release candidate, nightly, branch, or commit build;
+- a custom or composed package reference.
+
+Custom main-based builds remain represented only by the composed package
+metadata contract. A regression in a newer stable release must be fixed,
+isolated, or explicitly blocked. It must not be worked around by silently
+downgrading the ordinary fallback below this security floor.
+
+## Consequences
+
+- Ordinary Companion installs retain the security fixes shipped in
+ `2026.7.1`.
+- The automated LKG workflow fails closed for prerelease versions and creates
+ updates only for a strictly newer stable release.
+- Main-based validation and operator builds remain possible without conflating
+ composed package provenance with the official fallback.
+- A future stable regression requires an explicit compatibility decision rather
+ than an implicit downgrade.
diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md
index 58397a3bc..63a0ebf21 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/custom
+`gateway.nodes.commands.allow`, or legacy `gateway.nodes.allowCommands` on
+`2026.6.11`, `2026.7.1`, 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/custom schema. On `2026.6.11`, `2026.7.1`, 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/custom `gateway.nodes.commands.deny` key wins over
+`gateway.nodes.commands.allow`. On `2026.6.11`, `2026.7.1`, and `2026.7.2-beta.3`, use the
+legacy equivalents `gateway.nodes.denyCommands` and
+`gateway.nodes.allowCommands`.
---
@@ -195,9 +207,13 @@ 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.7.1` LKG, supported legacy `2026.6.11`, and
+CI-pinned `2026.7.2-beta.3` predate that schema migration, so SetupEngine writes
+their equivalent `gateway.nodes.allowCommands` key only when one of those exact
+versions is selected.
---
@@ -314,7 +330,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 +342,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/custom explicit opt-in list the
+gateway uses after platform defaults. Exact legacy versions `2026.6.11`,
+`2026.7.1`, 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/custom `gateway.nodes.commands.deny` key can be used as a final
+explicit blocklist; those three 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 +362,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 +390,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 +403,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..9f6dc1680 100644
--- a/scripts/validate-mxc-e2e.ps1
+++ b/scripts/validate-mxc-e2e.ps1
@@ -148,10 +148,77 @@ 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]$ExpectedVersion,
+ [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 ($ExpectedVersion -notmatch "^\d{4}\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z-]+)*)?$") {
+ throw "OPENCLAW_E2E_GATEWAY_VERSION must be the exact composed-package semantic version."
+ }
+ 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."
+ }
+ $packageJson = [string](& wsl.exe -d $HostDistroName -u root -- tar -xOf $hostPackagePath package/package.json)
+ if ($LASTEXITCODE -ne 0) {
+ throw "The reviewed composed gateway package does not contain package/package.json."
+ }
+ $packageVersion = ($packageJson | ConvertFrom-Json).version
+ if (-not [string]::Equals($packageVersion, $ExpectedVersion, [StringComparison]::Ordinal)) {
+ throw "OPENCLAW_E2E_GATEWAY_VERSION does not match the reviewed composed package."
+ }
+ $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 version=$ExpectedVersion 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 +226,29 @@ 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."
+ }
+ if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_VERSION)) {
+ throw "OPENCLAW_E2E_GATEWAY_VERSION must identify the reviewed composed package version before formal MXC validation."
+ }
+
+ Assert-ReviewedComposedGatewayPackage `
+ -PackageSpec $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC `
+ -ExpectedSha256 $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 `
+ -ExpectedVersion $env:OPENCLAW_E2E_GATEWAY_VERSION `
+ -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_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/GatewayPackageBuildTargetResolver.cs b/src/OpenClaw.Connection/GatewayPackageBuildTargetResolver.cs
new file mode 100644
index 000000000..ccea42f8f
--- /dev/null
+++ b/src/OpenClaw.Connection/GatewayPackageBuildTargetResolver.cs
@@ -0,0 +1,92 @@
+using System.Reflection;
+using OpenClaw.Shared;
+
+namespace OpenClaw.Connection;
+
+///
+/// Resolves the immutable Gateway package identity embedded in the shared
+/// assembly at build time.
+///
+public static class GatewayPackageBuildTargetResolver
+{
+ public const string SourceMetadataKey = "OpenClawGatewayPackageSource";
+ public const string ExpectedVersionMetadataKey = "OpenClawGatewayPackageExpectedVersion";
+ public const string PackageUriMetadataKey = "OpenClawGatewayPackageUri";
+ public const string Sha256MetadataKey = "OpenClawGatewayPackageSha256";
+
+ public static GatewayPackageTarget Resolve() =>
+ Resolve(typeof(AppVersionInfo).Assembly);
+
+ internal static GatewayPackageTarget Resolve(Assembly assembly)
+ {
+ ArgumentNullException.ThrowIfNull(assembly);
+ return Resolve(assembly
+ .GetCustomAttributes()
+ .Select(attribute => new KeyValuePair(
+ attribute.Key,
+ attribute.Value)));
+ }
+
+ internal static GatewayPackageTarget Resolve(
+ IEnumerable> metadata)
+ {
+ ArgumentNullException.ThrowIfNull(metadata);
+ var values = metadata
+ .Where(item =>
+ item.Key is SourceMetadataKey or ExpectedVersionMetadataKey or PackageUriMetadataKey or Sha256MetadataKey)
+ .GroupBy(item => item.Key, StringComparer.Ordinal)
+ .ToDictionary(
+ group => group.Key,
+ group =>
+ {
+ var matches = group.ToArray();
+ if (matches.Length != 1)
+ throw new InvalidOperationException($"Duplicate Gateway package metadata '{group.Key}'.");
+ return matches[0].Value?.Trim();
+ },
+ StringComparer.Ordinal);
+
+ var source = Require(values, SourceMetadataKey);
+ var expectedVersion = Require(values, ExpectedVersionMetadataKey);
+ values.TryGetValue(PackageUriMetadataKey, out var packageUri);
+ values.TryGetValue(Sha256MetadataKey, out var sha256);
+
+ if (source.Equals(nameof(GatewayPackageSource.Official), StringComparison.OrdinalIgnoreCase))
+ {
+ if (!string.IsNullOrWhiteSpace(packageUri) || !string.IsNullOrWhiteSpace(sha256))
+ {
+ throw new InvalidOperationException(
+ "Official Gateway package metadata cannot include a package URI or SHA-256.");
+ }
+
+ return GatewayPackageTarget.Official(expectedVersion);
+ }
+
+ if (source.Equals(nameof(GatewayPackageSource.Composed), StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ return GatewayPackageTarget.Composed(
+ expectedVersion,
+ new Uri(Require(values, PackageUriMetadataKey), UriKind.Absolute),
+ Require(values, Sha256MetadataKey));
+ }
+ catch (Exception ex) when (ex is ArgumentException or UriFormatException)
+ {
+ throw new InvalidOperationException("Invalid composed Gateway package build metadata.", ex);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Gateway package metadata '{SourceMetadataKey}' must be Official or Composed.");
+ }
+
+ private static string Require(
+ IReadOnlyDictionary values,
+ string key)
+ {
+ if (!values.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
+ throw new InvalidOperationException($"Missing Gateway package metadata '{key}'.");
+ return value;
+ }
+}
diff --git a/src/OpenClaw.Connection/GatewayPackageTarget.cs b/src/OpenClaw.Connection/GatewayPackageTarget.cs
new file mode 100644
index 000000000..670b3388e
--- /dev/null
+++ b/src/OpenClaw.Connection/GatewayPackageTarget.cs
@@ -0,0 +1,310 @@
+using System.Numerics;
+using System.Text.RegularExpressions;
+using OpenClaw.Shared;
+
+namespace OpenClaw.Connection;
+
+public enum GatewayPackageSource
+{
+ Official,
+ Composed
+}
+
+public enum GatewayPackageUpdateRoute
+{
+ CompanionInstaller,
+ CoreTransaction
+}
+
+public enum GatewayUpdateDispatchState
+{
+ Prepared,
+ Ambiguous,
+ Accepted,
+ Cancelled
+}
+
+public enum GatewayUpdateCompletionState
+{
+ Prepared,
+ Ambiguous,
+ Accepted
+}
+
+///
+/// Immutable identity for the exact Gateway package the Companion is allowed to install.
+///
+public sealed partial record GatewayPackageTarget
+{
+ private GatewayPackageTarget(
+ GatewayPackageSource source,
+ string expectedVersion,
+ Uri? packageUri,
+ string? sha256)
+ {
+ Source = source;
+ ExpectedVersion = expectedVersion;
+ PackageUri = packageUri;
+ Sha256 = sha256;
+ }
+
+ public GatewayPackageSource Source { get; }
+ public string ExpectedVersion { get; }
+ public Uri? PackageUri { get; }
+ public string? Sha256 { get; }
+
+ public static GatewayPackageTarget Official(string expectedVersion)
+ {
+ return new(
+ GatewayPackageSource.Official,
+ NormalizeExactVersion(expectedVersion),
+ packageUri: null,
+ sha256: null);
+ }
+
+ public static GatewayPackageTarget Composed(
+ string expectedVersion,
+ Uri packageUri,
+ string sha256)
+ {
+ ArgumentNullException.ThrowIfNull(packageUri);
+ if (!packageUri.IsAbsoluteUri ||
+ !packageUri.IsWellFormedOriginalString() ||
+ (packageUri.Scheme != Uri.UriSchemeHttp && packageUri.Scheme != Uri.UriSchemeHttps) ||
+ string.IsNullOrEmpty(packageUri.Host) ||
+ !packageUri.AbsolutePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) ||
+ !string.IsNullOrEmpty(packageUri.UserInfo) ||
+ !string.IsNullOrEmpty(packageUri.Query) ||
+ !string.IsNullOrEmpty(packageUri.Fragment))
+ {
+ throw new ArgumentException(
+ "Composed Gateway package URI must be an absolute, credential-free HTTP(S) .tgz URI without query or fragment.",
+ nameof(packageUri));
+ }
+
+ ArgumentException.ThrowIfNullOrWhiteSpace(sha256);
+ var normalizedSha256 = sha256.Trim().ToLowerInvariant();
+ if (!Sha256Regex().IsMatch(normalizedSha256))
+ throw new ArgumentException("Gateway package SHA-256 must contain exactly 64 hexadecimal characters.", nameof(sha256));
+
+ return new(
+ GatewayPackageSource.Composed,
+ NormalizeExactVersion(expectedVersion),
+ packageUri,
+ normalizedSha256);
+ }
+
+ internal static bool TryRestore(
+ GatewayPackageSource source,
+ string expectedVersion,
+ string? packageUri,
+ string? sha256,
+ out GatewayPackageTarget? target)
+ {
+ target = null;
+ try
+ {
+ target = source switch
+ {
+ GatewayPackageSource.Official when packageUri is null && sha256 is null =>
+ Official(expectedVersion),
+ GatewayPackageSource.Composed when packageUri is not null && sha256 is not null =>
+ Composed(expectedVersion, new Uri(packageUri, UriKind.Absolute), sha256),
+ _ => null
+ };
+ return target is not null;
+ }
+ catch (Exception ex) when (ex is ArgumentException or UriFormatException)
+ {
+ return false;
+ }
+ }
+
+ private static string NormalizeExactVersion(string value)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(value);
+ var normalized = value.Trim();
+ if (!ExactVersionRegex().IsMatch(normalized))
+ throw new ArgumentException("Gateway package target requires an exact semantic version.", nameof(value));
+ return normalized;
+ }
+
+ internal static int ComparePrecedence(string left, string right)
+ {
+ var normalizedLeft = NormalizeExactVersion(left);
+ var normalizedRight = NormalizeExactVersion(right);
+ var leftParts = ParsePrecedence(normalizedLeft);
+ var rightParts = ParsePrecedence(normalizedRight);
+
+ for (var i = 0; i < leftParts.Core.Count; i++)
+ {
+ var coreComparison = leftParts.Core[i].CompareTo(rightParts.Core[i]);
+ if (coreComparison != 0)
+ return coreComparison;
+ }
+
+ if (leftParts.PreRelease.Count == 0)
+ return rightParts.PreRelease.Count == 0 ? 0 : 1;
+ if (rightParts.PreRelease.Count == 0)
+ return -1;
+
+ for (var i = 0; i < Math.Min(leftParts.PreRelease.Count, rightParts.PreRelease.Count); i++)
+ {
+ var leftIdentifier = leftParts.PreRelease[i];
+ var rightIdentifier = rightParts.PreRelease[i];
+ var leftNumeric = leftIdentifier.All(char.IsAsciiDigit);
+ var rightNumeric = rightIdentifier.All(char.IsAsciiDigit);
+ var comparison = leftNumeric && rightNumeric
+ ? BigInteger.Parse(leftIdentifier).CompareTo(BigInteger.Parse(rightIdentifier))
+ : leftNumeric ? -1
+ : rightNumeric ? 1
+ : string.Compare(leftIdentifier, rightIdentifier, StringComparison.Ordinal);
+ if (comparison != 0)
+ return comparison;
+ }
+
+ return leftParts.PreRelease.Count.CompareTo(rightParts.PreRelease.Count);
+ }
+
+ private static SemanticVersionPrecedence ParsePrecedence(string version)
+ {
+ var withoutBuildMetadata = version.Split('+', 2)[0];
+ var coreAndPreRelease = withoutBuildMetadata.Split('-', 2);
+ return new(
+ coreAndPreRelease[0].Split('.').Select(BigInteger.Parse).ToArray(),
+ coreAndPreRelease.Length == 2 ? coreAndPreRelease[1].Split('.').ToArray() : []);
+ }
+
+ private sealed record SemanticVersionPrecedence(
+ IReadOnlyList Core,
+ IReadOnlyList PreRelease);
+
+ private const string ExactVersionPattern =
+ @"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" +
+ @"(?:-(?:(?:0|[1-9]\d*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))" +
+ @"(?:\.(?:(?:0|[1-9]\d*)|(?:[0-9A-Za-z-]*[A-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[0-9a-f]{64}\z", RegexOptions.CultureInvariant)]
+ private static partial Regex Sha256Regex();
+}
+
+///
+/// Pure first-adoption route policy. Unknown and legacy source versions stay on
+/// the Companion installer lane. Core is available only for explicitly audited
+/// official-package source versions.
+///
+public sealed class GatewayPackageUpdateRoutePolicy
+{
+ private const string LastLegacySourceVersion = "2026.7.2";
+
+ private readonly HashSet _auditedCoreSourceVersions;
+
+ public GatewayPackageUpdateRoutePolicy(IEnumerable? auditedCoreSourceVersions = null)
+ {
+ _auditedCoreSourceVersions = new(StringComparer.Ordinal);
+ foreach (var version in auditedCoreSourceVersions ?? [])
+ _auditedCoreSourceVersions.Add(GatewayPackageTarget.Official(version).ExpectedVersion);
+ }
+
+ public GatewayPackageUpdateRoute Select(string installedVersion, GatewayPackageTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ var normalizedInstalledVersion = GatewayPackageTarget.Official(installedVersion).ExpectedVersion;
+ if (target.Source == GatewayPackageSource.Composed ||
+ GatewayPackageTarget.ComparePrecedence(normalizedInstalledVersion, LastLegacySourceVersion) <= 0 ||
+ !_auditedCoreSourceVersions.Contains(normalizedInstalledVersion))
+ {
+ return GatewayPackageUpdateRoute.CompanionInstaller;
+ }
+
+ return GatewayPackageUpdateRoute.CoreTransaction;
+ }
+}
+
+///
+/// Canonical Gateway installer command construction shared by first install and
+/// in-place Companion updates.
+///
+public static class GatewayPackageInstallCommandBuilder
+{
+ public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh";
+
+ public static string Build(
+ string installUrl,
+ string? requestedVersion,
+ string? expectedPackageSha256 = null)
+ {
+ var escapedUrl = WslShellQuoting.EscapePosixSingleQuoteInner(installUrl);
+ if (!string.IsNullOrWhiteSpace(expectedPackageSha256))
+ return BuildVerifiedPackage(escapedUrl, requestedVersion, expectedPackageSha256);
+
+ if (string.IsNullOrWhiteSpace(requestedVersion))
+ return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash";
+
+ var trimmedVersion = requestedVersion.Trim();
+ if (trimmedVersion.Contains('\n') || trimmedVersion.Contains('\r'))
+ throw new ArgumentException("Gateway version cannot contain newlines.");
+
+ var escapedVersion = WslShellQuoting.EscapePosixSingleQuoteInner(trimmedVersion);
+ return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'";
+ }
+
+ public static string Build(string installUrl, GatewayPackageTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ return target.Source switch
+ {
+ GatewayPackageSource.Official =>
+ Build(installUrl, target.ExpectedVersion),
+ GatewayPackageSource.Composed =>
+ Build(installUrl, target.PackageUri!.AbsoluteUri, target.Sha256),
+ _ => throw new ArgumentOutOfRangeException(nameof(target))
+ };
+ }
+
+ private static string BuildVerifiedPackage(
+ 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.IsWellFormedOriginalString() ||
+ (packageUri.Scheme != Uri.UriSchemeHttp && packageUri.Scheme != Uri.UriSchemeHttps) ||
+ string.IsNullOrEmpty(packageUri.Host) ||
+ !packageUri.AbsolutePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) ||
+ !string.IsNullOrEmpty(packageUri.UserInfo) ||
+ !string.IsNullOrEmpty(packageUri.Query) ||
+ !string.IsNullOrEmpty(packageUri.Fragment))
+ {
+ throw new ArgumentException(
+ "Expected gateway package SHA-256 requires Version to be a credential-free HTTP(S) .tgz URL without query or fragment.");
+ }
+
+ 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\"";
+ }
+}
diff --git a/src/OpenClaw.Connection/GatewayRollbackPointManager.cs b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs
new file mode 100644
index 000000000..7899139cd
--- /dev/null
+++ b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs
@@ -0,0 +1,1983 @@
+using OpenClaw.Shared;
+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,
+ RecoveryResolved,
+ RestoreStaged,
+ UnregisterPending,
+ DistroUnregistered,
+ ImportPending,
+ Imported,
+ RestoreCancelled,
+ RestoreHealthy,
+ Failed
+}
+
+public enum GatewayRollbackPointVerificationStatus
+{
+ Pending,
+ Verified,
+ Failed
+}
+
+public enum GatewayUpdateProtectionMode
+{
+ FullVhd,
+ NativeBackup
+}
+
+public sealed record GatewayRollbackPointManifest
+{
+ public int SchemaVersion { get; init; } = 4;
+ 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 GatewayUpdateProtectionMode ProtectionMode { get; init; } = GatewayUpdateProtectionMode.FullVhd;
+ public string? NativeBackupSha256 { get; init; }
+ public long NativeBackupSizeBytes { get; init; }
+ public string? NodeCommandAllowSnapshotJson { get; init; }
+ public GatewayPackageSource? UpdateTargetSource { get; init; }
+ public string? UpdateTargetPackageUri { get; init; }
+ public string? UpdateTargetSha256 { get; init; }
+ public GatewayPackageUpdateRoute? UpdateRoute { get; init; }
+ public GatewayUpdateDispatchState? UpdateDispatchState { get; init; }
+ public string? UpdateRequestId { get; init; }
+ public string? UpdateTransactionId { get; init; }
+ public DateTimeOffset? UpdateConfirmationDeadlineUtc { get; init; }
+ public GatewayUpdateCompletionState? UpdateCompletionState { get; init; }
+ public string? UpdateCompletionRequestId { get; init; }
+ public string? UpdateCompletionOutcome { get; init; }
+ public string? UpdateCompletionObservedVersion { get; init; }
+ public string? LastFailureCode { get; init; }
+}
+
+public sealed record GatewayRollbackPointInfo(
+ string Id,
+ string DistroName,
+ string OpenClawVersion,
+ DateTimeOffset CreatedAtUtc,
+ GatewayRollbackPointVerificationStatus VerificationStatus,
+ GatewayRollbackPointPhase Phase,
+ GatewayUpdateProtectionMode ProtectionMode,
+ long ApproximateSizeBytes,
+ bool RestoreEligible,
+ string? FailureCode);
+
+public sealed record GatewayRollbackRetentionPolicy(int RetainPreviousVersions, TimeSpan? RetainYoungerThan)
+{
+ public static GatewayRollbackRetentionPolicy Default { get; } = new(1, TimeSpan.FromDays(7));
+
+ 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 verified native-backup protection and optional immutable offline VHD
+/// rollback points for one Companion-owned WSL distro. Normal update never
+/// unregisters or imports. Those lifecycle operations are reachable only for
+/// restore-eligible VHD points through the explicit restore method.
+///
+public sealed partial class GatewayRollbackPointManager
+{
+ private const string ManifestFileName = "manifest.json";
+ private const string RollbackVhdFileName = "rollback.vhdx";
+ private const string NativeBackupFileName = "openclaw-backup.tar.gz";
+ private const string LiveVhdFileName = "ext4.vhdx";
+ private const string VhdxSignature = "vhdxfile";
+ private const long MinimumVhdExportSafetyMarginBytes = 1024L * 1024 * 1024;
+ 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 Func _availableFreeSpaceBytes;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+
+ public GatewayRollbackPointManager(
+ IWslCommandRunner commandRunner,
+ string localDataRoot,
+ string ownedDistroName,
+ Func? utcNow = null,
+ Func? availableFreeSpaceBytes = 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);
+ _availableFreeSpaceBytes = availableFreeSpaceBytes ?? GetAvailableFreeSpaceBytes;
+ }
+
+ 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 [];
+
+ try
+ {
+ 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();
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
+ {
+ return [];
+ }
+ }
+
+ public async Task CreateVerifiedAsync(
+ string distroName,
+ string gatewayId,
+ string openClawVersion,
+ string targetOpenClawVersion,
+ GatewayUpdateProtectionMode protectionMode,
+ 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 (!Enum.IsDefined(protectionMode))
+ return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "protection_mode_invalid");
+ if (protectionMode == GatewayUpdateProtectionMode.FullVhd)
+ {
+ var capacity = ValidateVhdExportCapacity();
+ if (!capacity.Sufficient)
+ return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: capacity.FailureCode);
+ }
+
+ 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,
+ ProtectionMode = protectionMode
+ };
+ WriteManifest(manifest);
+ if (protectionMode == GatewayUpdateProtectionMode.NativeBackup)
+ return await CreateVerifiedNativeBackupAsync(manifest, cancellationToken).ConfigureAwait(false);
+
+ 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);
+ }
+ }
+
+ private async Task CreateVerifiedNativeBackupAsync(
+ GatewayRollbackPointManifest manifest,
+ CancellationToken cancellationToken)
+ {
+ var archivePath = GetNativeBackupPath(manifest.Id);
+ var partialPath = archivePath + ".partial";
+ if (!HasSafePathBoundary(GetPointDirectory(manifest.Id), archivePath))
+ return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "native_backup_reparse_boundary");
+ var distroStagePath = $"/tmp/openclaw-windows-companion-{manifest.Id}";
+ var distroArchivePath = $"{distroStagePath}/{NativeBackupFileName}";
+
+ var verifiedManifestCommitted = false;
+ try
+ {
+ var backup = await _commandRunner.RunInDistroAsync(
+ manifest.DistroName,
+ BuildNativeBackupCommand(
+ distroArchivePath,
+ manifest.DefaultUserName,
+ manifest.DefaultUserUid),
+ cancellationToken).ConfigureAwait(false);
+ if (!backup.Success)
+ return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "native_backup_command_failed", backup.ExitCode);
+ if (!TryParseVerifiedNativeBackup(
+ backup.StandardOutput,
+ distroArchivePath,
+ out var expectedSha256,
+ out var expectedSizeBytes))
+ return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "native_backup_receipt_invalid");
+
+ var transfer = await _commandRunner.CopyFileFromDistroAsync(
+ manifest.DistroName,
+ distroArchivePath,
+ partialPath,
+ cancellationToken).ConfigureAwait(false);
+ if (!transfer.Success)
+ return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "native_backup_transfer_failed", transfer.ExitCode);
+
+ var verification = await VerifyNativeBackupAsync(
+ partialPath, expectedSha256, expectedSizeBytes, cancellationToken).ConfigureAwait(false);
+ if (!verification.Verified)
+ return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, verification.FailureCode ?? "native_backup_verify_failed");
+ File.Move(partialPath, archivePath);
+
+ var versionAfterBackup = await ProbeExactVersionAsync(manifest.DistroName, cancellationToken).ConfigureAwait(false);
+ var identityAfterBackup = await ProbeInternalIdentityHashAsync(manifest.DistroName, cancellationToken).ConfigureAwait(false);
+ var defaultUserAfterBackup = await ProbeDefaultUserAsync(manifest.DistroName, cancellationToken).ConfigureAwait(false);
+ if (!string.Equals(versionAfterBackup, manifest.OpenClawVersion, StringComparison.Ordinal) ||
+ !string.Equals(identityAfterBackup, manifest.InternalIdentitySha256, StringComparison.Ordinal) ||
+ defaultUserAfterBackup is null ||
+ defaultUserAfterBackup.Name != manifest.DefaultUserName ||
+ defaultUserAfterBackup.Uid != manifest.DefaultUserUid)
+ {
+ return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "native_backup_state_attestation_changed");
+ }
+
+ manifest = manifest with
+ {
+ UpdatedAtUtc = _utcNow(),
+ NativeBackupSha256 = verification.Sha256,
+ NativeBackupSizeBytes = verification.SizeBytes,
+ VerificationStatus = GatewayRollbackPointVerificationStatus.Verified,
+ Phase = GatewayRollbackPointPhase.Verified,
+ RestoreEligible = false,
+ 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)
+ DeleteGeneratedFile(archivePath, GetPointDirectory(manifest.Id));
+ try
+ {
+ await _commandRunner.RunInDistroAsync(
+ manifest.DistroName,
+ BuildNativeBackupCleanupCommand(distroStagePath),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ // The host copy is authoritative; stale in-distro staging is bounded to this receipt id.
+ }
+ }
+ }
+
+ private (bool Sufficient, string? FailureCode) ValidateVhdExportCapacity()
+ {
+ var liveVhdPath = Path.Combine(GetInstallDirectory(), LiveVhdFileName);
+ try
+ {
+ if (!HasSafePathBoundary(_localDataRoot, liveVhdPath) ||
+ !File.Exists(liveVhdPath) ||
+ IsReparsePoint(liveVhdPath))
+ {
+ return (false, "vhd_live_path_unavailable");
+ }
+
+ var liveSizeBytes = new FileInfo(liveVhdPath).Length;
+ if (liveSizeBytes <= 0)
+ return (false, "vhd_live_size_invalid");
+ var proportionalMargin = checked((liveSizeBytes + 9) / 10);
+ var safetyMargin = Math.Max(MinimumVhdExportSafetyMarginBytes, proportionalMargin);
+ var requiredBytes = checked(liveSizeBytes + safetyMargin);
+ var availableBytes = _availableFreeSpaceBytes(_pointsRoot);
+ return availableBytes >= requiredBytes
+ ? (true, null)
+ : (false, "vhd_export_insufficient_space");
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException
+ or NotSupportedException or PathTooLongException or OverflowException)
+ {
+ return (false, "vhd_export_capacity_probe_failed");
+ }
+ }
+
+ private static IReadOnlyList BuildNativeBackupCommand(
+ string wslArchivePath,
+ string expectedUserName,
+ uint expectedUserUid) =>
+ [
+ "bash",
+ "-lc",
+ $"expected_user={WslShellQuoting.QuotePosixSingleQuote(expectedUserName)} && " +
+ $"expected_uid={WslShellQuoting.QuotePosixSingleQuote(expectedUserUid.ToString())} && " +
+ "test \"$(id -un)\" = \"$expected_user\" && test \"$(id -u)\" = \"$expected_uid\" && " +
+ $"archive={WslShellQuoting.QuotePosixSingleQuote(wslArchivePath)} && " +
+ "stage=\"$(dirname -- \"$archive\")\" && " +
+ "mkdir -m 700 -- \"$stage\" && test ! -L \"$stage\" && test -O \"$stage\" && " +
+ $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && " +
+ "receipt=\"$(openclaw backup create --output \"$archive\" --verify --json)\" && " +
+ "digest=\"$(sha256sum -- \"$archive\" | cut -d ' ' -f 1)\" && " +
+ "size=\"$(stat -c %s -- \"$archive\")\" && " +
+ "OPENCLAW_BACKUP_RECEIPT=\"$receipt\" OPENCLAW_BACKUP_SHA256=\"$digest\" OPENCLAW_BACKUP_SIZE=\"$size\" " +
+ "node -e 'const receipt=JSON.parse(process.env.OPENCLAW_BACKUP_RECEIPT); " +
+ "if(receipt.verified!==true||receipt.archivePath!==process.argv[1])process.exit(2); " +
+ "const size=Number(process.env.OPENCLAW_BACKUP_SIZE); " +
+ "if(!/^[0-9a-f]{64}$/.test(process.env.OPENCLAW_BACKUP_SHA256)||!Number.isSafeInteger(size)||size<=0)process.exit(3); " +
+ "process.stdout.write(JSON.stringify({...receipt,sha256:process.env.OPENCLAW_BACKUP_SHA256,sizeBytes:size}));' \"$archive\""
+ ];
+
+ private static IReadOnlyList BuildNativeBackupCleanupCommand(string wslStagePath) =>
+ ["rm", "-rf", "--", wslStagePath];
+
+ private static bool TryParseVerifiedNativeBackup(
+ string? output,
+ string expectedArchivePath,
+ out string expectedSha256,
+ out long expectedSizeBytes)
+ {
+ expectedSha256 = string.Empty;
+ expectedSizeBytes = 0;
+ try
+ {
+ using var document = JsonDocument.Parse(output ?? string.Empty);
+ var root = document.RootElement;
+ if (root.ValueKind != JsonValueKind.Object ||
+ !root.TryGetProperty("verified", out var verified) ||
+ verified.ValueKind != JsonValueKind.True ||
+ !root.TryGetProperty("archivePath", out var archivePath) ||
+ archivePath.ValueKind != JsonValueKind.String ||
+ !string.Equals(archivePath.GetString(), expectedArchivePath, StringComparison.Ordinal) ||
+ !root.TryGetProperty("sha256", out var sha256) ||
+ sha256.ValueKind != JsonValueKind.String ||
+ !Sha256Regex().IsMatch(sha256.GetString() ?? string.Empty) ||
+ !root.TryGetProperty("sizeBytes", out var sizeBytes) ||
+ !sizeBytes.TryGetInt64(out expectedSizeBytes) ||
+ expectedSizeBytes <= 0)
+ {
+ return false;
+ }
+
+ expectedSha256 = sha256.GetString()!;
+ return true;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ 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)
+ return false;
+
+ var result = manifest.ProtectionMode switch
+ {
+ GatewayUpdateProtectionMode.FullVhd when manifest.RestoreEligible =>
+ await VerifyVhdAsync(
+ GetRollbackVhdPath(pointId), manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false),
+ GatewayUpdateProtectionMode.NativeBackup when !manifest.RestoreEligible =>
+ await VerifyNativeBackupAsync(
+ GetNativeBackupPath(pointId),
+ manifest.NativeBackupSha256,
+ manifest.NativeBackupSizeBytes,
+ cancellationToken).ConfigureAwait(false),
+ _ => new VhdVerification(false, null, 0, "point_protection_metadata_invalid")
+ };
+ 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;
+ }
+
+ if (!await AttestLiveDistroOwnershipAsync(
+ manifest,
+ distroName,
+ cancellationToken).ConfigureAwait(false))
+ {
+ return false;
+ }
+
+ var exactVersion = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false);
+ return string.Equals(exactVersion, normalizedExpectedVersion, StringComparison.Ordinal);
+ }
+
+ private async Task AttestLiveDistroOwnershipAsync(
+ GatewayRollbackPointManifest manifest,
+ string distroName,
+ CancellationToken cancellationToken)
+ {
+ 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;
+ }
+
+ return true;
+ }
+
+ public void MarkUpdateInProgress(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.UpdateInProgress);
+
+ public void MarkPostUpdateHealthy(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.PostUpdateHealthy);
+
+ public GatewayRollbackPointManifest MarkNativeRecoveryResolved(
+ string pointId,
+ bool cancelUnacceptedInstallerDispatch = false)
+ {
+ if (!TryLoadPoint(pointId, out var manifest) ||
+ manifest is null ||
+ !IsManifestOwned(manifest) ||
+ manifest.ProtectionMode != GatewayUpdateProtectionMode.NativeBackup ||
+ manifest.Phase != GatewayRollbackPointPhase.UpdateInProgress)
+ {
+ throw new InvalidOperationException("The native recovery receipt is not eligible for resolution.");
+ }
+
+ if (cancelUnacceptedInstallerDispatch &&
+ (manifest.UpdateRoute != GatewayPackageUpdateRoute.CompanionInstaller ||
+ manifest.UpdateDispatchState is not (
+ GatewayUpdateDispatchState.Prepared or GatewayUpdateDispatchState.Ambiguous)))
+ {
+ throw new InvalidOperationException("Only an unaccepted Companion installer dispatch can be cancelled.");
+ }
+
+ var updated = manifest with
+ {
+ Phase = GatewayRollbackPointPhase.RecoveryResolved,
+ UpdateDispatchState = cancelUnacceptedInstallerDispatch
+ ? GatewayUpdateDispatchState.Cancelled
+ : manifest.UpdateDispatchState,
+ UpdatedAtUtc = _utcNow(),
+ LastFailureCode = null
+ };
+ WriteManifest(updated);
+ return updated;
+ }
+
+ 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 GatewayRollbackPointManifest ArmUpdateDispatch(
+ string pointId,
+ GatewayPackageTarget target,
+ GatewayPackageUpdateRoute route,
+ string? requestId)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ 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 ||
+ !string.Equals(manifest.TargetOpenClawVersion, target.ExpectedVersion, StringComparison.Ordinal) ||
+ (route == GatewayPackageUpdateRoute.CoreTransaction && string.IsNullOrWhiteSpace(requestId)) ||
+ (route == GatewayPackageUpdateRoute.CompanionInstaller && requestId is not null))
+ {
+ throw new InvalidOperationException("The update dispatch cannot be armed for this rollback point and target.");
+ }
+
+ var updated = manifest with
+ {
+ Phase = GatewayRollbackPointPhase.UpdateInProgress,
+ UpdateTargetSource = target.Source,
+ UpdateTargetPackageUri = target.PackageUri?.AbsoluteUri,
+ UpdateTargetSha256 = target.Sha256,
+ UpdateRoute = route,
+ UpdateDispatchState = GatewayUpdateDispatchState.Prepared,
+ UpdateRequestId = requestId,
+ UpdateTransactionId = null,
+ UpdateConfirmationDeadlineUtc = null,
+ UpdateCompletionState = null,
+ UpdateCompletionRequestId = null,
+ UpdateCompletionOutcome = null,
+ UpdateCompletionObservedVersion = null,
+ UpdatedAtUtc = _utcNow(),
+ LastFailureCode = null
+ };
+ WriteManifest(updated);
+ return updated;
+ }
+
+ public GatewayRollbackPointManifest MarkUpdateDispatchAmbiguous(string pointId)
+ {
+ return UpdateDispatch(
+ pointId,
+ GatewayUpdateDispatchState.Ambiguous,
+ transactionId: null,
+ confirmationDeadlineUtc: null);
+ }
+
+ public GatewayRollbackPointManifest RecordCoreUpdateAccepted(
+ string pointId,
+ string transactionId,
+ DateTimeOffset confirmationDeadlineUtc)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(transactionId);
+ return UpdateDispatch(
+ pointId,
+ GatewayUpdateDispatchState.Accepted,
+ transactionId.Trim(),
+ confirmationDeadlineUtc);
+ }
+
+ public GatewayRollbackPointManifest MarkInstallerDispatchAccepted(string pointId)
+ {
+ return UpdateDispatch(
+ pointId,
+ GatewayUpdateDispatchState.Accepted,
+ transactionId: null,
+ confirmationDeadlineUtc: null);
+ }
+
+ public GatewayRollbackPointManifest ArmCoreUpdateCompletion(
+ string pointId,
+ string completionRequestId,
+ string outcome,
+ string? observedVersion)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(completionRequestId);
+ var normalizedOutcome = NormalizeCompletionOutcome(outcome);
+ if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest) ||
+ manifest.Phase != GatewayRollbackPointPhase.UpdateInProgress ||
+ manifest.UpdateRoute != GatewayPackageUpdateRoute.CoreTransaction ||
+ manifest.UpdateDispatchState != GatewayUpdateDispatchState.Accepted ||
+ string.IsNullOrWhiteSpace(manifest.UpdateTransactionId) ||
+ manifest.UpdateConfirmationDeadlineUtc is null ||
+ manifest.UpdateCompletionState is not null)
+ {
+ throw new InvalidOperationException("The Core update completion cannot be armed for this rollback point.");
+ }
+
+ var updated = manifest with
+ {
+ UpdateCompletionState = GatewayUpdateCompletionState.Prepared,
+ UpdateCompletionRequestId = completionRequestId.Trim(),
+ UpdateCompletionOutcome = normalizedOutcome,
+ UpdateCompletionObservedVersion = observedVersion?.Trim(),
+ UpdatedAtUtc = _utcNow(),
+ LastFailureCode = null
+ };
+ WriteManifest(updated);
+ return updated;
+ }
+
+ public GatewayRollbackPointManifest MarkCoreUpdateCompletionAmbiguous(string pointId) =>
+ UpdateCoreCompletion(pointId, GatewayUpdateCompletionState.Ambiguous);
+
+ public GatewayRollbackPointManifest MarkCoreUpdateCompletionAccepted(string pointId) =>
+ UpdateCoreCompletion(pointId, GatewayUpdateCompletionState.Accepted);
+
+ private GatewayRollbackPointManifest UpdateCoreCompletion(
+ string pointId,
+ GatewayUpdateCompletionState state)
+ {
+ if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest) ||
+ manifest.Phase != GatewayRollbackPointPhase.UpdateInProgress ||
+ manifest.UpdateRoute != GatewayPackageUpdateRoute.CoreTransaction ||
+ manifest.UpdateDispatchState != GatewayUpdateDispatchState.Accepted ||
+ manifest.UpdateCompletionState != GatewayUpdateCompletionState.Prepared)
+ {
+ throw new InvalidOperationException("The armed Core update completion receipt is missing or invalid.");
+ }
+
+ var updated = manifest with
+ {
+ UpdateCompletionState = state,
+ UpdatedAtUtc = _utcNow(),
+ LastFailureCode = null
+ };
+ WriteManifest(updated);
+ return updated;
+ }
+
+ private GatewayRollbackPointManifest UpdateDispatch(
+ string pointId,
+ GatewayUpdateDispatchState state,
+ string? transactionId,
+ DateTimeOffset? confirmationDeadlineUtc)
+ {
+ if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest) ||
+ manifest.Phase != GatewayRollbackPointPhase.UpdateInProgress ||
+ manifest.UpdateRoute is null ||
+ manifest.UpdateDispatchState is null)
+ {
+ throw new InvalidOperationException("The armed update dispatch receipt is missing or invalid.");
+ }
+
+ var isCore = manifest.UpdateRoute == GatewayPackageUpdateRoute.CoreTransaction;
+ if ((isCore && state == GatewayUpdateDispatchState.Accepted &&
+ (string.IsNullOrWhiteSpace(transactionId) || confirmationDeadlineUtc is null)) ||
+ (!isCore && (transactionId is not null || confirmationDeadlineUtc is not null)))
+ {
+ throw new InvalidOperationException("The update dispatch result does not match its durable route.");
+ }
+
+ var updated = manifest with
+ {
+ UpdateDispatchState = state,
+ UpdateTransactionId = transactionId,
+ UpdateConfirmationDeadlineUtc = confirmationDeadlineUtc,
+ UpdatedAtUtc = _utcNow(),
+ LastFailureCode = null
+ };
+ WriteManifest(updated);
+ return updated;
+ }
+
+ private static string NormalizeCompletionOutcome(string outcome)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(outcome);
+ var normalized = outcome.Trim();
+ return normalized is "healthy" or "failed"
+ ? normalized
+ : throw new ArgumentException("Core update completion outcome must be healthy or failed.", nameof(outcome));
+ }
+
+ 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");
+ if (manifest.ProtectionMode != GatewayUpdateProtectionMode.FullVhd || !manifest.RestoreEligible)
+ return new(GatewayRollbackOperationState.VerificationFailed, manifest, "point_not_vhd_restore_eligible");
+
+ 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
+ {
+ if (!HasSafeStagingBoundary(stagePath))
+ return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "restore_stage_reparse_boundary");
+
+ 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) && !HasSafeStagingBoundary(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 (!HasSafeStagingBoundary(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();
+ if (HasUnreadableReceipt())
+ return 0;
+
+ var ownedManifests = LoadOwnedManifests();
+ foreach (var unverified in ownedManifests.Where(point =>
+ point.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified &&
+ point.Phase != GatewayRollbackPointPhase.UpdateInProgress &&
+ !IsUnresolvedRestorePhase(point.Phase)))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var pointDirectory = GetPointDirectory(unverified.Id);
+ var rollbackVhdPath = GetRollbackVhdPath(unverified.Id);
+ DeleteGeneratedFile(rollbackVhdPath, pointDirectory);
+ DeleteGeneratedFile(rollbackVhdPath + ".partial", pointDirectory);
+ var nativeBackupPath = GetNativeBackupPath(unverified.Id);
+ DeleteGeneratedFile(nativeBackupPath, pointDirectory);
+ DeleteGeneratedFile(nativeBackupPath + ".partial", pointDirectory);
+ }
+
+ var manifests = ownedManifests
+ .Where(point =>
+ point.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified &&
+ point.WasKnownGood)
+ .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);
+ foreach (var point in manifests.Where(point =>
+ point.Phase == GatewayRollbackPointPhase.UpdateInProgress ||
+ IsUnresolvedRestorePhase(point.Phase)))
+ {
+ 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.RecoveryResolved
+ 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 (!HasSafeStagingBoundary(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, _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.ProtectionMode == GatewayUpdateProtectionMode.FullVhd &&
+ 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 VerifyNativeBackupAsync(
+ string path,
+ string? expectedSha256,
+ long? expectedSize,
+ CancellationToken cancellationToken)
+ {
+ if (!File.Exists(path) || IsReparsePoint(path))
+ return new(false, null, 0, "native_backup_missing");
+
+ 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 <= 0 || (expectedSize.HasValue && size != expectedSize.Value))
+ return new(false, null, size, "native_backup_size_mismatch");
+ var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false))
+ .ToLowerInvariant();
+ if (stream.Length != size)
+ return new(false, hash, stream.Length, "native_backup_size_changed");
+ if (expectedSha256 is not null && !string.Equals(hash, expectedSha256, StringComparison.Ordinal))
+ return new(false, hash, size, "native_backup_hash_mismatch");
+ return new(true, hash, size, null);
+ }
+
+ private static long GetAvailableFreeSpaceBytes(string destinationPath)
+ {
+ var root = Path.GetPathRoot(NormalizePath(destinationPath));
+ if (string.IsNullOrWhiteSpace(root))
+ throw new IOException("The rollback destination has no volume root.");
+ return new DriveInfo(root).AvailableFreeSpace;
+ }
+
+ 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)),
+ NormalizePath(Path.Combine(directory, NativeBackupFileName)),
+ NormalizePath(Path.Combine(directory, NativeBackupFileName + ".partial"))
+ };
+ 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 is 3 or 4 &&
+ 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)) &&
+ IsProtectionMetadataValid(manifest) &&
+ IsUpdateDispatchReceiptValid(manifest);
+
+ private static bool IsProtectionMetadataValid(GatewayRollbackPointManifest manifest)
+ {
+ if (manifest.SchemaVersion == 3)
+ {
+ return manifest.ProtectionMode == GatewayUpdateProtectionMode.FullVhd &&
+ manifest.NativeBackupSha256 is null &&
+ manifest.NativeBackupSizeBytes == 0 &&
+ (manifest.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified ||
+ (manifest.RestoreEligible &&
+ Sha256Regex().IsMatch(manifest.VhdSha256) &&
+ manifest.VhdSizeBytes > 0));
+ }
+
+ return manifest.ProtectionMode switch
+ {
+ GatewayUpdateProtectionMode.FullVhd =>
+ manifest.NativeBackupSha256 is null &&
+ manifest.NativeBackupSizeBytes == 0 &&
+ (manifest.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified ||
+ (manifest.RestoreEligible &&
+ Sha256Regex().IsMatch(manifest.VhdSha256) &&
+ manifest.VhdSizeBytes > 0)),
+ GatewayUpdateProtectionMode.NativeBackup =>
+ !manifest.RestoreEligible &&
+ string.IsNullOrEmpty(manifest.VhdSha256) &&
+ manifest.VhdSizeBytes == 0 &&
+ (manifest.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified ||
+ (manifest.NativeBackupSha256 is not null &&
+ Sha256Regex().IsMatch(manifest.NativeBackupSha256) &&
+ manifest.NativeBackupSizeBytes > 0)),
+ _ => false
+ };
+ }
+
+ private static bool IsUpdateDispatchReceiptValid(GatewayRollbackPointManifest manifest)
+ {
+ var hasAnyDispatchMetadata =
+ manifest.UpdateTargetSource is not null ||
+ manifest.UpdateTargetPackageUri is not null ||
+ manifest.UpdateTargetSha256 is not null ||
+ manifest.UpdateRoute is not null ||
+ manifest.UpdateDispatchState is not null ||
+ manifest.UpdateRequestId is not null ||
+ manifest.UpdateTransactionId is not null ||
+ manifest.UpdateConfirmationDeadlineUtc is not null ||
+ manifest.UpdateCompletionState is not null ||
+ manifest.UpdateCompletionRequestId is not null ||
+ manifest.UpdateCompletionOutcome is not null ||
+ manifest.UpdateCompletionObservedVersion is not null;
+ if (!hasAnyDispatchMetadata)
+ return true;
+ if (!IsUpdateReceiptPhase(manifest.Phase))
+ {
+ return false;
+ }
+ if (manifest.UpdateTargetSource is not { } source ||
+ manifest.UpdateRoute is not { } route ||
+ manifest.UpdateDispatchState is not { } state ||
+ !GatewayPackageTarget.TryRestore(
+ source,
+ manifest.TargetOpenClawVersion,
+ manifest.UpdateTargetPackageUri,
+ manifest.UpdateTargetSha256,
+ out _))
+ {
+ return false;
+ }
+
+ if (route == GatewayPackageUpdateRoute.CompanionInstaller)
+ {
+ return manifest.UpdateRequestId is null &&
+ manifest.UpdateTransactionId is null &&
+ manifest.UpdateConfirmationDeadlineUtc is null &&
+ manifest.UpdateCompletionState is null &&
+ manifest.UpdateCompletionRequestId is null &&
+ manifest.UpdateCompletionOutcome is null &&
+ manifest.UpdateCompletionObservedVersion is null;
+ }
+
+ if (string.IsNullOrWhiteSpace(manifest.UpdateRequestId))
+ return false;
+ if (state != GatewayUpdateDispatchState.Accepted)
+ {
+ return manifest.UpdateCompletionState is null &&
+ manifest.UpdateCompletionRequestId is null &&
+ manifest.UpdateCompletionOutcome is null &&
+ manifest.UpdateCompletionObservedVersion is null;
+ }
+
+ if (string.IsNullOrWhiteSpace(manifest.UpdateTransactionId) ||
+ manifest.UpdateConfirmationDeadlineUtc is null)
+ {
+ return false;
+ }
+
+ var hasAnyCompletionMetadata =
+ manifest.UpdateCompletionState is not null ||
+ manifest.UpdateCompletionRequestId is not null ||
+ manifest.UpdateCompletionOutcome is not null ||
+ manifest.UpdateCompletionObservedVersion is not null;
+ if (!hasAnyCompletionMetadata)
+ return true;
+
+ return manifest.UpdateCompletionState is not null &&
+ !string.IsNullOrWhiteSpace(manifest.UpdateCompletionRequestId) &&
+ manifest.UpdateCompletionOutcome is "healthy" or "failed";
+ }
+
+ private static bool IsUpdateReceiptPhase(GatewayRollbackPointPhase phase) =>
+ phase is GatewayRollbackPointPhase.UpdateInProgress
+ or GatewayRollbackPointPhase.PostUpdateHealthy
+ or GatewayRollbackPointPhase.RecoveryResolved
+ or GatewayRollbackPointPhase.RestoreStaged
+ or GatewayRollbackPointPhase.UnregisterPending
+ or GatewayRollbackPointPhase.DistroUnregistered
+ or GatewayRollbackPointPhase.ImportPending
+ or GatewayRollbackPointPhase.Imported
+ or GatewayRollbackPointPhase.RestoreCancelled
+ or GatewayRollbackPointPhase.RestoreHealthy;
+
+ 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 GetNativeBackupPath(string pointId) => Path.Combine(GetPointDirectory(pointId), NativeBackupFileName);
+ 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.ProtectionMode,
+ manifest.ProtectionMode == GatewayUpdateProtectionMode.NativeBackup
+ ? manifest.NativeBackupSizeBytes
+ : 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 bool HasSafeStagingBoundary(string stagePath) =>
+ HasSafePathBoundary(_localDataRoot, _stagingRoot) &&
+ HasSafePathBoundary(_stagingRoot, stagePath);
+
+ 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..53bf416b4
--- /dev/null
+++ b/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs
@@ -0,0 +1,1800 @@
+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,
+ RecoveryResolved,
+ 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
+/// creates a verified native backup, durably arms one route, and dispatches
+/// either the shared Companion installer or an explicitly audited Core
+/// transaction. Full VHD protection is opt-in. WSL unregister/import is isolated
+/// to RestoreAsync and requires an explicit rollback-point confirmation.
+///
+public sealed partial class GatewayVersionAlignmentCoordinator
+{
+ private const string NativeBackupMinimumSourceVersion = "2026.7.2";
+ private static readonly TimeSpan InstallerDispatchSettleDelay = TimeSpan.FromMinutes(2);
+ private readonly IWslCommandRunner _commandRunner;
+ private readonly GatewayRollbackPointManager _rollbackPoints;
+ private readonly GatewayPackageTarget _target;
+ private readonly GatewayPackageUpdateRoutePolicy _routePolicy;
+ private readonly string _requiredVersion;
+ private readonly Func _synchronizeAsync;
+ private readonly Func _retentionPolicy;
+ private readonly Func>? _gatewayRequestAsync;
+ private readonly Func _connectedGatewayId;
+ private readonly Func _utcNow;
+ private readonly Func _protectionModeResolver;
+ private readonly SemaphoreSlim _operationGate = new(1, 1);
+
+ public GatewayVersionAlignmentCoordinator(
+ IWslCommandRunner commandRunner,
+ string requiredVersion,
+ GatewayRollbackPointManager rollbackPoints,
+ Func? synchronizeAsync = null,
+ Func? retentionPolicy = null,
+ Func>? gatewayRequestAsync = null,
+ Func? connectedGatewayId = null,
+ Func? utcNow = null,
+ Func? protectionModeResolver = null)
+ : this(
+ commandRunner,
+ GatewayPackageTarget.Official(requiredVersion),
+ rollbackPoints,
+ synchronizeAsync,
+ retentionPolicy,
+ gatewayRequestAsync,
+ connectedGatewayId,
+ utcNow: utcNow,
+ protectionModeResolver: protectionModeResolver)
+ {
+ }
+
+ public GatewayVersionAlignmentCoordinator(
+ IWslCommandRunner commandRunner,
+ GatewayPackageTarget target,
+ GatewayRollbackPointManager rollbackPoints,
+ Func? synchronizeAsync = null,
+ Func? retentionPolicy = null,
+ Func>? gatewayRequestAsync = null,
+ Func? connectedGatewayId = null,
+ GatewayPackageUpdateRoutePolicy? routePolicy = null,
+ Func? utcNow = null,
+ Func? protectionModeResolver = null)
+ {
+ ArgumentNullException.ThrowIfNull(commandRunner);
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(rollbackPoints);
+
+ _commandRunner = commandRunner;
+ _target = target;
+ _routePolicy = routePolicy ?? new GatewayPackageUpdateRoutePolicy();
+ _rollbackPoints = rollbackPoints;
+ _requiredVersion = target.ExpectedVersion;
+ _synchronizeAsync = synchronizeAsync ?? ((_, _) => Task.CompletedTask);
+ _retentionPolicy = retentionPolicy ?? (() => GatewayRollbackRetentionPolicy.Default);
+ _gatewayRequestAsync = gatewayRequestAsync;
+ _connectedGatewayId = connectedGatewayId ?? (() => null);
+ _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
+ _protectionModeResolver = protectionModeResolver ?? (() => GatewayUpdateProtectionMode.NativeBackup);
+ }
+
+ public string RequiredVersion => _requiredVersion;
+
+ public IReadOnlyList ListRollbackPoints() => _rollbackPoints.List();
+
+ public bool HasUnreadableRollbackReceipt() => _rollbackPoints.HasUnreadableReceipt();
+
+ public bool HasVerifiedPendingUpdate() =>
+ _rollbackPoints.FindPendingUpdates().Any(IsVerifiedProtectionPoint);
+
+ public GatewayUpdateProtectionMode ResolveProtectionMode(string sourceVersion) =>
+ ResolveEffectiveProtectionMode(_protectionModeResolver(), sourceVersion);
+
+ 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}.");
+ }
+ var pendingRoute = default(GatewayPackageUpdateRoute);
+ var pendingState = default(GatewayUpdateDispatchState);
+ CoreUpdateTransaction? pendingTransaction = null;
+ if (pending is not null &&
+ !TryGetPendingDispatch(
+ pending,
+ requireCurrentTarget: true,
+ out pendingRoute,
+ out pendingState,
+ out pendingTransaction))
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ before.InstalledVersion,
+ pending.OpenClawVersion,
+ pending.Id,
+ before.ExitCode,
+ "The pending update predates durable package-target and dispatch provenance. A second dispatch or cross-lane fallback was blocked; explicit recovery is required.");
+ }
+ if (pending is not null &&
+ pendingRoute != _routePolicy.Select(pending.OpenClawVersion, _target))
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ before.InstalledVersion,
+ pending.OpenClawVersion,
+ pending.Id,
+ before.ExitCode,
+ "The pending update route no longer matches the immutable package target policy. Cross-lane fallback was blocked; explicit recovery is required.");
+ }
+ 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.");
+ }
+ if (pendingRoute == GatewayPackageUpdateRoute.CoreTransaction &&
+ (pendingState != GatewayUpdateDispatchState.Accepted || pendingTransaction is null))
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ before.InstalledVersion,
+ pending.OpenClawVersion,
+ pending.Id,
+ before.ExitCode,
+ "The aligned Core update receipt has no accepted transaction provenance. A second update.run was blocked because the earlier response may have been lost; explicit recovery is required.");
+ }
+ return await FinalizePostUpdateAsync(
+ distroName, gatewayId, before.InstalledVersion!, pending.OpenClawVersion, pending.Id,
+ pending.NodeCommandAllowSnapshotJson, pendingTransaction,
+ before.ExitCode, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ if ((before.State is not GatewayVersionAlignmentState.Mismatch
+ and not GatewayVersionAlignmentState.NewerThanRequired) ||
+ before.InstalledVersion is null)
+ return before;
+
+ var previousVersion = pending?.OpenClawVersion ?? before.InstalledVersion;
+ var route = _routePolicy.Select(before.InstalledVersion, _target);
+
+ 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 pre-update protection 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;
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ before.InstalledVersion,
+ previousVersion,
+ pending.Id,
+ before.ExitCode,
+ "The pending update receipt proves update dispatch was armed, but the installed version is not aligned. A second non-idempotent update.run was blocked; explicit recovery is required.");
+ }
+ else
+ {
+ try
+ {
+ if (route == GatewayPackageUpdateRoute.CoreTransaction)
+ 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
+ {
+ var protectionMode = ResolveEffectiveProtectionMode(
+ _protectionModeResolver(),
+ previousVersion);
+ if (!Enum.IsDefined(protectionMode))
+ {
+ return Result(
+ GatewayVersionAlignmentState.RollbackPointFailed,
+ previousVersion,
+ previousVersion,
+ failureSummary: "The Gateway update protection policy returned an unsupported mode.");
+ }
+ rollback = await _rollbackPoints.CreateVerifiedAsync(
+ distroName,
+ gatewayId,
+ previousVersion,
+ _requiredVersion,
+ protectionMode,
+ 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,
+ "Verified pre-update protection 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 (!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 requestId = route == GatewayPackageUpdateRoute.CoreTransaction
+ ? $"windows-companion-gateway-update-{pointId}"
+ : null;
+ _rollbackPoints.ArmUpdateDispatch(pointId, _target, route, requestId);
+ 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 after update dispatch was durably armed. The receipt was preserved and no package mutation was invoked.");
+ }
+
+ CoreUpdateTransaction? transaction = null;
+ if (route == GatewayPackageUpdateRoute.CoreTransaction)
+ {
+ var transactionStart = await BeginCoreUpdateTransactionAsync(
+ gatewayId, requestId!, cancellationToken).ConfigureAwait(false);
+ if (transactionStart.Transaction is null)
+ {
+ _rollbackPoints.MarkUpdateDispatchAmbiguous(pointId);
+ var current = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false);
+ await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ current.Version,
+ previousVersion,
+ pointId,
+ current.ExitCode,
+ transactionStart.Failure);
+ }
+ transaction = transactionStart.Transaction;
+ _rollbackPoints.RecordCoreUpdateAccepted(
+ pointId,
+ transaction.TransactionId,
+ transaction.ConfirmDeadline);
+ }
+ else
+ {
+ var install = await _commandRunner.RunInDistroAsync(
+ distroName,
+ GatewayVersionAlignmentCommandBuilder.BuildVerifiedInstaller(_target, pointId),
+ cancellationToken).ConfigureAwait(false);
+ if (install.TimedOut || install.ExitCode == -1)
+ _rollbackPoints.MarkUpdateDispatchAmbiguous(pointId);
+ else
+ _rollbackPoints.MarkInstallerDispatchAccepted(pointId);
+ if (!install.Success)
+ {
+ var current = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false);
+ await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ current.Version,
+ previousVersion,
+ pointId,
+ install.ExitCode,
+ install.TimedOut
+ ? "The Companion installer exceeded the Windows dispatch timeout. Its state is ambiguous, and the armed receipt blocks a second install until explicit recovery."
+ : $"The Companion installer failed with exit code {install.ExitCode}. The armed receipt and protection evidence block a second install until 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);
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ gatewayId,
+ distroName,
+ pointId,
+ transaction,
+ "failed",
+ after.Version,
+ cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ after.Version,
+ previousVersion,
+ pointId,
+ after.ExitCode,
+ AppendCompletionFailure(
+ "The installed version could not be verified exactly after update. The transaction receipt and verified protection evidence were preserved for manual recovery; no automatic native-backup restore was attempted.",
+ completionFailure));
+ }
+
+ return await FinalizePostUpdateAsync(
+ distroName, gatewayId, after.Version!, previousVersion, pointId,
+ rollbackPoint.NodeCommandAllowSnapshotJson, transaction,
+ 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,
+ CoreUpdateTransaction? transaction,
+ int? exitCode,
+ CancellationToken cancellationToken,
+ bool resolveHistoricalReceipt = false,
+ string? transactionGatewayId = null)
+ {
+ var completionGatewayId = transactionGatewayId ?? gatewayId;
+ var policyMigration = await ApplyNodeCommandPolicyMigrationAsync(
+ distroName,
+ previousVersion,
+ installedVersion,
+ nodeCommandAllowSnapshotJson,
+ cancellationToken).ConfigureAwait(false);
+ if (policyMigration is not null)
+ {
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ completionGatewayId, distroName, pointId, transaction, "failed", installedVersion, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode,
+ AppendCompletionFailure(policyMigration, completionFailure));
+ }
+
+ try
+ {
+ await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ completionGatewayId, distroName, pointId, transaction, "failed", installedVersion, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode,
+ AppendCompletionFailure(
+ $"Post-update synchronization failed: {ex.GetType().Name}. The transaction receipt and verified protection evidence were preserved for manual recovery.",
+ completionFailure));
+ }
+
+ if (!await _rollbackPoints.VerifyAsync(pointId, cancellationToken).ConfigureAwait(false))
+ {
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ completionGatewayId, distroName, pointId, transaction, "failed", installedVersion, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode,
+ AppendCompletionFailure(
+ "Post-update health passed, but the pre-update protection no longer verifies. Retention cleanup was not run.",
+ completionFailure));
+ }
+
+ if (!await _rollbackPoints.AttestLiveDistroAsync(
+ pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false))
+ {
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ completionGatewayId, distroName, pointId, transaction, "failed", installedVersion, cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode,
+ AppendCompletionFailure(
+ "Post-update health passed, but the live distro no longer matches the exact expected version and rollback receipt. Finalization and cleanup were blocked.",
+ completionFailure));
+ }
+
+ var healthyCompletionFailure = await TryCompleteCoreUpdateAsync(
+ completionGatewayId, distroName, pointId, transaction, "healthy", installedVersion, cancellationToken).ConfigureAwait(false);
+ if (healthyCompletionFailure is not null)
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode,
+ $"Core did not accept healthy completion for update transaction {transaction!.TransactionId}. The UpdateInProgress receipt and verified protection evidence were preserved: {healthyCompletionFailure}");
+ }
+
+ if (!await _rollbackPoints.AttestLiveDistroAsync(
+ pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false))
+ {
+ 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.");
+ }
+ if (resolveHistoricalReceipt)
+ _rollbackPoints.MarkNativeRecoveryResolved(pointId);
+ else
+ _rollbackPoints.MarkPostUpdateHealthy(pointId);
+ await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false);
+ return Result(
+ resolveHistoricalReceipt
+ ? GatewayVersionAlignmentState.RecoveryResolved
+ : GatewayVersionAlignmentState.Updated,
+ installedVersion,
+ previousVersion,
+ pointId,
+ exitCode);
+ }
+
+ private async Task BeginCoreUpdateTransactionAsync(
+ string expectedGatewayId,
+ string requestId,
+ CancellationToken cancellationToken)
+ {
+ if (_gatewayRequestAsync is null)
+ {
+ return new(
+ null,
+ "The connected Gateway client does not expose the transactional update RPC. The UpdateInProgress receipt and verified protection evidence were preserved.");
+ }
+
+ var identityFailure = GetGatewayIdentityFailure(expectedGatewayId);
+ if (identityFailure is not null)
+ return new(null, identityFailure);
+
+ JsonElement payload;
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ payload = await _gatewayRequestAsync(
+ expectedGatewayId,
+ requestId,
+ "update.run",
+ new
+ {
+ target = new { package = "openclaw", version = _requiredVersion },
+ confirmationTier = "external"
+ },
+ (int)TimeSpan.FromMinutes(35).TotalMilliseconds,
+ cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return new(
+ null,
+ $"Transactional update.run failed: {ex.GetType().Name}. The UpdateInProgress receipt and verified protection evidence were preserved.");
+ }
+
+ if (!TryParseCoreUpdateTransaction(payload, requestId, _utcNow(), out var transaction))
+ {
+ return new(
+ null,
+ "Transactional update.run did not return a trusted transactionId and confirmDeadline. The UpdateInProgress receipt and verified protection evidence were preserved.");
+ }
+ return new(transaction, null);
+ }
+
+ private async Task TryCompleteCoreUpdateAsync(
+ string expectedGatewayId,
+ string distroName,
+ string pointId,
+ CoreUpdateTransaction? transaction,
+ string outcome,
+ string? observedVersion,
+ CancellationToken cancellationToken)
+ {
+ if (transaction is null)
+ return null;
+ if (_gatewayRequestAsync is null)
+ return "the connected Gateway client no longer exposes update.complete";
+
+ var pending = _rollbackPoints.FindPendingUpdate(expectedGatewayId, _requiredVersion);
+ if (pending is null || !string.Equals(pending.Id, pointId, StringComparison.Ordinal))
+ return "the durable update receipt is missing or no longer identifies this transaction";
+ if (pending.UpdateCompletionState is { } priorCompletion)
+ {
+ return priorCompletion == GatewayUpdateCompletionState.Accepted &&
+ string.Equals(pending.UpdateCompletionOutcome, outcome, StringComparison.Ordinal)
+ ? null
+ : $"update.complete was already attempted with outcome '{pending.UpdateCompletionOutcome ?? ""}' and state {priorCompletion}; automatic redispatch is blocked";
+ }
+ if (_utcNow() >= transaction.ConfirmDeadline)
+ return $"the update confirmation deadline {transaction.ConfirmDeadline:O} has expired; update.complete was not dispatched";
+
+ var identityFailure = GetGatewayIdentityFailure(expectedGatewayId);
+ if (identityFailure is not null)
+ return identityFailure;
+
+ var completionRequestId = $"windows-companion-gateway-complete-{pointId}";
+ _rollbackPoints.ArmCoreUpdateCompletion(
+ pointId,
+ completionRequestId,
+ outcome,
+ observedVersion);
+ if (string.Equals(outcome, "healthy", StringComparison.Ordinal) &&
+ (observedVersion is null ||
+ !await _rollbackPoints.AttestLiveDistroAsync(
+ pointId, distroName, observedVersion, cancellationToken).ConfigureAwait(false)))
+ {
+ return "the live Companion-owned distro changed after healthy completion was durably armed; update.complete was not dispatched";
+ }
+
+ var remaining = transaction.ConfirmDeadline - _utcNow();
+ if (remaining <= TimeSpan.Zero)
+ return $"the update confirmation deadline {transaction.ConfirmDeadline:O} expired before update.complete dispatch";
+
+ var timeoutMs = (int)Math.Max(
+ 1,
+ Math.Min(
+ TimeSpan.FromMinutes(2).TotalMilliseconds,
+ Math.Ceiling(remaining.TotalMilliseconds)));
+ using var deadlineCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ deadlineCancellation.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
+
+ try
+ {
+ object parameters = observedVersion is null
+ ? new
+ {
+ transactionId = transaction.TransactionId,
+ requestId = transaction.RequestId,
+ outcome
+ }
+ : new
+ {
+ transactionId = transaction.TransactionId,
+ requestId = transaction.RequestId,
+ outcome,
+ observedVersion
+ };
+ await _gatewayRequestAsync(
+ expectedGatewayId,
+ completionRequestId,
+ "update.complete",
+ parameters,
+ timeoutMs,
+ deadlineCancellation.Token).ConfigureAwait(false);
+ _rollbackPoints.MarkCoreUpdateCompletionAccepted(pointId);
+ return null;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ _rollbackPoints.MarkCoreUpdateCompletionAmbiguous(pointId);
+ return $"update.complete did not finish before confirmation deadline {transaction.ConfirmDeadline:O}";
+ }
+ catch (OperationCanceledException)
+ {
+ _rollbackPoints.MarkCoreUpdateCompletionAmbiguous(pointId);
+ throw;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _rollbackPoints.MarkCoreUpdateCompletionAmbiguous(pointId);
+ return $"update.complete failed with {ex.GetType().Name}";
+ }
+ }
+
+ private string? GetGatewayIdentityFailure(string expectedGatewayId)
+ {
+ var connectedGatewayId = _connectedGatewayId()?.Trim();
+ return string.Equals(connectedGatewayId, expectedGatewayId, StringComparison.Ordinal)
+ ? null
+ : $"The active Gateway identity changed before privileged update RPC dispatch. Expected '{expectedGatewayId}', but the live connection reports '{connectedGatewayId ?? ""}'. The UpdateInProgress receipt and verified protection evidence were preserved.";
+ }
+
+ private bool TryGetPendingDispatch(
+ GatewayRollbackPointManifest pending,
+ bool requireCurrentTarget,
+ out GatewayPackageUpdateRoute route,
+ out GatewayUpdateDispatchState state,
+ out CoreUpdateTransaction? transaction)
+ {
+ route = default;
+ state = default;
+ transaction = null;
+ if (pending.UpdateTargetSource is not { } source ||
+ pending.UpdateRoute is not { } persistedRoute ||
+ pending.UpdateDispatchState is not { } persistedState ||
+ !GatewayPackageTarget.TryRestore(
+ source,
+ pending.TargetOpenClawVersion,
+ pending.UpdateTargetPackageUri,
+ pending.UpdateTargetSha256,
+ out var persistedTarget) ||
+ (requireCurrentTarget && persistedTarget != _target))
+ {
+ return false;
+ }
+
+ route = persistedRoute;
+ state = persistedState;
+ if (route == GatewayPackageUpdateRoute.CoreTransaction &&
+ state == GatewayUpdateDispatchState.Accepted)
+ {
+ if (string.IsNullOrWhiteSpace(pending.UpdateRequestId) ||
+ string.IsNullOrWhiteSpace(pending.UpdateTransactionId) ||
+ pending.UpdateConfirmationDeadlineUtc is not { } deadline)
+ {
+ return false;
+ }
+ transaction = new(
+ pending.UpdateTransactionId,
+ deadline,
+ pending.UpdateRequestId);
+ }
+
+ return true;
+ }
+
+ private static bool TryParseCoreUpdateTransaction(
+ JsonElement payload,
+ string requestId,
+ DateTimeOffset now,
+ out CoreUpdateTransaction transaction)
+ {
+ transaction = default!;
+ if (payload.ValueKind != JsonValueKind.Object ||
+ !payload.TryGetProperty("transactionId", out var transactionIdValue) ||
+ transactionIdValue.ValueKind != JsonValueKind.String ||
+ !payload.TryGetProperty("confirmDeadline", out var confirmDeadlineValue) ||
+ confirmDeadlineValue.ValueKind != JsonValueKind.String)
+ {
+ return false;
+ }
+
+ var transactionId = transactionIdValue.GetString()?.Trim();
+ var confirmDeadlineText = confirmDeadlineValue.GetString()?.Trim();
+ if (string.IsNullOrWhiteSpace(transactionId) ||
+ !DateTimeOffset.TryParse(
+ confirmDeadlineText,
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.RoundtripKind,
+ out var confirmDeadline) ||
+ confirmDeadline <= now)
+ {
+ return false;
+ }
+
+ transaction = new(transactionId, confirmDeadline, requestId);
+ return true;
+ }
+
+ private static string AppendCompletionFailure(string failure, string? completionFailure) =>
+ completionFailure is null
+ ? failure
+ : $"{failure} Core failure completion was not acknowledged: {completionFailure}.";
+
+ 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 (!IsVerifiedProtectionPoint(pending))
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ previousVersion: pending.OpenClawVersion,
+ rollbackPointId: pending.Id,
+ failureSummary: "The mandatory pending Gateway update receipt no longer has verified pre-update protection. 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. Resolve that exact receipt before ordinary alignment; VHD restore is available only when the point is marked restore-eligible.");
+ }
+ 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;
+ }
+
+ public async Task CleanupRollbackPointsAsync(CancellationToken cancellationToken = default)
+ {
+ await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_rollbackPoints.HasUnreadableReceipt())
+ {
+ throw new InvalidOperationException(
+ "Rollback cleanup is blocked because a receipt directory cannot be read or validated.");
+ }
+
+ return await _rollbackPoints.CleanupAsync(
+ _retentionPolicy(),
+ cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationGate.Release();
+ }
+ }
+
+ public async Task ResolveNativeRecoveryAsync(
+ GatewayHostAccessPlan accessPlan,
+ string rollbackPointId,
+ string confirmedRollbackPointId,
+ CancellationToken cancellationToken = default)
+ {
+ 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 restoreGate = GetUnresolvedRestoreGate();
+ if (restoreGate is not null)
+ return restoreGate;
+
+ var pendingUpdates = _rollbackPoints.FindPendingUpdates();
+ if (pendingUpdates.Count != 1)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ failureSummary: "Native update recovery is ambiguous because the Companion does not have exactly one pending receipt; no receipt was changed.");
+ }
+ var pending = pendingUpdates[0];
+ if (!string.Equals(pending.Id, rollbackPointId, StringComparison.Ordinal) ||
+ pending.ProtectionMode != GatewayUpdateProtectionMode.NativeBackup ||
+ pending.Phase != GatewayRollbackPointPhase.UpdateInProgress)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RestoreFailed,
+ rollbackPointId: rollbackPointId,
+ failureSummary: "The selected native update receipt is not eligible for resolution.");
+ }
+ if (!await _rollbackPoints.VerifyAsync(rollbackPointId, cancellationToken).ConfigureAwait(false))
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ rollbackPointId: rollbackPointId,
+ failureSummary: "The native backup no longer verifies, so the recovery receipt was preserved.");
+ }
+
+ if (!TryGetPendingDispatch(
+ pending,
+ requireCurrentTarget: false,
+ out var recoveryRoute,
+ out var recoveryState,
+ out var recoveryTransaction))
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ failureSummary: "The durable dispatch provenance is incomplete; the receipt was preserved.");
+ }
+
+ var cancelUnacceptedInstallerDispatch = false;
+ if (recoveryState is GatewayUpdateDispatchState.Prepared or GatewayUpdateDispatchState.Ambiguous)
+ {
+ if (recoveryRoute != GatewayPackageUpdateRoute.CompanionInstaller)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ failureSummary: $"The {recoveryRoute} dispatch remains {recoveryState}; the receipt was preserved.");
+ }
+
+ var dispatchAge = _utcNow() - pending.UpdatedAtUtc;
+ if (dispatchAge < InstallerDispatchSettleDelay)
+ {
+ var retryAt = pending.UpdatedAtUtc + InstallerDispatchSettleDelay;
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ failureSummary: $"The Companion installer dispatch remains {recoveryState}. It cannot be adjudicated before {retryAt:O}; the receipt was preserved.");
+ }
+
+ var beforeTermination = await ProbeInstalledVersionAsync(
+ distroName,
+ cancellationToken).ConfigureAwait(false);
+ if (beforeTermination.Failure is not null ||
+ beforeTermination.Version is null ||
+ (!string.Equals(beforeTermination.Version, pending.OpenClawVersion, StringComparison.Ordinal) &&
+ !string.Equals(beforeTermination.Version, pending.TargetOpenClawVersion, StringComparison.Ordinal)) ||
+ !await _rollbackPoints.AttestLiveDistroAsync(
+ rollbackPointId,
+ distroName,
+ beforeTermination.Version,
+ cancellationToken).ConfigureAwait(false))
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ beforeTermination.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ beforeTermination.ExitCode,
+ "The Companion-owned distro could not be re-attested immediately before interruption; the receipt was preserved.");
+ }
+
+ var terminate = await _commandRunner.TerminateDistroAsync(
+ distroName,
+ cancellationToken).ConfigureAwait(false);
+ if (!terminate.Success)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ exitCode: terminate.ExitCode,
+ failureSummary: "The Companion-owned distro could not be stopped to exclude a still-running installer; the receipt was preserved.");
+ }
+ cancelUnacceptedInstallerDispatch = true;
+ }
+ else if (recoveryState != GatewayUpdateDispatchState.Accepted)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ rollbackPointId: rollbackPointId,
+ failureSummary: $"The {recoveryRoute} dispatch is {recoveryState} and cannot be recovered from an UpdateInProgress receipt.");
+ }
+
+ var installed = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false);
+ if (installed.Failure is not null || installed.Version is null)
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ installed.Failure ?? "The installed OpenClaw version could not be verified.");
+ }
+ if (string.Equals(installed.Version, pending.TargetOpenClawVersion, StringComparison.Ordinal))
+ {
+ var resolvesHistoricalTarget =
+ !string.Equals(pending.TargetOpenClawVersion, _requiredVersion, StringComparison.Ordinal);
+ if (recoveryRoute == GatewayPackageUpdateRoute.CoreTransaction &&
+ recoveryTransaction is null)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ "The target version is installed, but its dispatch provenance is incomplete; the receipt was preserved.");
+ }
+ if (cancelUnacceptedInstallerDispatch)
+ {
+ _rollbackPoints.MarkInstallerDispatchAccepted(rollbackPointId);
+ cancelUnacceptedInstallerDispatch = false;
+ }
+ return await FinalizePostUpdateAsync(
+ distroName,
+ accessPlan.GatewayId!,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ pending.NodeCommandAllowSnapshotJson,
+ recoveryTransaction,
+ installed.ExitCode,
+ cancellationToken,
+ resolvesHistoricalTarget,
+ recoveryTransaction is null ? null : pending.GatewayId).ConfigureAwait(false);
+ }
+ if (!string.Equals(installed.Version, pending.OpenClawVersion, StringComparison.Ordinal) ||
+ !await _rollbackPoints.AttestLiveDistroAsync(
+ rollbackPointId,
+ distroName,
+ pending.OpenClawVersion,
+ cancellationToken).ConfigureAwait(false))
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ "The live distro matches neither the exact pre-update state nor the exact target state; the receipt was preserved.");
+ }
+ if (cancelUnacceptedInstallerDispatch)
+ {
+ var notStarted = await _commandRunner.RunInDistroAsync(
+ distroName,
+ GatewayVersionAlignmentCommandBuilder.BuildInstallerNotStartedProbe(rollbackPointId),
+ cancellationToken).ConfigureAwait(false);
+ if (!notStarted.Success)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ notStarted.ExitCode,
+ notStarted.ExitCode == 1
+ ? "The Companion installer started before interruption, so the original package state cannot be proven intact; the receipt was preserved."
+ : "The Companion installer start marker could not be verified; the receipt was preserved.");
+ }
+ }
+
+ try
+ {
+ await _synchronizeAsync(accessPlan.GatewayId!, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return Result(
+ GatewayVersionAlignmentState.SynchronizationFailed,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ $"The pre-update version is restored, but Gateway, Companion, Node, or pairing health failed: {ex.GetType().Name}.");
+ }
+
+ if (recoveryRoute == GatewayPackageUpdateRoute.CoreTransaction)
+ {
+ if (recoveryTransaction is null)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ "The pre-update version is healthy, but the Core transaction lacks accepted provenance; the receipt was preserved.");
+ }
+
+ var completionFailure = await TryCompleteCoreUpdateAsync(
+ pending.GatewayId,
+ distroName,
+ rollbackPointId,
+ recoveryTransaction,
+ "failed",
+ installed.Version,
+ cancellationToken).ConfigureAwait(false);
+ if (completionFailure is not null)
+ {
+ return Result(
+ GatewayVersionAlignmentState.RecoveryAvailable,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ $"The pre-update version is healthy, but Core did not accept failed completion; the receipt was preserved: {completionFailure}");
+ }
+ }
+
+ if (!await _rollbackPoints.VerifyAsync(rollbackPointId, cancellationToken).ConfigureAwait(false) ||
+ !await _rollbackPoints.AttestLiveDistroAsync(
+ rollbackPointId,
+ distroName,
+ pending.OpenClawVersion,
+ cancellationToken).ConfigureAwait(false))
+ {
+ return Result(
+ GatewayVersionAlignmentState.VerificationFailed,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode,
+ "The backup or live distro changed after synchronization; the recovery receipt was preserved.");
+ }
+
+ _rollbackPoints.MarkNativeRecoveryResolved(
+ rollbackPointId,
+ cancelUnacceptedInstallerDispatch);
+ await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false);
+ return Result(
+ GatewayVersionAlignmentState.RecoveryResolved,
+ installed.Version,
+ pending.OpenClawVersion,
+ rollbackPointId,
+ installed.ExitCode);
+ }
+ finally
+ {
+ _operationGate.Release();
+ }
+ }
+
+ private static bool IsVerifiedProtectionPoint(GatewayRollbackPointManifest point) =>
+ point.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified &&
+ point.ProtectionMode switch
+ {
+ GatewayUpdateProtectionMode.NativeBackup =>
+ !point.RestoreEligible &&
+ point.NativeBackupSizeBytes > 0 &&
+ !string.IsNullOrWhiteSpace(point.NativeBackupSha256),
+ GatewayUpdateProtectionMode.FullVhd =>
+ point.RestoreEligible &&
+ point.VhdSizeBytes > 0 &&
+ !string.IsNullOrWhiteSpace(point.VhdSha256),
+ _ => false
+ };
+
+ private static GatewayUpdateProtectionMode ResolveEffectiveProtectionMode(
+ GatewayUpdateProtectionMode requestedMode,
+ string sourceVersion)
+ {
+ if (requestedMode != GatewayUpdateProtectionMode.NativeBackup)
+ return requestedMode;
+
+ var comparison = CompareSemanticVersions(sourceVersion, NativeBackupMinimumSourceVersion);
+ return comparison is >= 0
+ ? GatewayUpdateProtectionMode.NativeBackup
+ : GatewayUpdateProtectionMode.FullVhd;
+ }
+
+ 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 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 CoreUpdateTransaction(
+ string TransactionId,
+ DateTimeOffset ConfirmDeadline,
+ string RequestId);
+ private sealed record CoreUpdateTransactionStart(
+ CoreUpdateTransaction? Transaction,
+ 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|[1-9]\d*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))" +
+ @"(?:\.(?:(?:0|[1-9]\d*)|(?:[0-9A-Za-z-]*[A-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
+{
+ private const string InstallerReceiptRoot =
+ "$HOME/.local/state/openclaw-windows-companion/update-receipts";
+
+ public static IReadOnlyList BuildProbe() =>
+ ["bash", "-lc", $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw --version"];
+
+ public static IReadOnlyList BuildVerifiedInstaller(
+ GatewayPackageTarget target,
+ string rollbackPointId)
+ {
+ var markerPath = GetInstallerMarkerPath(rollbackPointId);
+ return
+ [
+ "bash",
+ "-lc",
+ $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && " +
+ $"mkdir -p \"{InstallerReceiptRoot}\" && : > \"{markerPath}\" && " +
+ $"sync -f \"{markerPath}\" && " +
+ GatewayPackageInstallCommandBuilder.Build(
+ GatewayPackageInstallCommandBuilder.DefaultInstallUrl,
+ target)
+ ];
+ }
+
+ public static IReadOnlyList BuildInstallerNotStartedProbe(string rollbackPointId) =>
+ [
+ "bash",
+ "-lc",
+ $"test ! -e \"{GetInstallerMarkerPath(rollbackPointId)}\""
+ ];
+
+ 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)} " +
+ WslShellQuoting.QuotePosixSingleQuote(completeArrayJson));
+
+ private static IReadOnlyList BuildConfigCommand(string operation) =>
+ [
+ "bash",
+ "-lc",
+ $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw config {operation}"
+ ];
+
+ private static string GetInstallerMarkerPath(string rollbackPointId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(rollbackPointId);
+ if (rollbackPointId.Any(character => !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_'))
+ throw new ArgumentException("Rollback point id contains unsupported characters.", nameof(rollbackPointId));
+ return $"{InstallerReceiptRoot}/{rollbackPointId}.started";
+ }
+}
diff --git a/src/OpenClaw.Connection/WslCommandRunner.cs b/src/OpenClaw.Connection/WslCommandRunner.cs
index 8dfd3ef50..84367a52a 100644
--- a/src/OpenClaw.Connection/WslCommandRunner.cs
+++ b/src/OpenClaw.Connection/WslCommandRunner.cs
@@ -1,16 +1,28 @@
using OpenClaw.Shared;
+using Microsoft.Win32;
using System.Diagnostics;
using System.Globalization;
+using System.Runtime.InteropServices;
using System.Text;
namespace OpenClaw.Connection;
-public sealed record WslCommandResult(int ExitCode, string StandardOutput, string StandardError)
+public sealed record WslCommandResult(
+ int ExitCode,
+ string StandardOutput,
+ string StandardError,
+ bool TimedOut = false)
{
- public bool Success => ExitCode == 0;
+ public bool Success => ExitCode == 0 && !TimedOut;
}
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 +33,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);
@@ -29,6 +56,13 @@ Task RunInDistroAsync(
string name, IReadOnlyList command,
CancellationToken cancellationToken = default,
IReadOnlyDictionary? environment = null);
+
+ Task CopyFileFromDistroAsync(
+ string name,
+ string sourcePath,
+ string destinationPath,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new WslCommandResult(-1, string.Empty, "WSL file transfer is unavailable."));
}
///
@@ -37,6 +71,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 +89,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,
@@ -70,6 +196,92 @@ public Task RunInDistroAsync(
return RunAsync(args, cancellationToken, environment);
}
+ public async Task CopyFileFromDistroAsync(
+ string name,
+ string sourcePath,
+ string destinationPath,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "wsl.exe",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardErrorEncoding = Encoding.UTF8
+ };
+ foreach (var argument in new[] { "-d", name, "--", "cat", "--", sourcePath })
+ psi.ArgumentList.Add(argument);
+
+ _logger.Info($"[WSL] Copy file from distro {name} to a host-managed destination.");
+
+ using var process = new Process { StartInfo = psi };
+ try
+ {
+ process.Start();
+ }
+ catch (Exception ex)
+ {
+ return new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}");
+ }
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(_defaultTimeout);
+ var stderrTask = process.StandardError.ReadToEndAsync(timeoutCts.Token);
+ var timedOut = false;
+ try
+ {
+ await using var destination = new FileStream(
+ destinationPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 81920,
+ FileOptions.Asynchronous | FileOptions.WriteThrough);
+ var copyTask = process.StandardOutput.BaseStream.CopyToAsync(destination, timeoutCts.Token);
+ await Task.WhenAll(process.WaitForExitAsync(timeoutCts.Token), copyTask).ConfigureAwait(false);
+ await destination.FlushAsync(timeoutCts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ timedOut = true;
+ // slopwatch-ignore: SW003 Timeout cleanup preserves the source and removes only the partial host copy.
+ try { process.Kill(entireProcessTree: true); } catch { }
+ }
+ catch (OperationCanceledException)
+ {
+ // slopwatch-ignore: SW003 Cancellation cleanup preserves the source and removes only the partial host copy.
+ try { process.Kill(entireProcessTree: true); } catch { }
+ TryDeletePartial(destinationPath);
+ throw;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // slopwatch-ignore: SW003 Transfer failure cleanup preserves the source and removes only the partial host copy.
+ try { process.Kill(entireProcessTree: true); } catch { }
+ TryDeletePartial(destinationPath);
+ return new WslCommandResult(-1, string.Empty, $"WSL file transfer failed: {ex.Message}");
+ }
+
+ string stderr;
+ try { stderr = await stderrTask.ConfigureAwait(false); } catch { stderr = string.Empty; }
+ if (timedOut || process.ExitCode != 0)
+ TryDeletePartial(destinationPath);
+ return timedOut
+ ? new WslCommandResult(-1, string.Empty, "wsl.exe file transfer timed out", TimedOut: true)
+ : new WslCommandResult(process.ExitCode, string.Empty, stderr);
+ }
+
+ private static void TryDeletePartial(string path)
+ {
+ try { File.Delete(path); } catch { }
+ }
+
public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) =>
RunAsync(["--terminate", name], cancellationToken);
@@ -171,7 +383,22 @@ private async Task RunProcessAsync(
try { stderr = await stderrTask; } catch { stderr = string.Empty; }
return timedOut
- ? new WslCommandResult(-1, stdout, "wsl.exe timed out")
+ ? new WslCommandResult(-1, stdout, "wsl.exe timed out", TimedOut: true)
: 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.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
index c8011e325..a4612eac8 100644
--- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
+++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
@@ -154,7 +154,15 @@ public SetupWindow(
_config.GatewayPort = gatewayPortOverride.Value;
_config.GatewayUrl = null;
}
- GatewayLkgVersion.ApplyToConfig(_config);
+ try
+ {
+ GatewayLkgVersion.ApplyToConfig(_config);
+ }
+ catch (InvalidOperationException ex)
+ {
+ ShowConfigurationError(ex.Message);
+ return;
+ }
_config.ApplyUiDefaults(rollbackOnFailure: setupArguments.RollbackOnFailure);
if (startAtGatewayInstalledMilestone)
{
diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
index 738a47889..e3966b1e2 100644
--- a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
+++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs
@@ -2,13 +2,20 @@ namespace OpenClaw.SetupEngine;
public static class GatewayLkgVersion
{
- public const string DefaultInstallUrl = "https://openclaw.ai/install-cli.sh";
- public const string LkgVersion = "2026.6.11";
+ public const string DefaultInstallUrl = OpenClaw.Connection.GatewayPackageInstallCommandBuilder.DefaultInstallUrl;
+ public const string LkgVersion = "2026.7.1";
public static string ResolveLkgVersion() => LkgVersion;
public static void ApplyToConfig(SetupConfig config)
+ => ApplyToConfig(config, OpenClaw.Connection.GatewayPackageBuildTargetResolver.Resolve());
+
+ internal static void ApplyToConfig(
+ SetupConfig config,
+ OpenClaw.Connection.GatewayPackageTarget target)
{
+ ArgumentNullException.ThrowIfNull(config);
+ ArgumentNullException.ThrowIfNull(target);
if (!string.IsNullOrWhiteSpace(config.Gateway.Version))
return;
@@ -18,6 +25,107 @@ public static void ApplyToConfig(SetupConfig config)
return;
}
- config.Gateway.Version = LkgVersion;
+ var configuredSha256 = config.Gateway.ExpectedPackageSha256?.Trim();
+ if (!string.IsNullOrWhiteSpace(configuredSha256) &&
+ !string.Equals(configuredSha256, target.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ "Configured Gateway package digest does not match the resolved immutable package target.");
+ }
+
+ config.Gateway.Version = target.Source == OpenClaw.Connection.GatewayPackageSource.Official
+ ? target.ExpectedVersion
+ : target.PackageUri!.AbsoluteUri;
+ config.Gateway.ExpectedInstalledVersion = target.ExpectedVersion;
+ config.Gateway.ExpectedPackageSha256 = target.Sha256;
+ }
+
+ internal static string? ResolveExpectedInstalledVersion(SetupConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+
+ var configuredExpectedVersion = config.Gateway.ExpectedInstalledVersion?.Trim();
+ var configuredPackageSpec = config.Gateway.Version?.Trim();
+ var configuredSha256 = config.Gateway.ExpectedPackageSha256?.Trim();
+ if (!string.IsNullOrWhiteSpace(configuredExpectedVersion))
+ {
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(configuredSha256))
+ {
+ var composedTarget = OpenClaw.Connection.GatewayPackageTarget.Composed(
+ configuredExpectedVersion,
+ new Uri(configuredPackageSpec ?? string.Empty, UriKind.Absolute),
+ configuredSha256);
+ return composedTarget.ExpectedVersion;
+ }
+
+ var officialTarget = OpenClaw.Connection.GatewayPackageTarget.Official(
+ configuredExpectedVersion);
+ if (!string.Equals(
+ configuredPackageSpec,
+ officialTarget.ExpectedVersion,
+ StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "Official Gateway version does not match ExpectedInstalledVersion.");
+ }
+
+ return officialTarget.ExpectedVersion;
+ }
+ catch (Exception ex) when (ex is ArgumentException or UriFormatException)
+ {
+ throw new InvalidOperationException("Configured Gateway package identity is invalid.", ex);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(config.Gateway.InstallUrl) &&
+ !string.Equals(config.Gateway.InstallUrl, DefaultInstallUrl, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var target = OpenClaw.Connection.GatewayPackageBuildTargetResolver.Resolve();
+ var expectedVersionSpec = target.Source == OpenClaw.Connection.GatewayPackageSource.Official
+ ? target.ExpectedVersion
+ : target.PackageUri!.AbsoluteUri;
+ return string.Equals(config.Gateway.Version?.Trim(), expectedVersionSpec, StringComparison.Ordinal) &&
+ (string.IsNullOrWhiteSpace(config.Gateway.ExpectedInstalledVersion) ||
+ string.Equals(
+ config.Gateway.ExpectedInstalledVersion.Trim(),
+ target.ExpectedVersion,
+ StringComparison.Ordinal)) &&
+ string.Equals(
+ config.Gateway.ExpectedPackageSha256?.Trim(),
+ target.Sha256,
+ StringComparison.OrdinalIgnoreCase)
+ ? target.ExpectedVersion
+ : null;
+ }
+
+ internal static string? ResolveSchemaVersion(GatewayConfig config)
+ => ResolveSchemaVersion(
+ config,
+ OpenClaw.Connection.GatewayPackageBuildTargetResolver.Resolve());
+
+ internal static string? ResolveSchemaVersion(
+ GatewayConfig config,
+ OpenClaw.Connection.GatewayPackageTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ ArgumentNullException.ThrowIfNull(target);
+ if (!string.IsNullOrWhiteSpace(config.ExpectedInstalledVersion))
+ {
+ return config.ExpectedInstalledVersion.Trim();
+ }
+
+ if (target.Source == OpenClaw.Connection.GatewayPackageSource.Composed &&
+ string.Equals(config.Version?.Trim(), target.PackageUri?.AbsoluteUri, StringComparison.Ordinal) &&
+ string.Equals(config.ExpectedPackageSha256?.Trim(), target.Sha256, StringComparison.OrdinalIgnoreCase))
+ {
+ return target.ExpectedVersion;
+ }
+
+ return config.Version;
}
}
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/Program.cs b/src/OpenClaw.SetupEngine/Program.cs
index 865613363..1ab355954 100644
--- a/src/OpenClaw.SetupEngine/Program.cs
+++ b/src/OpenClaw.SetupEngine/Program.cs
@@ -132,7 +132,15 @@ public static async Task Main(string[] args)
}
if (!string.IsNullOrWhiteSpace(tailscaleHostname))
config.Tailscale.Hostname = tailscaleHostname;
- GatewayLkgVersion.ApplyToConfig(config);
+ try
+ {
+ GatewayLkgVersion.ApplyToConfig(config);
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.Error.WriteLine($"ERROR: {ex.Message}");
+ return 2;
+ }
if (headless) config.Headless = true;
if (rollback) config.RollbackOnFailure = true;
if (noRollback) config.RollbackOnFailure = false;
diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs
index d73234c5c..471ae2703 100644
--- a/src/OpenClaw.SetupEngine/SetupContext.cs
+++ b/src/OpenClaw.SetupEngine/SetupContext.cs
@@ -154,8 +154,10 @@ public sealed class GatewayConfig
public string Bind { get; set; } = "loopback";
public string? InstallUrl { get; set; }
public string? Version { get; set; }
+ public string? ExpectedInstalledVersion { 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..5351a0bb4 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,
@@ -1247,16 +1278,27 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati
}
string installScript;
+ string? expectedInstalledVersion;
try
{
- installScript = BuildInstallCommand(installUrl, ctx.Config.Gateway.Version);
+ installScript = BuildInstallCommand(
+ installUrl,
+ ctx.Config.Gateway.Version,
+ ctx.Config.Gateway.ExpectedPackageSha256);
+ expectedInstalledVersion = GatewayLkgVersion.ResolveExpectedInstalledVersion(ctx.Config);
}
- catch (ArgumentException ex)
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
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}");
@@ -1274,6 +1316,12 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati
var verify = await ctx.Commands.RunInWslAsync(distro, cmd, TimeSpan.FromSeconds(15), ct: ct);
if (verify.ExitCode == 0 && !string.IsNullOrWhiteSpace(verify.Stdout))
{
+ if (expectedInstalledVersion is not null &&
+ !IsExpectedOpenClawVersion(verify.Stdout, expectedInstalledVersion))
+ {
+ continue;
+ }
+
if (executablePath != null)
{
var pathResult = await EnsureCliOnDefaultPathAsync(ctx, distro, executablePath, ct);
@@ -1286,21 +1334,49 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati
}
}
- return StepResult.Fail("CLI installed but not found in any known location");
+ return expectedInstalledVersion is null
+ ? StepResult.Fail("CLI installed but not found in any known location")
+ : StepResult.Fail($"CLI installed but exact OpenClaw {expectedInstalledVersion} was 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)
+ => GatewayPackageInstallCommandBuilder.Build(
+ installUrl,
+ requestedVersion,
+ expectedPackageSha256);
+
+ internal static bool IsExpectedOpenClawVersion(string output, string expectedVersion)
{
- var escapedUrl = WslShellQuoting.EscapePosixSingleQuoteInner(installUrl);
- if (string.IsNullOrWhiteSpace(requestedVersion))
- return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash";
+ if (string.IsNullOrWhiteSpace(output) || string.IsNullOrWhiteSpace(expectedVersion))
+ return false;
- var trimmedVersion = requestedVersion.Trim();
- if (trimmedVersion.Contains('\n') || trimmedVersion.Contains('\r'))
- throw new ArgumentException("Gateway version cannot contain newlines.");
+ var candidates = new List();
+ foreach (var rawLine in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
+ {
+ var line = rawLine.Trim();
+ var candidate = line.StartsWith("OpenClaw ", StringComparison.Ordinal)
+ ? line["OpenClaw ".Length..].Split([' ', '\t', '('], 2)[0]
+ : line.Any(char.IsWhiteSpace)
+ ? null
+ : line;
+ if (candidate is null)
+ continue;
- var escapedVersion = WslShellQuoting.EscapePosixSingleQuoteInner(trimmedVersion);
- return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'";
+ try
+ {
+ candidates.Add(GatewayPackageTarget.Official(candidate).ExpectedVersion);
+ }
+ catch (ArgumentException)
+ {
+ // Ignore unrelated output; exactly one valid OpenClaw version must remain.
+ }
+ }
+
+ return candidates.Count == 1 &&
+ string.Equals(candidates[0], expectedVersion.Trim(), StringComparison.Ordinal);
}
private static async Task EnsureCliOnDefaultPathAsync(
@@ -1357,9 +1433,26 @@ 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 configuredMode) => 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 +1485,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 +1499,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 +1541,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 +1592,28 @@ 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(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.ReloadMode);
+ configCommands += $"\n openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(reloadMode)}";
+ }
+
return configCommands;
}
+ internal static string ResolveNodeCommandAllowConfigKey(GatewayConfig gw)
+ => GatewayNodeCommandPolicyConfig.ResolveAllowKey(
+ GatewayLkgVersion.ResolveSchemaVersion(gw))
+ ?? GatewayNodeCommandPolicyConfig.CurrentAllowKey;
+
// 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 +1638,12 @@ 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.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 +1672,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 +1741,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 +2320,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 +2371,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..b24eca017 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(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..921136ab4 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, 2026.7.1, 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/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs
index 6c93e99c2..9a3ec1dd4 100644
--- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs
+++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs
@@ -145,6 +145,19 @@ async Task RunCronJobDetailedAsync(string jobId, bool forc
/// Long-poll for QR linking completion. Sends web.login.wait { currentQrDataUrl, timeoutMs }.
Task WebLoginWaitAsync(string? currentQrDataUrl = null, int timeoutMs = 30000);
Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000);
+ ///
+ /// Sends a Gateway RPC request with a caller-supplied correlation id.
+ /// Intended for protocols that durably bind their transaction to the
+ /// original request envelope.
+ ///
+ Task SendCorrelatedRequestAsync(
+ string requestId,
+ string method,
+ object? parameters = null,
+ int timeoutMs = 30000,
+ CancellationToken cancellationToken = default)
+ => Task.FromException(
+ new NotSupportedException("Caller-correlated Gateway requests are not supported by this client."));
// ─── Gateway protocol APIs ───
// These ship with default implementations so adding them does not source-break
diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs
index 93baa963d..8d6d3ca1b 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,32 @@ 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 bool UsesLegacySchema(string? gatewayVersion)
+ {
+ var version = gatewayVersion?.Trim();
+ if (string.IsNullOrEmpty(version))
+ return false;
+
+ return string.Equals(version, "2026.6.11", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(version, "2026.7.1", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(version, "2026.7.2-beta.3", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static string? ResolveAllowKey(string? gatewayVersion)
+ {
+ var version = gatewayVersion?.Trim();
+ if (string.IsNullOrEmpty(version))
+ return null;
+
+ return UsesLegacySchema(version) ? LegacyAllowKey : CurrentAllowKey;
+ }
+}
+
public static class CommandCenterDiagnostics
{
private static readonly IReadOnlyDictionary s_severityPriority =
@@ -1248,17 +1278,39 @@ 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);
+ if (allowKey is null)
+ {
+ return string.Join(Environment.NewLine, [
+ "Gateway version unavailable; the node command allowlist key cannot be determined safely.",
+ "Refresh or reconnect the gateway before changing its command policy.",
+ "After the version is known:",
+ $"Preserve every existing entry and add only: {commandList}"
+ ]);
+ }
+
+ 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 +1318,22 @@ public static string BuildDangerousCommandOptInGuidance(IEnumerable comm
.Order(StringComparer.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(commandList))
commandList = "none";
+ var allowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion);
+ if (allowKey is null)
+ {
+ return string.Join(Environment.NewLine, [
+ "Privacy-sensitive OpenClaw command opt-in guidance",
+ $"Commands: {commandList}",
+ "Gateway version unavailable; refresh or reconnect the gateway before changing its command policy.",
+ "Leave these commands blocked until the allowlist key can be determined safely."
+ ]);
+ }
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 +1456,14 @@ 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 nodeCommandAllowlistReference = nodeCommandAllowKey ??
+ "the gateway node command allowlist (key unavailable until the gateway version is known)";
var isPendingApproval = node.ApprovalState is
GatewayNodeApprovalState.PendingApproval or
GatewayNodeApprovalState.PendingReapproval;
@@ -1482,9 +1549,13 @@ 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 {nodeCommandAllowlistReference}, re-approve or re-pair the device if the gateway keeps an older command snapshot.",
+ RepairAction = nodeCommandAllowKey is null ? null : "Copy safe allowlist merge guidance",
+ CopyText = nodeCommandAllowKey is null
+ ? null
+ : BuildAllowCommandsMergeGuidance(
+ CommandCenterCommandGroups.SafeCompanionCommands,
+ gatewayVersion)
});
}
@@ -1495,9 +1566,13 @@ 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.",
- RepairAction = "Copy opt-in guidance",
- CopyText = BuildDangerousCommandOptInGuidance(node.PrivacySensitiveApprovedCommands)
+ Detail = string.Join(", ", node.PrivacySensitiveApprovedCommands) + $" should only be available when explicitly allowed by {nodeCommandAllowlistReference}.",
+ RepairAction = nodeCommandAllowKey is null ? null : "Copy opt-in guidance",
+ CopyText = nodeCommandAllowKey is null
+ ? null
+ : BuildDangerousCommandOptInGuidance(
+ node.PrivacySensitiveApprovedCommands,
+ gatewayVersion)
});
}
@@ -1510,8 +1585,12 @@ GatewayNodeApprovalState.PendingApproval or
Category = "allowlist",
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)
+ RepairAction = nodeCommandAllowKey is null ? null : "Copy opt-in guidance",
+ CopyText = nodeCommandAllowKey is null
+ ? null
+ : BuildDangerousCommandOptInGuidance(
+ node.MissingDangerousAllowlistCommands,
+ gatewayVersion)
});
}
@@ -1524,8 +1603,12 @@ 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 = nodeCommandAllowKey is null ? null : "Copy browser proxy allowlist merge guidance",
+ CopyText = nodeCommandAllowKey is null
+ ? null
+ : BuildAllowCommandsMergeGuidance(
+ node.MissingBrowserAllowlistCommands,
+ gatewayVersion)
});
}
diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
index cbbcda9af..6964be7eb 100644
--- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj
+++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
@@ -5,10 +5,55 @@
enable
enable
OpenClaw.Shared
+ Official
+ 2026.7.1
+
+
+ <_Parameter1>OpenClawGatewayPackageSource
+ <_Parameter2>$(OpenClawGatewayPackageSource)
+
+
+ <_Parameter1>OpenClawGatewayPackageExpectedVersion
+ <_Parameter2>$(OpenClawGatewayPackageExpectedVersion)
+
+
+ <_Parameter1>OpenClawGatewayPackageUri
+ <_Parameter2>$(OpenClawGatewayPackageUri)
+
+
+ <_Parameter1>OpenClawGatewayPackageSha256
+ <_Parameter2>$(OpenClawGatewayPackageSha256)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index 5a8273836..1d9497f71 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -652,21 +652,63 @@ private static string ExtractMessageText(JsonElement message)
/// Sends a wizard RPC request and waits for the response payload.
/// Used for wizard.start, wizard.next, wizard.cancel, wizard.status.
///
- public async Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000)
+ public Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) =>
+ SendCorrelatedRequestCoreAsync(
+ Guid.NewGuid().ToString(),
+ method,
+ parameters,
+ timeoutMs,
+ cancellationToken: default,
+ useLegacyTransportHook: true);
+
+ public Task SendCorrelatedRequestAsync(
+ string requestId,
+ string method,
+ object? parameters = null,
+ int timeoutMs = 30000,
+ CancellationToken cancellationToken = default) =>
+ SendCorrelatedRequestCoreAsync(
+ requestId,
+ method,
+ parameters,
+ timeoutMs,
+ cancellationToken,
+ useLegacyTransportHook: false);
+
+ private async Task SendCorrelatedRequestCoreAsync(
+ string requestId,
+ string method,
+ object? parameters,
+ int timeoutMs,
+ CancellationToken cancellationToken,
+ bool useLegacyTransportHook)
{
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");
+ if (!s_pairingRequestIdRegex.IsMatch(requestId))
+ throw new ArgumentException("Gateway request id is invalid.", nameof(requestId));
+ if (string.IsNullOrWhiteSpace(method))
+ throw new ArgumentException("Gateway request method is required.", nameof(method));
_logger.Info($"[GatewayClient] Sending frame: {method}");
- var requestId = Guid.NewGuid().ToString();
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- _pendingWizardResponses[requestId] = completion;
+ if (!_pendingWizardResponses.TryAdd(requestId, completion))
+ throw new InvalidOperationException("A Gateway request with this id is already pending.");
TrackPendingRequest(requestId, method);
try
{
- await SendRawAsync(SerializeRequest(requestId, method, parameters));
- return await completion.Task.WaitAsync(TimeSpan.FromMilliseconds(timeoutMs), CancellationToken);
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ CancellationToken,
+ cancellationToken);
+ var frame = SerializeRequest(requestId, method, parameters);
+ if (useLegacyTransportHook)
+ await SendRawAsync(frame);
+ else
+ await SendRawAsync(frame, linkedCancellation.Token);
+ return await completion.Task.WaitAsync(
+ TimeSpan.FromMilliseconds(timeoutMs),
+ linkedCancellation.Token);
}
catch (TimeoutException ex)
{
diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index cc5d4a997..6b79b7fc5 100644
--- a/src/OpenClaw.Shared/SettingsData.cs
+++ b/src/OpenClaw.Shared/SettingsData.cs
@@ -118,6 +118,12 @@ 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. Seven days is the default; zero disables age retention.
+ public int GatewayRollbackRetentionAgeDays { get; set; } = 7;
+ /// Gateway rollback protection mode. NativeBackup is the default; FullVhd is explicit opt-in.
+ public string GatewayRollbackProtectionMode { get; set; } = "NativeBackup";
public bool HasSeenActivityStreamTip { get; set; } = false;
public string? SkippedUpdateTag { get; set; }
public bool NotifyChatResponses { get; set; } = true;
diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs
index 3309764e5..bf514695d 100644
--- a/src/OpenClaw.Shared/WebSocketClientBase.cs
+++ b/src/OpenClaw.Shared/WebSocketClientBase.cs
@@ -430,13 +430,27 @@ or WebSocketState.Closed
or WebSocketState.Aborted;
/// Send a text message over the WebSocket. Thread-safe.
- protected virtual async Task SendRawAsync(string message)
+ protected virtual Task SendRawAsync(string message) =>
+ SendRawAsync(message, CancellationToken.None);
+
+ ///
+ /// Send a text message while observing a caller-owned deadline.
+ /// Subclasses that replace the transport should override this contract as
+ /// well as the legacy one-argument hook.
+ ///
+ protected virtual Task SendRawAsync(string message, CancellationToken cancellationToken) =>
+ SendRawCoreAsync(message, cancellationToken);
+
+ private async Task SendRawCoreAsync(string message, CancellationToken cancellationToken)
{
+ using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ _cts.Token,
+ cancellationToken);
try
{
- await _sendLock.WaitAsync(_cts.Token);
+ await _sendLock.WaitAsync(linkedCancellation.Token);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Shutdown canceled the wait; drop the send silently.
return;
@@ -463,7 +477,7 @@ protected virtual async Task SendRawAsync(string message)
{
var written = Encoding.UTF8.GetBytes(message, buffer);
await ws.SendAsync(buffer.AsMemory(0, written),
- WebSocketMessageType.Text, true, _cts.Token);
+ WebSocketMessageType.Text, true, linkedCancellation.Token);
}
finally
{
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 2f7f1707b..0d238652b 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -196,6 +196,11 @@ public IntPtr GetHubWindowHandle()
private AppState? _appState;
internal AppState? AppState => _appState;
private UpdateCoordinator? _updateCoordinator;
+ private GatewayVersionAlignmentCoordinator? _gatewayVersionAlignmentCoordinator;
+ private GatewayRollbackPointManager? _gatewayRollbackPoints;
+ private string? _gatewayVersionAlignmentPromptKey;
+ private int _gatewayVersionAlignmentInFlight;
+ private int _gatewayVersionAlignmentFollowUpQueued;
private GatewayService? _gatewayService;
private PairingApprovalCoordinator? _pairingApprovalCoordinator;
private OpenClawTray.Dialogs.PairingApprovalDialog? _pairingApprovalDialog;
@@ -785,6 +790,24 @@ _dispatcherQueue is null
tunnelManager: _sshTunnelService);
_connectionManager.OperatorClientChanged += OnOperatorClientChanged;
_connectionManager.StateChanged += OnManagerStateChanged;
+ var gatewayAlignmentRunner = new WslExeCommandRunner(appLogger, defaultTimeout: TimeSpan.FromMinutes(35));
+ _gatewayRollbackPoints = new GatewayRollbackPointManager(
+ gatewayAlignmentRunner,
+ AppIdentity.ResolveSetupLocalDataDirectory(),
+ AppIdentity.SetupDistroName);
+ var gatewayPackageTarget = GatewayPackageBuildTargetResolver.Resolve();
+ var rollbackPoints = _gatewayRollbackPoints;
+ if (rollbackPoints is null)
+ throw new InvalidOperationException("Gateway rollback point manager was not initialized.");
+ _gatewayVersionAlignmentCoordinator = new GatewayVersionAlignmentCoordinator(
+ gatewayAlignmentRunner,
+ gatewayPackageTarget,
+ rollbackPoints,
+ SynchronizeCompanionGatewayAsync,
+ ResolveGatewayRollbackRetentionPolicy,
+ SendGatewayVersionAlignmentRequestAsync,
+ () => _connectionManager?.CurrentSnapshot.GatewayId,
+ protectionModeResolver: ResolveGatewayRollbackProtectionMode);
// First-run check (also supports forced onboarding for testing).
// Wrapped in try/catch so a wizard construction failure cannot tear
@@ -823,9 +846,13 @@ _dispatcherQueue is null
// hosts. Fire-and-forget on a background task so a slow LxssManager at
// cold logon never delays InitializeGatewayClient. The keepalive itself
// runs detached from the tray — see WslDistroKeepAlive in LocalGatewaySetup.cs.
- var wslKeepAlive = new WslGatewayKeepAliveService(() => _settings, () => _gatewayRegistry);
- _ = Task.Run(wslKeepAlive.TryEnsureAsync);
+ var wslKeepAlive = new WslGatewayKeepAliveService(
+ () => _settings,
+ () => _gatewayRegistry,
+ gatewayId => RunOnUiThreadAsync(
+ () => CheckCompanionGatewayVersionAsync(gatewayId)));
InitializeGatewayClient();
+ _ = Task.Run(wslKeepAlive.TryEnsureAsync);
// Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant)
if (_settings != null &&
@@ -2061,11 +2088,452 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna
_lastManagerConnectedSideEffectsKey = connectedSideEffectsKey;
_ = RunHealthCheckAsync();
_ = TryConnectLocalNodeServiceAsync();
+ _ = CheckCompanionGatewayVersionAsync(snap.GatewayId);
}
}
else
{
_lastManagerConnectedSideEffectsKey = null;
+ _gatewayVersionAlignmentPromptKey = null;
+ }
+ }
+
+ private async Task CheckCompanionGatewayVersionAsync(string? gatewayId)
+ {
+ 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, gatewayId, 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.Aligned &&
+ _gatewayVersionAlignmentCoordinator.HasVerifiedPendingUpdate())
+ {
+ var resumed = await _gatewayVersionAlignmentCoordinator.UpdateAsync(accessPlan);
+ ReportGatewayAlignmentResult(resumed);
+ if (!resumed.IsAligned)
+ _gatewayVersionAlignmentPromptKey = null;
+ return;
+ }
+
+ if ((probe.State is not GatewayVersionAlignmentState.Mismatch
+ and not GatewayVersionAlignmentState.NewerThanRequired) ||
+ 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 = promptKey;
+ 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 protection 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 effectiveProtectionMode =
+ probe.InstalledVersion is { Length: > 0 } sourceVersion &&
+ _gatewayVersionAlignmentCoordinator is { } coordinator
+ ? coordinator.ResolveProtectionMode(sourceVersion)
+ : ResolveGatewayRollbackProtectionMode();
+ 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" +
+ (effectiveProtectionMode == GatewayUpdateProtectionMode.FullVhd
+ ? "Companion will briefly stop this distro and verify a complete offline VHD rollback point. "
+ : "Companion will create and verify a native OpenClaw backup without stopping the distro. ") +
+ "It will then update OpenClaw in place and reconnect the Gateway and Windows Node. The distro is never " +
+ "recreated during update. Companion emergency restore is available only for Full VHD points.",
+ 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 Task RunOnUiThreadAsync(Func action)
+ {
+ if (_dispatcherQueue?.HasThreadAccess == true)
+ return action();
+
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (_dispatcherQueue?.TryEnqueue(async () =>
+ {
+ try
+ {
+ await action();
+ completion.SetResult();
+ }
+ 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 Task SendGatewayVersionAlignmentRequestAsync(
+ string expectedGatewayId,
+ string requestId,
+ string method,
+ object? parameters,
+ int timeoutMs,
+ CancellationToken cancellationToken)
+ {
+ var connectionManager = _connectionManager
+ ?? throw new InvalidOperationException("Gateway connection manager is unavailable.");
+ if (!string.Equals(
+ connectionManager.CurrentSnapshot.GatewayId,
+ expectedGatewayId,
+ StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The active Gateway identity changed before privileged update RPC dispatch.");
+ }
+
+ var client = connectionManager.OperatorClient
+ ?? throw new InvalidOperationException("Gateway operator client is unavailable.");
+ if (!client.IsConnectedToGateway)
+ throw new InvalidOperationException("Gateway operator client is not connected.");
+ if (!ReferenceEquals(client, connectionManager.OperatorClient) ||
+ !string.Equals(
+ connectionManager.CurrentSnapshot.GatewayId,
+ expectedGatewayId,
+ StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The active Gateway connection changed before privileged update RPC dispatch.");
+ }
+ return client.SendCorrelatedRequestAsync(
+ requestId,
+ method,
+ parameters,
+ timeoutMs,
+ cancellationToken);
+ }
+
+ 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.RecoveryResolved:
+ ShowGatewayAlignmentToast(
+ "Native Gateway recovery resolved",
+ $"The retained backup, OpenClaw {result.InstalledVersion}, Gateway, Windows Node, and pairing state were verified without restoring or recreating the distro.");
+ break;
+ case GatewayVersionAlignmentState.RollbackPointFailed:
+ ShowGatewayAlignmentToast(
+ "Local Gateway update not started",
+ "The required verified protection 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. Review the verified protection point in Settings; Companion restore is available only for Full VHD points.");
+ 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 ?? 7;
+ return new GatewayRollbackRetentionPolicy(
+ count,
+ ageDays > 0 ? TimeSpan.FromDays(ageDays) : null);
+ }
+
+ internal IReadOnlyList GetGatewayRollbackPoints() =>
+ _gatewayVersionAlignmentCoordinator?.ListRollbackPoints() ?? [];
+
+ internal bool HasUnreadableGatewayRollbackReceipt() =>
+ _gatewayVersionAlignmentCoordinator?.HasUnreadableRollbackReceipt() == true;
+
+ internal async Task CleanupGatewayRollbackPointsAsync(CancellationToken cancellationToken = default)
+ {
+ var coordinator = _gatewayVersionAlignmentCoordinator;
+ return coordinator is null
+ ? 0
+ : await coordinator.CleanupRollbackPointsAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private GatewayUpdateProtectionMode ResolveGatewayRollbackProtectionMode() =>
+ string.Equals(_settings?.GatewayRollbackProtectionMode, SettingsManager.GatewayRollbackProtectionFullVhd, StringComparison.OrdinalIgnoreCase)
+ ? GatewayUpdateProtectionMode.FullVhd
+ : GatewayUpdateProtectionMode.NativeBackup;
+
+ 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 async Task ResolveNativeGatewayRecoveryAsync(
+ 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.ResolveNativeRecoveryAsync(
+ 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}");
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
index b31b4bece..b15f90645 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
@@ -626,6 +626,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
index 71936584c..9d7a515d6 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
@@ -15,6 +15,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
+using System.Linq;
namespace OpenClawTray.Pages;
@@ -56,6 +57,7 @@ public void Initialize()
InitializeGatewayInfo();
if (CurrentApp.Settings is { } settings)
LoadGatewaySection(settings);
+ RefreshGatewayRollbackStorage();
}
///
@@ -287,6 +289,266 @@ private void OnCheckUpdates(object sender, RoutedEventArgs e)
((IAppCommands)CurrentApp).CheckForUpdates();
}
+ private void OnManageGatewayRollbackPoints(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ ManageGatewayRollbackPointsAsync,
+ new OpenClawTray.AppLogger(),
+ nameof(OnManageGatewayRollbackPoints));
+
+ private async Task ManageGatewayRollbackPointsAsync()
+ {
+ GatewayRollbackResultBar.IsOpen = false;
+ var points = CurrentApp.GetGatewayRollbackPoints();
+ if (points.Count == 0)
+ {
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Informational;
+ GatewayRollbackResultBar.Title = "No rollback points";
+ GatewayRollbackResultBar.Message = "A verified rollback point will be created before the next local Gateway update.";
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+
+ var choices = points.Select(point => new GatewayRollbackPointChoice(point)).ToArray();
+ var mandatoryChoices = choices
+ .Where(choice => IsMandatoryRecoveryPhase(choice.Point.Phase))
+ .ToArray();
+ if (mandatoryChoices.Length > 1)
+ {
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Error;
+ GatewayRollbackResultBar.Title = "Gateway recovery is ambiguous";
+ GatewayRollbackResultBar.Message =
+ "Multiple mandatory recovery receipts exist: " +
+ string.Join(", ", mandatoryChoices.Select(choice => choice.Point.Id)) +
+ ". No rollback action was started.";
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+ var preferredChoice = mandatoryChoices.SingleOrDefault()
+ ?? choices.FirstOrDefault(choice => choice.Point.RestoreEligible);
+ var selector = new ComboBox
+ {
+ ItemsSource = choices,
+ DisplayMemberPath = nameof(GatewayRollbackPointChoice.DisplayText),
+ SelectedIndex = preferredChoice is null ? -1 : Array.IndexOf(choices, preferredChoice),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ MinWidth = 520
+ };
+ var content = new StackPanel { Spacing = 10 };
+ content.Children.Add(new TextBlock
+ {
+ Text = "Rollback points show metadata only. Their private contents are never displayed.",
+ TextWrapping = TextWrapping.Wrap
+ });
+ content.Children.Add(selector);
+
+ var selectDialog = new ContentDialog
+ {
+ Title = "Select a Gateway rollback point",
+ Content = content,
+ PrimaryButtonText = "Continue",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot
+ };
+ if (await selectDialog.ShowAsync() != ContentDialogResult.Primary ||
+ selector.SelectedItem is not GatewayRollbackPointChoice selected)
+ {
+ return;
+ }
+
+ if (selected.Point.ProtectionMode == GatewayUpdateProtectionMode.NativeBackup)
+ {
+ if (selected.Point.Phase == GatewayRollbackPointPhase.UpdateInProgress)
+ {
+ var resolveDialog = new ContentDialog
+ {
+ Title = "Resolve native update recovery?",
+ Content =
+ "Companion will not restore this native backup automatically. It will verify the retained backup, " +
+ "the exact currently installed OpenClaw version, distro identity, Gateway, Windows Node, and pairing health. " +
+ "The receipt is resolved only when the live installation exactly matches either the pre-update version or the intended target.",
+ PrimaryButtonText = "Verify and resolve",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot
+ };
+ if (await resolveDialog.ShowAsync() != ContentDialogResult.Primary)
+ return;
+
+ var resolved = await CurrentApp.ResolveNativeGatewayRecoveryAsync(selected.Point.Id);
+ GatewayRollbackResultBar.Severity =
+ resolved.State is GatewayVersionAlignmentState.RecoveryResolved or GatewayVersionAlignmentState.Updated
+ ? InfoBarSeverity.Success
+ : InfoBarSeverity.Error;
+ GatewayRollbackResultBar.Title =
+ resolved.State is GatewayVersionAlignmentState.RecoveryResolved or GatewayVersionAlignmentState.Updated
+ ? "Native update recovery resolved"
+ : "Native update recovery needs attention";
+ GatewayRollbackResultBar.Message =
+ resolved.FailureSummary ??
+ "The live Gateway state was verified and the pending recovery receipt was resolved.";
+ GatewayRollbackResultBar.IsOpen = true;
+ RefreshGatewayRollbackStorage();
+ return;
+ }
+
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Informational;
+ GatewayRollbackResultBar.Title = "Native backup is not restorable here";
+ GatewayRollbackResultBar.Message = "This point uses OpenClaw's native backup. It can be retained or cleaned up, but Companion cannot restore it as a full WSL VHD.";
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+
+ if (!selected.Point.RestoreEligible)
+ {
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Warning;
+ GatewayRollbackResultBar.Title = "Rollback point is not eligible";
+ GatewayRollbackResultBar.Message = "Verification or transaction state prevents restoring this point.";
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+
+ var canCancelStagedRestore =
+ selected.Point.Phase == GatewayRollbackPointPhase.RestoreStaged;
+ var confirmation = new ContentDialog
+ {
+ Title = $"Restore OpenClaw {selected.Point.OpenClawVersion}?",
+ Content =
+ "Emergency restore will stop and unregister the current Companion-owned WSL distro, then import a verified copy " +
+ $"of rollback point {selected.Point.Id} under the same distro name. The complete retained filesystem and runtime state " +
+ "will replace the current state. Companion will then verify Gateway, Windows Node, and pairing health." +
+ (canCancelStagedRestore
+ ? $" Alternatively, cancel the staged restore for exact point {selected.Point.Id} before any destructive boundary."
+ : string.Empty),
+ PrimaryButtonText = canCancelStagedRestore
+ ? "Resume this rollback point"
+ : "Restore this rollback point",
+ SecondaryButtonText = canCancelStagedRestore
+ ? "Cancel staged restore"
+ : null,
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot
+ };
+ var confirmationResult = await confirmation.ShowAsync();
+ if (confirmationResult == ContentDialogResult.Secondary && canCancelStagedRestore)
+ {
+ var cancelled = CurrentApp.CancelGatewayRollbackPointRestore(
+ selected.Point.Id, selected.Point.Id);
+ GatewayRollbackResultBar.Severity =
+ cancelled.State == GatewayVersionAlignmentState.RestoreCancelled
+ ? InfoBarSeverity.Success
+ : InfoBarSeverity.Error;
+ GatewayRollbackResultBar.Title =
+ cancelled.State == GatewayVersionAlignmentState.RestoreCancelled
+ ? "Staged restore cancelled"
+ : "Staged restore cancellation needs attention";
+ GatewayRollbackResultBar.Message =
+ cancelled.State == GatewayVersionAlignmentState.RestoreCancelled
+ ? "The staged copy was removed before the destructive boundary. Fresh Gateway updates are unblocked."
+ : cancelled.FailureSummary ?? "The durable recovery receipt was preserved.";
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+ if (confirmationResult != ContentDialogResult.Primary)
+ return;
+
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Informational;
+ GatewayRollbackResultBar.Title = "Restoring local Gateway";
+ GatewayRollbackResultBar.Message = "Do not close Companion while the WSL registration and health checks are being restored.";
+ GatewayRollbackResultBar.IsOpen = true;
+
+ var result = await CurrentApp.RestoreGatewayRollbackPointAsync(selected.Point.Id);
+ GatewayRollbackResultBar.Severity = result.State == GatewayVersionAlignmentState.Restored
+ ? InfoBarSeverity.Success
+ : InfoBarSeverity.Error;
+ GatewayRollbackResultBar.Title = result.State == GatewayVersionAlignmentState.Restored
+ ? "Gateway rollback restored"
+ : "Gateway rollback needs attention";
+ GatewayRollbackResultBar.Message = result.State == GatewayVersionAlignmentState.Restored
+ ? $"OpenClaw {result.InstalledVersion} and its retained state are healthy and synchronized."
+ : result.RollbackPointId is { } requiredPointId &&
+ !string.Equals(requiredPointId, selected.Point.Id, StringComparison.Ordinal)
+ ? $"Recovery must resume exact rollback point {requiredPointId}. " +
+ (result.FailureSummary ?? "The mandatory receipt was preserved.")
+ : result.FailureSummary ?? "The durable recovery receipt and verified rollback point were preserved for retry.";
+ GatewayRollbackResultBar.IsOpen = true;
+ }
+
+ private static bool IsMandatoryRecoveryPhase(GatewayRollbackPointPhase phase) =>
+ phase is GatewayRollbackPointPhase.UpdateInProgress
+ or GatewayRollbackPointPhase.RestoreStaged
+ or GatewayRollbackPointPhase.UnregisterPending
+ or GatewayRollbackPointPhase.DistroUnregistered
+ or GatewayRollbackPointPhase.ImportPending
+ or GatewayRollbackPointPhase.Imported;
+
+ private sealed record GatewayRollbackPointChoice(GatewayRollbackPointInfo Point)
+ {
+ public string DisplayText =>
+ $"Point {Point.Id} | {Point.Phase} | OpenClaw {Point.OpenClawVersion} | {Point.CreatedAtUtc.ToLocalTime():g} | " +
+ $"{Point.ProtectionMode} | {Point.VerificationStatus} | {FormatByteSize(Point.ApproximateSizeBytes)} | " +
+ $"{(Point.RestoreEligible ? "restore eligible" : "not restorable by Companion")}";
+ }
+
+ private void OnCleanupGatewayRollbackPoints(object sender, RoutedEventArgs e) =>
+ AsyncEventHandlerGuard.Run(
+ CleanupGatewayRollbackPointsAsync,
+ new OpenClawTray.AppLogger(),
+ nameof(OnCleanupGatewayRollbackPoints));
+
+ private async Task CleanupGatewayRollbackPointsAsync()
+ {
+ int deleted;
+ try
+ {
+ deleted = await CurrentApp.CleanupGatewayRollbackPointsAsync();
+ }
+ catch (InvalidOperationException ex)
+ {
+ RefreshGatewayRollbackStorage();
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Error;
+ GatewayRollbackResultBar.Title = "Rollback cleanup blocked";
+ GatewayRollbackResultBar.Message = ex.Message;
+ GatewayRollbackResultBar.IsOpen = true;
+ return;
+ }
+
+ RefreshGatewayRollbackStorage();
+ GatewayRollbackResultBar.Severity = InfoBarSeverity.Informational;
+ GatewayRollbackResultBar.Title = "Rollback cleanup finished";
+ GatewayRollbackResultBar.Message = deleted == 0
+ ? "No rollback protection files matched the current retention settings."
+ : $"Removed {deleted} rollback protection item{(deleted == 1 ? string.Empty : "s")} using the current retention settings.";
+ GatewayRollbackResultBar.IsOpen = true;
+ }
+
+ private void RefreshGatewayRollbackStorage()
+ {
+ if (CurrentApp.HasUnreadableGatewayRollbackReceipt())
+ {
+ GatewayRollbackStorageText.Text =
+ "Stored rollback protection is unavailable because a receipt cannot be read or validated.";
+ return;
+ }
+
+ var points = CurrentApp.GetGatewayRollbackPoints();
+ var totalBytes = points.Sum(point => Math.Max(0, point.ApproximateSizeBytes));
+ GatewayRollbackStorageText.Text =
+ $"Stored rollback protection: {points.Count} point{(points.Count == 1 ? string.Empty : "s")}, {FormatByteSize(totalBytes)} total.";
+ }
+
+ private static string FormatByteSize(long bytes)
+ {
+ if (bytes >= 1024L * 1024 * 1024)
+ return $"{bytes / (1024d * 1024 * 1024):0.0} GB";
+ if (bytes >= 1024L * 1024)
+ return $"{bytes / (1024d * 1024):0.0} MB";
+ if (bytes >= 1024)
+ return $"{bytes / 1024d:0.0} KB";
+ return $"{bytes} B";
+ }
+
private void OnDocumentationLink(object sender, RoutedEventArgs e)
{
OpenShellTarget(DocumentationUrl, "documentation");
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
index 7b16879d5..9b0d4a813 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/ISettingsStore.cs
@@ -1,3 +1,5 @@
+using OpenClawTray.Services;
+
namespace OpenClawTray.Presentation;
///
@@ -74,6 +76,9 @@ public interface ISettingsEditor
bool ScreenRecordingConsentGiven { set; }
bool CameraRecordingConsentGiven { set; }
+ int GatewayRollbackRetentionCount { set; }
+ int GatewayRollbackRetentionAgeDays { set; }
+ string GatewayRollbackProtectionMode { set; }
bool ShowChatToolCalls { set; }
}
@@ -105,6 +110,10 @@ public sealed record SettingsSnapshot
public bool ScreenRecordingConsentGiven { get; init; }
public bool CameraRecordingConsentGiven { get; init; }
+ public int GatewayRollbackRetentionCount { get; init; }
+ public int GatewayRollbackRetentionAgeDays { get; init; }
+ public string GatewayRollbackProtectionMode { get; init; } = SettingsManager.GatewayRollbackProtectionNativeBackup;
+
/// Reflects VoiceTtsEnabled; the "read responses aloud" toggle mirrors it.
public bool VoiceTtsEnabled { get; init; }
public bool ShowChatToolCalls { get; init; }
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
index 0fd4aa8e2..f018b5d14 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Globalization;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using OpenClawTray.Services;
@@ -52,6 +53,9 @@ internal sealed class SettingsPageViewModel : INavigationAware, IDisposable, INo
private bool _notifyInfo;
private bool _screenRecordingConsentGiven;
private bool _cameraRecordingConsentGiven;
+ private string _gatewayRollbackRetentionCount = "1";
+ private double _gatewayRollbackRetentionAgeDays;
+ private string _gatewayRollbackProtectionMode = SettingsManager.GatewayRollbackProtectionNativeBackup;
private bool _readResponsesAloud;
private bool _showChatToolCalls;
@@ -205,6 +209,48 @@ public bool CameraRecordingConsentGiven
set { if (SetField(ref _cameraRecordingConsentGiven, value) && !_loading) Persist(e => e.CameraRecordingConsentGiven = value); }
}
+ public string GatewayRollbackRetentionCount
+ {
+ get => _gatewayRollbackRetentionCount;
+ set
+ {
+ var normalized = value is "1" or "2" or "-1" ? value : "1";
+ if (SetField(ref _gatewayRollbackRetentionCount, normalized) && !_loading)
+ {
+ Persist(e => e.GatewayRollbackRetentionCount =
+ int.Parse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture));
+ }
+ }
+ }
+
+ public double GatewayRollbackRetentionAgeDays
+ {
+ get => _gatewayRollbackRetentionAgeDays;
+ set
+ {
+ var normalized = double.IsNaN(value)
+ ? 0
+ : Math.Round(Math.Clamp(value, 0, 3650), MidpointRounding.AwayFromZero);
+ if (SetField(ref _gatewayRollbackRetentionAgeDays, normalized) && !_loading)
+ {
+ Persist(e => e.GatewayRollbackRetentionAgeDays = (int)normalized);
+ }
+ }
+ }
+
+ public string GatewayRollbackProtectionMode
+ {
+ get => _gatewayRollbackProtectionMode;
+ set
+ {
+ var normalized = string.Equals(value, SettingsManager.GatewayRollbackProtectionFullVhd, StringComparison.OrdinalIgnoreCase)
+ ? SettingsManager.GatewayRollbackProtectionFullVhd
+ : SettingsManager.GatewayRollbackProtectionNativeBackup;
+ if (SetField(ref _gatewayRollbackProtectionMode, normalized) && !_loading)
+ Persist(e => e.GatewayRollbackProtectionMode = normalized);
+ }
+ }
+
///
/// "Read responses aloud" mirrors VoiceTtsEnabled (mute is its inverse). Routed through
/// the app command that persists + broadcasts, exactly like before; it does not go through the
@@ -296,6 +342,18 @@ private void LoadFromStore()
SetField(ref _notifyInfo, s.NotifyInfo, nameof(NotifyInfo));
SetField(ref _screenRecordingConsentGiven, s.ScreenRecordingConsentGiven, nameof(ScreenRecordingConsentGiven));
SetField(ref _cameraRecordingConsentGiven, s.CameraRecordingConsentGiven, nameof(CameraRecordingConsentGiven));
+ SetField(
+ ref _gatewayRollbackRetentionCount,
+ NormalizeRollbackRetentionCount(s.GatewayRollbackRetentionCount),
+ nameof(GatewayRollbackRetentionCount));
+ SetField(
+ ref _gatewayRollbackRetentionAgeDays,
+ (double)Math.Clamp(s.GatewayRollbackRetentionAgeDays, 0, 3650),
+ nameof(GatewayRollbackRetentionAgeDays));
+ SetField(
+ ref _gatewayRollbackProtectionMode,
+ NormalizeProtectionMode(s.GatewayRollbackProtectionMode),
+ nameof(GatewayRollbackProtectionMode));
SetField(ref _readResponsesAloud, s.VoiceTtsEnabled, nameof(ReadResponsesAloud));
SetField(ref _showChatToolCalls, s.ShowChatToolCalls, nameof(ShowChatToolCalls));
}
@@ -305,6 +363,16 @@ private void LoadFromStore()
}
}
+ private static string NormalizeRollbackRetentionCount(int value) =>
+ value is 1 or 2 or -1
+ ? value.ToString(CultureInfo.InvariantCulture)
+ : "1";
+
+ private static string NormalizeProtectionMode(string? value) =>
+ string.Equals(value, SettingsManager.GatewayRollbackProtectionFullVhd, StringComparison.OrdinalIgnoreCase)
+ ? SettingsManager.GatewayRollbackProtectionFullVhd
+ : SettingsManager.GatewayRollbackProtectionNativeBackup;
+
private void Persist(Action edit)
{
if (_loading)
diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
index 0f5bbdb29..6e0d02b1a 100644
--- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
+++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsStore.cs
@@ -52,6 +52,9 @@ public SettingsStore(SettingsManager settings, IUiDispatcher dispatcher)
NotifyInfo = _settings.NotifyInfo,
ScreenRecordingConsentGiven = _settings.ScreenRecordingConsentGiven,
CameraRecordingConsentGiven = _settings.CameraRecordingConsentGiven,
+ GatewayRollbackRetentionCount = _settings.GatewayRollbackRetentionCount,
+ GatewayRollbackRetentionAgeDays = _settings.GatewayRollbackRetentionAgeDays,
+ GatewayRollbackProtectionMode = _settings.GatewayRollbackProtectionMode,
VoiceTtsEnabled = _settings.VoiceTtsEnabled,
ShowChatToolCalls = _settings.ShowChatToolCalls,
};
@@ -133,6 +136,9 @@ private sealed class Editor : ISettingsEditor
public bool NotifyInfo { set => _settings.NotifyInfo = value; }
public bool ScreenRecordingConsentGiven { set => _settings.ScreenRecordingConsentGiven = value; }
public bool CameraRecordingConsentGiven { set => _settings.CameraRecordingConsentGiven = value; }
+ public int GatewayRollbackRetentionCount { set => _settings.GatewayRollbackRetentionCount = value; }
+ public int GatewayRollbackRetentionAgeDays { set => _settings.GatewayRollbackRetentionAgeDays = value; }
+ public string GatewayRollbackProtectionMode { set => _settings.GatewayRollbackProtectionMode = value; }
public bool ShowChatToolCalls { set => _settings.ShowChatToolCalls = value; }
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs
index d2b4df8a2..487292c2a 100644
--- a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs
@@ -19,10 +19,13 @@ internal CommandCenterStateBuilder(AppStateSnapshot snapshot)
internal GatewayCommandCenterState Build()
{
- var nodes = _snapshot.Nodes.Select(NodeCapabilityHealthInfo.FromNode).ToList();
+ var gatewayVersion = _snapshot.GatewaySelf?.ServerVersion;
+ var nodes = _snapshot.Nodes
+ .Select(node => NodeCapabilityHealthInfo.FromNode(node, gatewayVersion))
+ .ToList();
if (nodes.Count == 0 && _snapshot.NodeService?.GetLocalNodeInfo() is { } localNode)
{
- nodes.Add(NodeCapabilityHealthInfo.FromLocalDeclarations(localNode));
+ nodes.Add(NodeCapabilityHealthInfo.FromLocalDeclarations(localNode, gatewayVersion));
}
var tunnelInputs = CommandCenterTopologyTunnelResolver.Derive(
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index f211c7b55..b12f27568 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -18,11 +18,13 @@ public class SettingsManager
private readonly string _settingsDirectory;
private readonly string _settingsFilePath;
private const string ProtectedSecretPrefix = "dpapi:";
- private const int CurrentSettingsSchemaVersion = 1;
+ private const int CurrentSettingsSchemaVersion = 2;
private static readonly byte[] ProtectedSecretEntropy = Encoding.UTF8.GetBytes("OpenClawTray.Settings.v1");
public const string AppThemeSystem = "System";
public const string AppThemeLight = "Light";
public const string AppThemeDark = "Dark";
+ public const string GatewayRollbackProtectionNativeBackup = "NativeBackup";
+ public const string GatewayRollbackProtectionFullVhd = "FullVhd";
public static string SettingsDirectoryPath => GetDefaultSettingsDirectory();
public static string SettingsPath => Path.Combine(SettingsDirectoryPath, "settings.json");
@@ -49,6 +51,21 @@ public class SettingsManager
public bool HasLegacyGatewayCredentials =>
!string.IsNullOrWhiteSpace(LegacyToken) ||
!string.IsNullOrWhiteSpace(LegacyBootstrapToken);
+ public int GatewayRollbackRetentionCount
+ {
+ get => NormalizeRollbackRetentionCount(_data.GatewayRollbackRetentionCount);
+ set => _data = _data with { GatewayRollbackRetentionCount = NormalizeRollbackRetentionCount(value) };
+ }
+ public int GatewayRollbackRetentionAgeDays
+ {
+ get => Math.Clamp(_data.GatewayRollbackRetentionAgeDays, 0, 3650);
+ set => _data = _data with { GatewayRollbackRetentionAgeDays = Math.Clamp(value, 0, 3650) };
+ }
+ public string GatewayRollbackProtectionMode
+ {
+ get => NormalizeGatewayRollbackProtectionMode(_data.GatewayRollbackProtectionMode);
+ set => _data = _data with { GatewayRollbackProtectionMode = NormalizeGatewayRollbackProtectionMode(value) };
+ }
// Startup
public bool AutoStart { get => _data.AutoStart; set => _data = _data with { AutoStart = value }; }
@@ -282,6 +299,9 @@ public void Load()
HasSeenActivityStreamTip = false,
SkippedUpdateTag = "",
PreferredGatewayId = null,
+ GatewayRollbackRetentionCount = 1,
+ GatewayRollbackRetentionAgeDays = 7,
+ GatewayRollbackProtectionMode = GatewayRollbackProtectionNativeBackup,
SystemRunSandboxEnabled = true,
SystemRunBlockHostFallbackWhenMxcUnavailable = false,
SystemRunAllowOutbound = false,
@@ -320,6 +340,9 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded, string? raw
A2UIImageHosts = loaded.A2UIImageHosts is { Count: > 0 } hosts ? new List(hosts) : new(),
SkippedUpdateTag = loaded.SkippedUpdateTag ?? defaults.SkippedUpdateTag,
PreferredGatewayId = loaded.PreferredGatewayId ?? defaults.PreferredGatewayId,
+ GatewayRollbackRetentionCount = NormalizeRollbackRetentionCount(loaded.GatewayRollbackRetentionCount),
+ GatewayRollbackRetentionAgeDays = Math.Clamp(loaded.GatewayRollbackRetentionAgeDays, 0, 3650),
+ GatewayRollbackProtectionMode = NormalizeGatewayRollbackProtectionMode(loaded.GatewayRollbackProtectionMode),
AppTheme = NormalizeAppTheme(loaded.AppTheme),
ShowDiagnostics = loaded.ShowDiagnostics,
OpenTelemetryEndpoint = NormalizeOptionalString(loaded.OpenTelemetryEndpoint),
@@ -349,6 +372,13 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded, string? raw
private static bool IsValidPort(int port) => port is >= 1 and <= 65535;
+ private static int NormalizeRollbackRetentionCount(int value) => value is 1 or 2 or -1 ? value : 1;
+
+ private static string NormalizeGatewayRollbackProtectionMode(string? value) =>
+ string.Equals(value, GatewayRollbackProtectionFullVhd, StringComparison.OrdinalIgnoreCase)
+ ? GatewayRollbackProtectionFullVhd
+ : GatewayRollbackProtectionNativeBackup;
+
private static List CloneSandboxCustomFolders(IEnumerable? folders) =>
folders is null
? new List()
diff --git a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
index 07a3a1e10..d12d0870e 100644
--- a/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/WslGatewayKeepAliveService.cs
@@ -16,10 +16,12 @@ namespace OpenClawTray.Services;
///
internal sealed class WslGatewayKeepAliveService(
Func getSettings,
- Func getRegistry)
+ Func getRegistry,
+ Func? onLocalDistroReady = null)
{
private readonly Func _getSettings = getSettings;
private readonly Func _getRegistry = getRegistry;
+ private readonly Func? _onLocalDistroReady = onLocalDistroReady;
///
/// Ensures a WSL keepalive process is running for the local gateway distro
@@ -65,10 +67,22 @@ public async Task TryEnsureAsync()
psi.ArgumentList.Add("sleep");
psi.ArgumentList.Add("infinity");
- var proc = System.Diagnostics.Process.Start(psi);
- if (proc is not null)
+ try
+ {
+ var proc = System.Diagnostics.Process.Start(psi);
+ if (proc is not null)
+ {
+ Logger.Info($"[WslKeepAlive] Started keepalive for {distroName} (PID {proc.Id}).");
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"[WslKeepAlive] Could not start keepalive for {distroName} (non-fatal): {ex.Message}");
+ }
+
+ if (_onLocalDistroReady is not null)
{
- Logger.Info($"[WslKeepAlive] Started keepalive for {distroName} (PID {proc.Id}).");
+ await _onLocalDistroReady(activeRecord?.Id);
}
}
catch (Exception ex)
diff --git a/src/skills/windows-a2ui/SKILL.md b/src/skills/windows-a2ui/SKILL.md
index 1d60a50cb..e1382380a 100644
--- a/src/skills/windows-a2ui/SKILL.md
+++ b/src/skills/windows-a2ui/SKILL.md
@@ -163,7 +163,11 @@ Paths outside the allowed set are silently dropped from the action envelope. If
## Capability gate
-Three RPCs are involved, all of which must be on the gateway's `gateway.nodes.allowCommands` list (and the node must advertise them via `node.describe`):
+Three RPCs are involved, all of which must be on the gateway's
+version-appropriate allowlist (and the node must advertise them via
+`node.describe`): use `gateway.nodes.commands.allow` for current/custom
+packages, or `gateway.nodes.allowCommands` for exact legacy versions
+`2026.6.11`, `2026.7.1`, and `2026.7.2-beta.3`.
```
canvas.a2ui.push
diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
index 96326c7a1..47e48d284 100644
--- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
+++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs
@@ -488,7 +488,8 @@ public async Task ConnectWithSharedTokenAsync_RevalidatesDurableTokensUnderTrans
_factory,
_registry,
NullLogger.Instance,
- tunnelManager: tunnel);
+ tunnelManager: tunnel,
+ sharedTokenValidationTimeout: TimeSpan.FromMilliseconds(250));
var connectTask = manager.ConnectAsync("gw-1");
await tunnel.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
@@ -500,7 +501,7 @@ public async Task ConnectWithSharedTokenAsync_RevalidatesDurableTokensUnderTrans
tunnel.AllowStart.SetResult(true);
await connectTask.WaitAsync(TimeSpan.FromSeconds(2));
- var result = await replaceTask.WaitAsync(TimeSpan.FromSeconds(10));
+ var result = await replaceTask.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(SetupCodeOutcome.ConnectionFailed, result.Outcome);
Assert.Null(_registry.GetById("gw-1")?.SharedGatewayToken);
@@ -2169,7 +2170,7 @@ private sealed class MockClientFactory : IGatewayClientFactory
public IGatewayClientLifecycle Create(string gatewayUrl, GatewayCredential credential, string identityPath, IOpenClawLogger logger)
{
- var mock = new MockLifecycle(gatewayUrl);
+ var mock = new MockLifecycle(gatewayUrl, identityPath);
CreatedClients.Add(mock);
CreatedCredentials.Add(credential);
CreatedIdentityPaths.Add(identityPath);
@@ -2195,9 +2196,9 @@ internal sealed class MockLifecycle : IGatewayClientLifecycle
{
private readonly MockGatewayClient _client;
- public MockLifecycle(string url)
+ public MockLifecycle(string url, string identityPath)
{
- _client = new MockGatewayClient(url);
+ _client = new MockGatewayClient(url, identityPath);
}
public OpenClawGatewayClient DataClient => _client;
@@ -2230,8 +2231,8 @@ public void SimulateDeviceTokenReceived(string token, string role, string[]? sco
private sealed class MockGatewayClient : OpenClawGatewayClient
{
- public MockGatewayClient(string url)
- : base(url, "mock-token", NullLogger.Instance) { }
+ public MockGatewayClient(string url, string identityPath)
+ : base(url, "mock-token", NullLogger.Instance, identityPath: identityPath) { }
public void SimulateTransportConnected() =>
RaiseTransportConnected();
diff --git a/tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs b/tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs
new file mode 100644
index 000000000..7ff06b09a
--- /dev/null
+++ b/tests/OpenClaw.Connection.Tests/GatewayPackageTargetTests.cs
@@ -0,0 +1,185 @@
+using OpenClaw.Connection;
+
+namespace OpenClaw.Connection.Tests;
+
+public sealed class GatewayPackageTargetTests
+{
+ private const string Digest =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+ [Fact]
+ public void BuildTargetResolver_UsesOrdinaryUpstreamAssemblyDefault()
+ {
+ var target = GatewayPackageBuildTargetResolver.Resolve();
+
+ Assert.Equal(GatewayPackageSource.Official, target.Source);
+ Assert.Equal("2026.7.1", target.ExpectedVersion);
+ Assert.Null(target.PackageUri);
+ Assert.Null(target.Sha256);
+ }
+
+ [Fact]
+ public void BuildTargetResolver_AcceptsInjectedComposedMetadata()
+ {
+ var target = GatewayPackageBuildTargetResolver.Resolve(
+ [
+ KeyValuePair.Create(
+ GatewayPackageBuildTargetResolver.SourceMetadataKey,
+ "Composed"),
+ KeyValuePair.Create(
+ GatewayPackageBuildTargetResolver.ExpectedVersionMetadataKey,
+ "2026.7.22+companion.2"),
+ KeyValuePair.Create(
+ GatewayPackageBuildTargetResolver.PackageUriMetadataKey,
+ "https://packages.example.test/openclaw-composed.tgz"),
+ KeyValuePair.Create(
+ GatewayPackageBuildTargetResolver.Sha256MetadataKey,
+ Digest)
+ ]);
+
+ Assert.Equal(GatewayPackageSource.Composed, target.Source);
+ Assert.Equal("2026.7.22+companion.2", target.ExpectedVersion);
+ Assert.Equal("https://packages.example.test/openclaw-composed.tgz", target.PackageUri!.AbsoluteUri);
+ Assert.Equal(Digest, target.Sha256);
+ }
+
+ [Theory]
+ [InlineData("Official", "https://packages.example.test/openclaw.tgz", null)]
+ [InlineData("Official", null, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
+ [InlineData("Composed", null, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
+ [InlineData("Composed", "https://packages.example.test/openclaw.tgz", null)]
+ public void BuildTargetResolver_RejectsInconsistentMetadata(
+ string source,
+ string? packageUri,
+ string? sha256)
+ {
+ var metadata = new Dictionary
+ {
+ [GatewayPackageBuildTargetResolver.SourceMetadataKey] = source,
+ [GatewayPackageBuildTargetResolver.ExpectedVersionMetadataKey] = "2026.7.22"
+ };
+ if (packageUri is not null)
+ metadata[GatewayPackageBuildTargetResolver.PackageUriMetadataKey] = packageUri;
+ if (sha256 is not null)
+ metadata[GatewayPackageBuildTargetResolver.Sha256MetadataKey] = sha256;
+
+ Assert.Throws(() =>
+ GatewayPackageBuildTargetResolver.Resolve(metadata));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("latest")]
+ [InlineData("v2026.7.22")]
+ [InlineData("2026.07.22")]
+ public void Official_RejectsNonExactVersion(string version)
+ {
+ Assert.Throws(() => GatewayPackageTarget.Official(version));
+ }
+
+ [Fact]
+ public void Composed_NormalizesImmutableCredentialFreeTarget()
+ {
+ var target = GatewayPackageTarget.Composed(
+ "2026.7.22+companion.2",
+ new Uri("https://packages.example.test/openclaw-composed.tgz"),
+ Digest.ToUpperInvariant());
+
+ Assert.Equal(GatewayPackageSource.Composed, target.Source);
+ Assert.Equal("2026.7.22+companion.2", target.ExpectedVersion);
+ Assert.Equal("https://packages.example.test/openclaw-composed.tgz", target.PackageUri!.AbsoluteUri);
+ Assert.Equal(Digest, target.Sha256);
+ Assert.All(
+ typeof(GatewayPackageTarget).GetProperties(),
+ property => Assert.False(property.CanWrite));
+ }
+
+ [Theory]
+ [InlineData("https://user:secret@packages.example.test/openclaw.tgz")]
+ [InlineData("https://packages.example.test/openclaw.tgz?token=secret")]
+ [InlineData("https://packages.example.test/openclaw.zip")]
+ [InlineData("file:///tmp/openclaw.tgz")]
+ public void Composed_RejectsNonPublicOrNonPackageUri(string uri)
+ {
+ Assert.Throws(() =>
+ GatewayPackageTarget.Composed("2026.7.22", new Uri(uri), Digest));
+ }
+
+ [Fact]
+ public void VerifiedInstallerCommand_RejectsHostlessHttpPackageUri()
+ {
+ Assert.Throws(() =>
+ GatewayPackageInstallCommandBuilder.Build(
+ GatewayPackageInstallCommandBuilder.DefaultInstallUrl,
+ "https:/packages/openclaw.tgz",
+ Digest));
+ }
+
+ [Fact]
+ public void RoutePolicy_PreRoutesLegacyAndUnprovenSources()
+ {
+ var target = GatewayPackageTarget.Official("2026.7.22");
+ var policy = new GatewayPackageUpdateRoutePolicy(
+ ["2026.6.11", "2026.7.2-beta.3", "2026.7.2", "2026.8.1"]);
+
+ foreach (var legacyVersion in new[] { "2026.6.11", "2026.7.2-beta.3", "2026.7.2" })
+ {
+ Assert.Equal(
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ policy.Select(legacyVersion, target));
+ }
+ Assert.Equal(
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ policy.Select("2026.7.21", target));
+ Assert.Equal(
+ GatewayPackageUpdateRoute.CoreTransaction,
+ policy.Select("2026.8.1", target));
+ }
+
+ [Fact]
+ public void RoutePolicy_AlwaysRoutesComposedPackageThroughVerifiedInstaller()
+ {
+ var target = GatewayPackageTarget.Composed(
+ "2026.8.2",
+ new Uri("https://packages.example.test/openclaw-composed.tgz"),
+ Digest);
+ var policy = new GatewayPackageUpdateRoutePolicy(["2026.8.1"]);
+
+ Assert.Equal(
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ policy.Select("2026.8.1", target));
+ }
+
+ [Fact]
+ public void OfficialInstallerCommand_PinsExactVersionOverTls()
+ {
+ var target = GatewayPackageTarget.Official("2026.8.2");
+
+ var command = GatewayPackageInstallCommandBuilder.Build(
+ GatewayPackageInstallCommandBuilder.DefaultInstallUrl,
+ target);
+
+ Assert.Contains("curl -fsSL --proto '=https' --tlsv1.2", command, StringComparison.Ordinal);
+ Assert.Contains("bash -s -- --version '2026.8.2'", command, StringComparison.Ordinal);
+ Assert.DoesNotContain("sha256sum", command, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void VerifiedInstallerCommand_ChecksDigestBeforeInstaller()
+ {
+ var target = GatewayPackageTarget.Composed(
+ "2026.8.2",
+ new Uri("https://packages.example.test/openclaw-composed.tgz"),
+ Digest);
+
+ var command = GatewayPackageInstallCommandBuilder.Build(
+ GatewayPackageInstallCommandBuilder.DefaultInstallUrl,
+ target);
+
+ var digestCheck = command.IndexOf("sha256sum --check --strict -", StringComparison.Ordinal);
+ var installer = command.IndexOf(
+ "bash \"$installer_path\" --version \"$package_path\"",
+ StringComparison.Ordinal);
+ Assert.True(digestCheck >= 0 && digestCheck < installer);
+ }
+}
diff --git a/tests/OpenClaw.Connection.Tests/GatewayVersionAlignmentCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/GatewayVersionAlignmentCoordinatorTests.cs
new file mode 100644
index 000000000..d27c929c9
--- /dev/null
+++ b/tests/OpenClaw.Connection.Tests/GatewayVersionAlignmentCoordinatorTests.cs
@@ -0,0 +1,4066 @@
+using System.Collections.Concurrent;
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Nodes;
+
+namespace OpenClaw.Connection.Tests;
+
+public sealed class GatewayVersionAlignmentCoordinatorTests : IDisposable
+{
+ private const string RequiredVersion = "2026.7.22+companion.2";
+ private const string PreviousVersion = "2026.7.21+companion.1";
+ private const string LegacyVersion = "2026.7.2-beta.3";
+ private const string MachineId = "0123456789abcdef0123456789abcdef";
+ private const string NativeBackupSha256 = "66840dda154e8a113c31dd0ad32f7f3a366a80e8136979d8f5a101d3d29d6f72";
+ private const long NativeBackupSizeBytes = 8;
+ private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), $"openclaw-rollback-tests-{Guid.NewGuid():N}");
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("v2026.7.22")]
+ [InlineData("latest")]
+ [InlineData("2026.07.22")]
+ [InlineData("2026.7")]
+ [InlineData("2026.7.22-01")]
+ [InlineData("2026.7.22-alpha.01")]
+ public void Constructor_RejectsNonExactRequiredVersion(string version)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ Assert.Throws(() => new GatewayVersionAlignmentCoordinator(runner, version, manager));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_RequiresProvenOwnedWslPlan()
+ {
+ var runner = new FakeWslCommandRunner();
+ var coordinator = CreateCoordinator(runner);
+ var plans = new[]
+ {
+ GatewayHostAccessPlan.None("gateway"),
+ EligiblePlan() with { TerminalTarget = GatewayTerminalTarget.Ssh },
+ EligiblePlan() with { CanControlWslGateway = false },
+ EligiblePlan() with { DistroName = "OtherDistro" },
+ EligiblePlan() with { GatewayId = null }
+ };
+
+ foreach (var plan in plans)
+ Assert.Equal(GatewayVersionAlignmentState.Ineligible, (await coordinator.UpdateAsync(plan)).State);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_RejectsControllableNonOwnedDistroWithoutProbe()
+ {
+ var runner = new FakeWslCommandRunner();
+ var result = await CreateCoordinator(runner).ProbeAsync(
+ EligiblePlan() with { DistroName = "OtherDistro" });
+
+ Assert.Equal(GatewayVersionAlignmentState.Ineligible, result.State);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_TreatsDifferentBuildMetadataAsExactMismatch()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.22+companion.1"));
+
+ var result = await CreateCoordinator(runner).ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Mismatch, result.State);
+ Assert.Equal("2026.7.22+companion.1", result.InstalledVersion);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_TreatsHigherOrderedCompanionBuildAsNewer()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.22+companion.3"));
+
+ var result = await CreateCoordinator(runner).ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.NewerThanRequired, result.State);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_FailsClosedWhenDifferentBuildMetadataCannotBeOrdered()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.22+vendor.3"));
+
+ var result = await CreateCoordinator(runner).ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VersionOrderUnknown, result.State);
+ Assert.NotNull(result.FailureSummary);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_FailsClosedWhenSignedBuildIdentifierLooksNumeric()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.22+companion.-1"));
+
+ var result = await CreateCoordinator(runner).ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VersionOrderUnknown, result.State);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_RejectsNumericPrereleaseIdentifierWithLeadingZero()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.21-01"));
+
+ var result = await CreateCoordinator(runner).ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.ProbeFailed, result.State);
+ Assert.Contains("unrecognized version", result.FailureSummary, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_ValidatesLaterMetadataNamespaceBeforeUsingEarlierNumericOrder()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.22+2.other"));
+ var manager = CreateManager(runner);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, "2026.7.22+1.vendor", manager);
+
+ var result = await coordinator.ProbeAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VersionOrderUnknown, result.State);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_AlignsNewerUnauditedVersionThroughVerifiedInstallerWithoutPreSync()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.23"));
+ EnqueueCreateAttestation(runner, "2026.7.23");
+ EnqueuePendingAttestation(runner, "2026.7.23");
+ EnqueuePendingAttestation(runner, "2026.7.23");
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var synchronizations = 0;
+ var coordinator = CreateCoordinator(
+ runner,
+ (_, _) =>
+ {
+ synchronizations++;
+ Assert.Contains(
+ runner.Calls,
+ call => call.Kind == "distro" &&
+ IsVerifiedInstallerCommand(
+ call.Arguments,
+ GatewayPackageTarget.Official(RequiredVersion)));
+ return Task.CompletedTask;
+ });
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Equal(1, synchronizations);
+ Assert.Single(
+ runner.Calls,
+ call => call.Kind == "distro" &&
+ IsVerifiedInstallerCommand(
+ call.Arguments,
+ GatewayPackageTarget.Official(RequiredVersion)));
+ Assert.Equal(
+ GatewayRollbackPointPhase.PostUpdateHealthy,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_CreatesOfflineVerifiedPointThenUpdatesAndCleansOnlyAfterHealth()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var synchronizations = 0;
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = CreateCoordinator(
+ runner,
+ (_, _) => { synchronizations++; return Task.CompletedTask; },
+ updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Equal(2, synchronizations);
+ Assert.NotNull(result.RollbackPointId);
+ var point = Assert.Single(coordinator.ListRollbackPoints());
+ Assert.Equal(GatewayRollbackPointPhase.PostUpdateHealthy, point.Phase);
+ Assert.True(point.RestoreEligible);
+ Assert.True(point.ApproximateSizeBytes > 8);
+ Assert.Collection(
+ runner.Calls,
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("terminate", call.Kind),
+ call => Assert.Equal(["--export", "OpenClawGateway", call.Arguments[2], "--vhd"], call.Arguments),
+ call => AssertDistro(call, ProbeCommand()),
+ call => AssertDistro(call, IdentityCommand()),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, ProbeCommand()),
+ call => Assert.Equal("registrations", call.Kind),
+ call => Assert.Equal("get-configuration", call.Kind),
+ call => AssertDistro(call, IdentityCommand()),
+ call => AssertDistro(call, DefaultUserCommand()),
+ call => AssertDistro(call, ProbeCommand()));
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "unregister");
+ Assert.Collection(
+ updateRpc.Calls,
+ call =>
+ {
+ Assert.Equal("update.run", call.Method);
+ var target = call.Parameters.GetProperty("target");
+ Assert.Equal("openclaw", target.GetProperty("package").GetString());
+ Assert.Equal(RequiredVersion, target.GetProperty("version").GetString());
+ Assert.Equal("external", call.Parameters.GetProperty("confirmationTier").GetString());
+ Assert.Equal(
+ $"windows-companion-gateway-update-{result.RollbackPointId}",
+ call.RequestId);
+ Assert.False(call.Parameters.TryGetProperty("requestId", out _));
+ },
+ call =>
+ {
+ Assert.Equal("update.complete", call.Method);
+ Assert.Equal("healthy", call.Parameters.GetProperty("outcome").GetString());
+ Assert.Equal(RequiredVersion, call.Parameters.GetProperty("observedVersion").GetString());
+ Assert.StartsWith("transaction-", call.Parameters.GetProperty("transactionId").GetString());
+ Assert.Equal(updateRpc.Calls[0].RequestId, call.Parameters.GetProperty("requestId").GetString());
+ });
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FailsClosedBeforePrivilegedRpcWhenConnectedGatewayIdentityDiffers()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = CreateCoordinator(
+ runner,
+ updateRpc: updateRpc,
+ connectedGatewayId: () => "different-gateway");
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("active Gateway identity changed", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(updateRpc.Calls);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Theory]
+ [InlineData("2026.6.11")]
+ [InlineData("2026.7.2-beta.3")]
+ public async Task UpdateAsync_LegacySourceDefaultsToFullVhdAndVerifiedInstallerWithoutUpdateRun(
+ string firstAdoptionVersion)
+ {
+ const string completeArray = """["system.run","system.which","camera.snap"]""";
+ var requiresPolicyMigration =
+ GatewayNodeCommandPolicyConfig.ResolveAllowKey(firstAdoptionVersion) !=
+ GatewayNodeCommandPolicyConfig.ResolveAllowKey(RequiredVersion);
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(firstAdoptionVersion));
+ if (requiresPolicyMigration)
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueueCreateAttestation(runner, firstAdoptionVersion);
+ EnqueuePendingAttestation(runner, firstAdoptionVersion);
+ if (requiresPolicyMigration)
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueuePendingAttestation(runner, firstAdoptionVersion);
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ if (requiresPolicyMigration)
+ {
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(completeArray));
+ runner.EnqueueDistro(Ok());
+ }
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ CreateManager(runner),
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy(
+ [firstAdoptionVersion, PreviousVersion]));
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Single(runner.Calls, call =>
+ call.Kind == "distro" &&
+ IsVerifiedInstallerCommand(
+ call.Arguments,
+ GatewayPackageTarget.Official(RequiredVersion)));
+ Assert.Empty(updateRpc.Calls);
+ Assert.Equal(
+ GatewayRollbackPointPhase.PostUpdateHealthy,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Equal(
+ GatewayUpdateProtectionMode.FullVhd,
+ Assert.Single(ReadOwnedManifests()).ProtectionMode);
+ }
+
+ [Fact]
+ public void BuildSetNodeCommandAllow_UsesCanonicalPosixQuoting()
+ {
+ const string json = """["system.run","owner's.command"]""";
+
+ var command = GatewayVersionAlignmentCommandBuilder.BuildSetNodeCommandAllow(
+ RequiredVersion,
+ json);
+
+ Assert.Equal(
+ ConfigCommand(
+ "set gateway.nodes.commands.allow '[\"system.run\",\"owner'\\''s.command\"]'"),
+ command);
+ }
+
+ [Theory]
+ [InlineData("update service unavailable")]
+ [InlineData("missing scope: operator.admin")]
+ [InlineData("invalid update.run params: at root: unexpected property 'target'")]
+ [InlineData("invalid update.run params: at root: unexpected property 'confirmationTier'; at root: unexpected property 'target'")]
+ public async Task UpdateAsync_DoesNotUseVanillaUpdaterForOtherUpdateRunFailures(string failure)
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new InvalidOperationException(failure)));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("InvalidOperationException", result.FailureSummary, StringComparison.Ordinal);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(UpdateCommand(RequiredVersion)));
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DoesNotUseVanillaUpdaterForUpdateRunTimeout()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new TimeoutException("Timed out waiting for update.run response")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("TimeoutException", result.FailureSummary, StringComparison.Ordinal);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(UpdateCommand(RequiredVersion)));
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RestartedCoordinatorBlocksArmedExternalInstallerReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ var target = GatewayPackageTarget.Official(RequiredVersion);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ target,
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var restartedManager = CreateManager(runner);
+ var restartedCoordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ target,
+ restartedManager,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway");
+
+ var blocked = await restartedCoordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, blocked.State);
+ Assert.Contains("second non-idempotent", blocked.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(updateRpc.Calls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ IsVerifiedInstallerCommand(call.Arguments, target));
+ Assert.Equal(
+ GatewayUpdateDispatchState.Prepared,
+ Assert.Single(ReadOwnedManifests()).UpdateDispatchState);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RestartedCoordinatorRejectsDifferentArmedPackageTarget()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ var composedTarget = GatewayPackageTarget.Composed(
+ RequiredVersion,
+ new Uri("https://example.invalid/openclaw-composed.tgz"),
+ new string('a', 64));
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ composedTarget,
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var restartedCoordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ CreateManager(runner),
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway");
+
+ var blocked = await restartedCoordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, blocked.State);
+ Assert.Contains("provenance", blocked.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(updateRpc.Calls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ IsVerifiedInstallerCommand(
+ call.Arguments,
+ GatewayPackageTarget.Official(RequiredVersion)));
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayPackageSource.Composed, receipt.UpdateTargetSource);
+ Assert.Equal(composedTarget.PackageUri!.AbsoluteUri, receipt.UpdateTargetPackageUri);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_CancellationDuringGatewayRpcPreservesReceiptAndReleasesGate()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var requestStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue(async (_, _, _, _, cancellationToken) =>
+ {
+ Assert.True(cancellationToken.CanBeCanceled);
+ requestStarted.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return default;
+ });
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+ using var cancellation = new CancellationTokenSource();
+
+ var updateTask = coordinator.UpdateAsync(EligiblePlan(), cancellation.Token);
+ await requestStarted.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => updateTask);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var probe = await coordinator.ProbeAsync(EligiblePlan());
+ Assert.NotEqual(GatewayVersionAlignmentState.Busy, probe.State);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_MigratesCompleteLegacyAllowlistToCurrentPath()
+ {
+ const string completeArray = """["system.run","system.which","camera.snap"]""";
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(LegacyVersion));
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueueCreateAttestation(runner, LegacyVersion);
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(completeArray));
+ runner.EnqueueDistro(Ok());
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Equal(completeArray, Assert.Single(ReadOwnedManifests()).NodeCommandAllowSnapshotJson);
+ Assert.Contains(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("get gateway.nodes.allowCommands --json")));
+ Assert.Contains(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("unset gateway.nodes.allowCommands")));
+ Assert.Contains(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand(
+ "set gateway.nodes.commands.allow '[\"system.run\",\"system.which\",\"camera.snap\"]'")));
+ var setIndex = runner.Calls.FindIndex(call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand(
+ "set gateway.nodes.commands.allow '[\"system.run\",\"system.which\",\"camera.snap\"]'")));
+ var verifyIndex = runner.Calls.FindIndex(call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("get gateway.nodes.commands.allow --json")));
+ var unsetIndex = runner.Calls.FindIndex(call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("unset gateway.nodes.allowCommands")));
+ Assert.True(setIndex >= 0 && verifyIndex > setIndex && unsetIndex > verifyIndex);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PreservesReceiptWhenCoreTransactionEnvelopeIsInvalid()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = "transaction-without-deadline"
+ })));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("confirmDeadline", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RejectsAlreadyExpiredCoreConfirmationDeadline()
+ {
+ var now = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, requestId, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = $"transaction-{requestId}",
+ confirmDeadline = now.ToString("O")
+ })));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc, clock: () => now);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("confirmDeadline", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RechecksDeadlineAfterPersistingCompletionAndBeforeDispatch()
+ {
+ var now = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var deadline = now.AddSeconds(1);
+ var clockValues = new Queue([now, now, deadline]);
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, requestId, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = $"transaction-{requestId}",
+ confirmDeadline = deadline.ToString("O")
+ })));
+ var coordinator = CreateCoordinator(
+ runner,
+ updateRpc: updateRpc,
+ clock: () => clockValues.Count > 0 ? clockValues.Dequeue() : deadline);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("expired before", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Single(updateRpc.Calls);
+ Assert.Equal("update.run", updateRpc.Calls[0].Method);
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateCompletionState.Prepared, receipt.UpdateCompletionState);
+ Assert.Equal("healthy", receipt.UpdateCompletionOutcome);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BoundsCompletionRpcTimeoutToRemainingDeadline()
+ {
+ var now = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var deadline = now.AddMilliseconds(500);
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, requestId, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = $"transaction-{requestId}",
+ confirmDeadline = deadline.ToString("O")
+ })));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc, clock: () => now);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ var completion = Assert.Single(updateRpc.Calls, call => call.Method == "update.complete");
+ Assert.InRange(completion.TimeoutMs, 1, 500);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PreservesReceiptWhenHealthyCoreCompletionIsRejected()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, requestId, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = $"transaction-{requestId}",
+ confirmDeadline = "2030-01-01T00:00:00Z"
+ })));
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new InvalidOperationException("completion rejected")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("did not accept healthy completion", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Equal(
+ ["update.run", "update.complete"],
+ updateRpc.Calls.Select(call => call.Method).ToArray());
+ Assert.Equal("healthy", updateRpc.Calls[1].Parameters.GetProperty("outcome").GetString());
+ Assert.Equal(RequiredVersion, updateRpc.Calls[1].Parameters.GetProperty("observedVersion").GetString());
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateCompletionState.Ambiguous, receipt.UpdateCompletionState);
+ Assert.Equal(updateRpc.Calls[1].RequestId, receipt.UpdateCompletionRequestId);
+
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var resumed = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, resumed.State);
+ Assert.Equal(2, updateRpc.Calls.Count);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DoesNotCompleteAcceptedCoreTransactionAfterDeadlineExpires()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ var target = GatewayPackageTarget.Official(RequiredVersion);
+ var requestId = $"windows-companion-gateway-update-{created.Point!.Id}";
+ manager.ArmUpdateDispatch(
+ created.Point.Id,
+ target,
+ GatewayPackageUpdateRoute.CoreTransaction,
+ requestId);
+ manager.RecordCoreUpdateAccepted(
+ created.Point.Id,
+ $"transaction-{requestId}",
+ new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero));
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ target,
+ manager,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => new DateTimeOffset(2026, 7, 25, 12, 0, 1, TimeSpan.Zero));
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("deadline", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(updateRpc.Calls);
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Null(receipt.UpdateCompletionState);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FailedCompletionOmitsObservedVersionWhenProbeIsUnavailable()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(new WslCommandResult(7, "", "probe unavailable"));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ var completion = Assert.Single(updateRpc.Calls, call => call.Method == "update.complete");
+ Assert.Equal("failed", completion.Parameters.GetProperty("outcome").GetString());
+ Assert.False(completion.Parameters.TryGetProperty("observedVersion", out _));
+ Assert.Equal(
+ updateRpc.Calls.Single(call => call.Method == "update.run").RequestId,
+ completion.Parameters.GetProperty("requestId").GetString());
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksWhenCompleteAllowlistCannotBeReadAtFinalBoundary()
+ {
+ const string capturedArray = """["system.run","system.which","camera.snap"]""";
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(LegacyVersion));
+ runner.EnqueueDistro(Ok(capturedArray));
+ EnqueueCreateAttestation(runner, LegacyVersion);
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(new WslCommandResult(1, "", "policy read failed"));
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ var point = Assert.Single(coordinator.ListRollbackPoints());
+ Assert.Equal(GatewayRollbackPointPhase.Verified, point.Phase);
+ Assert.Equal(GatewayRollbackPointVerificationStatus.Verified, point.VerificationStatus);
+ Assert.True(point.RestoreEligible);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksPolicyDriftAfterReceiptTransitionBeforeUpdater()
+ {
+ const string capturedArray = """["system.run","system.which","camera.snap"]""";
+ const string changedArray = """["system.run","system.which"]""";
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(LegacyVersion));
+ runner.EnqueueDistro(Ok(capturedArray));
+ EnqueueCreateAttestation(runner, LegacyVersion);
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(Ok(changedArray));
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ var point = Assert.Single(coordinator.ListRollbackPoints());
+ Assert.Equal(GatewayRollbackPointPhase.Verified, point.Phase);
+ Assert.True(point.RestoreEligible);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PreservesReceiptWhenCompleteAllowlistMigrationFails()
+ {
+ const string completeArray = """["system.run","system.which","camera.snap"]""";
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(LegacyVersion));
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueueCreateAttestation(runner, LegacyVersion);
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ runner.EnqueueDistro(Ok());
+ runner.EnqueueDistro(new WslCommandResult(1, "", "set failed"));
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Contains("preserved", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("unset gateway.nodes.allowCommands")));
+ }
+
+ [Fact]
+ public async Task VerifyAsync_InvalidatesCorruptPointButPreservesPendingReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(created.Point!.Id);
+ var rollbackPath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ created.Point.Id,
+ "rollback.vhdx");
+ File.WriteAllBytes(rollbackPath, [1, 2, 3]);
+
+ Assert.False(await manager.VerifyAsync(created.Point.Id));
+
+ var invalid = Assert.Single(manager.List());
+ Assert.Equal(GatewayRollbackPointVerificationStatus.Failed, invalid.VerificationStatus);
+ Assert.False(invalid.RestoreEligible);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, invalid.Phase);
+ Assert.Equal(created.Point.Id, Assert.Single(manager.FindPendingUpdates()).Id);
+ Assert.False(new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager)
+ .HasVerifiedPendingUpdate());
+ }
+
+ [ReparsePointFact]
+ public async Task CreateVerifiedAsync_RejectsReparseBackedPointsRootBeforeExport()
+ {
+ var redirected = Path.Combine(_tempRoot, "redirected-points");
+ var pointsParent = Path.Combine(_tempRoot, "gateway-rollback-points");
+ var pointsRoot = Path.Combine(pointsParent, "OpenClawGateway");
+ Directory.CreateDirectory(redirected);
+ Directory.CreateDirectory(pointsParent);
+ Directory.CreateSymbolicLink(pointsRoot, redirected);
+
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, result.State);
+ Assert.Equal("rollback_points_reparse_boundary", result.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ Assert.Empty(Directory.GetFileSystemEntries(redirected));
+ }
+
+ [Fact]
+ public async Task CreateVerifiedAsync_RejectsWrongRegisteredBasePathBeforeLifecycleMutation()
+ {
+ var runner = new FakeWslCommandRunner
+ {
+ RegisteredBasePath = Path.Combine(_tempRoot, "unexpected-registration-root")
+ };
+ var result = await CreateManager(runner).CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, result.State);
+ Assert.Equal("registration_base_path_mismatch", result.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind is "terminate" or "direct");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FailsClosedWhenPreUpdateHealthFails()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var coordinator = CreateCoordinator(
+ runner,
+ (_, _) => Task.FromException(new InvalidOperationException("private detail")));
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.PreUpdateHealthFailed, result.State);
+ Assert.Single(runner.Calls);
+ Assert.DoesNotContain("private detail", result.FailureSummary);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FailsClosedWhenVhdExportCannotBeVerifiedAndRestartsExistingRuntime()
+ {
+ var runner = new FakeWslCommandRunner { ExportResult = new WslCommandResult(9, "", "secret") };
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var synchronizations = 0;
+ var coordinator = CreateCoordinator(runner, (_, _) => { synchronizations++; return Task.CompletedTask; });
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RollbackPointFailed, result.State);
+ Assert.Equal(2, synchronizations);
+ Assert.DoesNotContain("private detail", result.FailureSummary);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "distro" && call.Arguments.Contains("update --tag"));
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PreservesVerifiedRecoveryPointWhenUpdateFails()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new InvalidOperationException("private detail")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.NotNull(result.RollbackPointId);
+ var point = Assert.Single(coordinator.ListRollbackPoints());
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, point.Phase);
+ Assert.True(point.RestoreEligible);
+ Assert.DoesNotContain("secret", result.FailureSummary);
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksWhenLiveIdentityChangesImmediatelyBeforeNewPackageMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ Assert.Equal(GatewayRollbackPointPhase.Verified, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksWhenLiveIdentityChangesAfterDispatchReceiptIsArmed()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("after update dispatch was durably armed", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(updateRpc.Calls);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Equal(
+ GatewayUpdateDispatchState.Prepared,
+ Assert.Single(ReadOwnedManifests()).UpdateDispatchState);
+ }
+
+ [Theory]
+ [InlineData("2026.7.21+companion.9")]
+ [InlineData("2026.7.21-rc.1+companion.1")]
+ [InlineData("2026.7.23+companion.1")]
+ public async Task UpdateAsync_BlocksExactVersionDriftBeforeUpdaterWithoutDowngrade(string observedVersion)
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, observedVersion);
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ Assert.Equal(GatewayRollbackPointPhase.Verified, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksFinalizationWhenExactVersionChangesAfterPostUpdateProbe()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, "2026.7.22+companion.3");
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.True(coordinator.HasVerifiedPendingUpdate());
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksHealthyMarkAndCleanupWhenReceiptDriftsDuringHealthyCompletion()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, requestId, _, _, _) => Task.FromResult(JsonSerializer.SerializeToElement(new
+ {
+ transactionId = $"transaction-{requestId}",
+ confirmDeadline = "2030-01-01T00:00:00Z"
+ })));
+ updateRpc.Enqueue((_, _, method, _, _) =>
+ {
+ Assert.Equal("update.complete", method);
+ EnqueuePendingAttestation(runner, "2026.7.22+companion.3");
+ return Task.FromResult(JsonSerializer.SerializeToElement(new { ok = true }));
+ });
+ var retentionPolicyCalls = 0;
+ var manager = CreateManager(runner);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ retentionPolicy: () =>
+ {
+ retentionPolicyCalls++;
+ return GatewayRollbackRetentionPolicy.Default;
+ },
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.FullVhd);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(manager.List()).Phase);
+ Assert.True(coordinator.HasVerifiedPendingUpdate());
+ Assert.Equal(0, retentionPolicyCalls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksHealthyCompletionWhenIdentityChangesAfterCompletionIsArmed()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("after healthy completion was durably armed", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateCompletionState.Prepared, receipt.UpdateCompletionState);
+ Assert.Equal("healthy", receipt.UpdateCompletionOutcome);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DoesNotReplaceAcceptedFailedCompletionWithHealthyOutcome()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var syncCalls = 0;
+ var coordinator = CreateCoordinator(
+ runner,
+ (_, _) =>
+ {
+ syncCalls++;
+ return syncCalls == 2
+ ? Task.FromException(new InvalidOperationException("transient"))
+ : Task.CompletedTask;
+ },
+ updateRpc);
+
+ var failedHealth = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, failedHealth.State);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.True(coordinator.HasVerifiedPendingUpdate());
+
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var resumed = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, resumed.State);
+ Assert.True(coordinator.HasVerifiedPendingUpdate());
+ Assert.Equal(3, syncCalls);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ var runCalls = updateRpc.Calls.Where(call => call.Method == "update.run").ToArray();
+ Assert.Single(runCalls);
+ var completionCalls = updateRpc.Calls.Where(call => call.Method == "update.complete").ToArray();
+ var completion = Assert.Single(completionCalls);
+ Assert.Equal(runCalls[0].RequestId, completion.Parameters.GetProperty("requestId").GetString());
+ Assert.Equal(
+ $"transaction-{runCalls[0].RequestId}",
+ completion.Parameters.GetProperty("transactionId").GetString());
+ Assert.Equal("failed", completionCalls[0].Parameters.GetProperty("outcome").GetString());
+ Assert.Equal(RequiredVersion, completionCalls[0].Parameters.GetProperty("observedVersion").GetString());
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateCompletionState.Accepted, receipt.UpdateCompletionState);
+ Assert.Equal("failed", receipt.UpdateCompletionOutcome);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FreshCoordinatorDoesNotRedispatchAcceptedFailedCompletion()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ var firstRpc = new FakeGatewayUpdateRpc();
+ var synchronizationCalls = 0;
+ var firstCoordinator = CreateCoordinator(
+ runner,
+ (_, _) =>
+ {
+ synchronizationCalls++;
+ return synchronizationCalls == 2
+ ? Task.FromException(new InvalidOperationException("transient"))
+ : Task.CompletedTask;
+ },
+ firstRpc);
+
+ var interrupted = await firstCoordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, interrupted.State);
+ var originalRun = Assert.Single(firstRpc.Calls, call => call.Method == "update.run");
+ var persisted = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateDispatchState.Accepted, persisted.UpdateDispatchState);
+ Assert.Equal(originalRun.RequestId, persisted.UpdateRequestId);
+ Assert.Equal($"transaction-{originalRun.RequestId}", persisted.UpdateTransactionId);
+
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var restartedRpc = new FakeGatewayUpdateRpc();
+ var restartedCoordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ CreateManager(runner),
+ gatewayRequestAsync: restartedRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]));
+
+ var resumed = await restartedCoordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, resumed.State);
+ Assert.DoesNotContain(restartedRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(restartedRpc.Calls, call => call.Method == "update.complete");
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateCompletionState.Accepted, receipt.UpdateCompletionState);
+ Assert.Equal("failed", receipt.UpdateCompletionOutcome);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksAlignedPendingReceiptWithoutLiveDispatchProvenance()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(created.Point!.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ RequiredVersion,
+ manager,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway");
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("predates durable package-target", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(updateRpc.Calls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(UpdateCommand(RequiredVersion)));
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_LostUpdateRunResponseDoesNotRedispatchWhenReceiptLaterAppearsAligned()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new TimeoutException("response lost after dispatch")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var lostResponse = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, lostResponse.State);
+ Assert.Equal(RequiredVersion, lostResponse.InstalledVersion);
+
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var resumed = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, resumed.State);
+ Assert.Contains("no accepted transaction provenance", resumed.FailureSummary, StringComparison.Ordinal);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ Assert.DoesNotContain(updateRpc.Calls, call => call.Method == "update.complete");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksAlignedPendingFinalizationWhenLiveIdentityChanged()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(created.Point!.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager, (_, _) => { synchronizations++; return Task.CompletedTask; });
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("predates durable package-target", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(0, synchronizations);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DoesNotRedispatchAfterFailedMismatchedUpdate()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new InvalidOperationException("transient")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+
+ var failed = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, failed.State);
+
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var retried = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, retried.State);
+ Assert.Contains("second non-idempotent update.run was blocked", retried.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(failed.RollbackPointId, retried.RollbackPointId);
+ Assert.Single(runner.Calls, call => call.Kind == "direct" && call.Arguments.FirstOrDefault() == "--export");
+ var runCalls = updateRpc.Calls.Where(call => call.Method == "update.run").ToArray();
+ Assert.Single(runCalls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksPendingRedispatchWithoutRequiringBrokenRuntimePreHealth()
+ {
+ var runtimeBroken = false;
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) =>
+ {
+ runtimeBroken = true;
+ return Task.FromException(new InvalidOperationException("update failed"));
+ });
+ var coordinator = CreateCoordinator(
+ runner,
+ (_, _) =>
+ runtimeBroken
+ ? Task.FromException(new InvalidOperationException("runtime unavailable"))
+ : Task.CompletedTask,
+ updateRpc);
+
+ var failed = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, failed.State);
+
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var retried = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, retried.State);
+ Assert.Contains("second non-idempotent update.run was blocked", retried.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(failed.RollbackPointId, retried.RollbackPointId);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksPendingRetryWhenLiveDistroIdentityChanged()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) => Task.FromException(
+ new InvalidOperationException("transient")));
+ var coordinator = CreateCoordinator(runner, updateRpc: updateRpc);
+ var failed = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, failed.State);
+
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var blocked = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, blocked.State);
+ Assert.Contains("no longer matches the pending rollback receipt", blocked.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(failed.RollbackPointId, blocked.RollbackPointId);
+ Assert.Single(updateRpc.Calls, call => call.Method == "update.run");
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksPendingRetryWhenLiveVersionDiffersFromReceiptSource()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(created.Point!.Id);
+ runner.ClearCalls();
+ const string unrecordedVersion = "2026.7.20+companion.9";
+ runner.EnqueueDistro(Ok(unrecordedVersion));
+ EnqueuePendingAttestation(runner, unrecordedVersion);
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var blocked = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, blocked.State);
+ Assert.Contains("predates durable package-target", blocked.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(created.Point.Id, blocked.RollbackPointId);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksWhenPendingReceiptTargetsAnOlderCompanionVersion()
+ {
+ const string supersededTarget = "2026.7.22+companion.1";
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, supersededTarget);
+ manager.MarkUpdateInProgress(created.Point!.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var syncCalls = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager, (_, _) => { syncCalls++; return Task.CompletedTask; });
+
+ var probe = await coordinator.ProbeAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, probe.State);
+ Assert.Equal(created.Point.Id, probe.RollbackPointId);
+ Assert.Empty(runner.Calls);
+
+ var blocked = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, blocked.State);
+ Assert.Equal(created.Point.Id, blocked.RollbackPointId);
+ Assert.Equal(0, syncCalls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ }
+
+ [Theory]
+ [InlineData(GatewayRollbackPointPhase.RestoreStaged)]
+ [InlineData(GatewayRollbackPointPhase.UnregisterPending)]
+ [InlineData(GatewayRollbackPointPhase.DistroUnregistered)]
+ [InlineData(GatewayRollbackPointPhase.ImportPending)]
+ [InlineData(GatewayRollbackPointPhase.Imported)]
+ public async Task UpdateAsync_BlocksEveryUnresolvedRestorePhaseBeforeProbe(
+ GatewayRollbackPointPhase phase)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, phase);
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var probe = await coordinator.ProbeAsync(EligiblePlan());
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, probe.State);
+ Assert.Equal(point.Point.Id, probe.RollbackPointId);
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Equal(point.Point.Id, result.RollbackPointId);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_InstallerTimeoutPersistsAmbiguousDispatch()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("2026.7.23"));
+ EnqueueCreateAttestation(runner, "2026.7.23");
+ EnqueuePendingAttestation(runner, "2026.7.23");
+ EnqueuePendingAttestation(runner, "2026.7.23");
+ runner.EnqueueDistro(new WslCommandResult(
+ -1,
+ string.Empty,
+ "wsl.exe timed out",
+ TimedOut: true));
+ runner.EnqueueDistro(Ok("2026.7.23"));
+ var coordinator = CreateCoordinator(runner);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("state is ambiguous", result.FailureSummary, StringComparison.Ordinal);
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Ambiguous, receipt.UpdateDispatchState);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DefaultProtectionCreatesNativeBackupWithoutTerminateOrExport()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var manager = CreateManager(runner);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]));
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ var manifest = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayUpdateProtectionMode.NativeBackup, manifest.ProtectionMode);
+ Assert.False(manifest.RestoreEligible);
+ Assert.Equal(GatewayRollbackPointVerificationStatus.Verified, manifest.VerificationStatus);
+ var listedPoint = Assert.Single(manager.List());
+ Assert.Equal(GatewayUpdateProtectionMode.NativeBackup, listedPoint.ProtectionMode);
+ Assert.False(listedPoint.RestoreEligible);
+ Assert.True(listedPoint.ApproximateSizeBytes > 0);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ Assert.Contains(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.Count == 3 &&
+ call.Arguments[2].Contains(
+ "openclaw backup create --output ",
+ StringComparison.Ordinal) &&
+ call.Arguments[2].Contains(" --verify --json", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task NativeBackup_UsesDistroLocalStagingIndependentOfDefaultUserHome()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ var manager = CreateManager(runner);
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+
+ Assert.Equal(GatewayRollbackOperationState.Created, result.State);
+ var backupCall = Assert.Single(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.Count == 3 &&
+ call.Arguments[2].Contains("openclaw backup create", StringComparison.Ordinal));
+ Assert.Contains(
+ "archive='/tmp/openclaw-windows-companion-",
+ backupCall.Arguments[2],
+ StringComparison.Ordinal);
+ Assert.Contains("/openclaw-backup.tar.gz'", backupCall.Arguments[2], StringComparison.Ordinal);
+ Assert.Contains("mkdir -m 700 -- \"$stage\"", backupCall.Arguments[2], StringComparison.Ordinal);
+ Assert.DoesNotContain("mkdir -p", backupCall.Arguments[2], StringComparison.Ordinal);
+ Assert.DoesNotContain("archive='/home/", backupCall.Arguments[2], StringComparison.Ordinal);
+ Assert.DoesNotContain("$HOME/.local", backupCall.Arguments[2], StringComparison.Ordinal);
+ var copyCall = Assert.Single(runner.Calls, call => call.Kind == "copy");
+ Assert.StartsWith(
+ "/tmp/openclaw-windows-companion-",
+ copyCall.Arguments[0],
+ StringComparison.Ordinal);
+ Assert.EndsWith("/openclaw-backup.tar.gz", copyCall.Arguments[0], StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task NativeBackup_DefaultUserDriftFailsBeforeVerification()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("other-user\n1001"));
+ var manager = CreateManager(runner);
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("native_backup_state_attestation_changed", result.FailureCode);
+ Assert.Equal(
+ GatewayRollbackPointVerificationStatus.Failed,
+ Assert.Single(ReadOwnedManifests()).VerificationStatus);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DefaultNativeProtectionCompletesHealthyTransaction()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var synchronizations = 0;
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var manager = CreateManager(runner);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]));
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Equal(2, synchronizations);
+ var point = Assert.Single(manager.List());
+ Assert.Equal(GatewayUpdateProtectionMode.NativeBackup, point.ProtectionMode);
+ Assert.Equal(GatewayRollbackPointPhase.PostUpdateHealthy, point.Phase);
+ Assert.False(point.RestoreEligible);
+ Assert.True(point.ApproximateSizeBytes > 0);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ Assert.Collection(
+ updateRpc.Calls,
+ call => Assert.Equal("update.run", call.Method),
+ call => Assert.Equal("update.complete", call.Method));
+ }
+
+ [Theory]
+ [InlineData("2026.6.11", GatewayUpdateProtectionMode.FullVhd)]
+ [InlineData("2026.7.2-beta.3", GatewayUpdateProtectionMode.FullVhd)]
+ [InlineData("2026.7.2", GatewayUpdateProtectionMode.NativeBackup)]
+ [InlineData("2026.7.22+companion.1", GatewayUpdateProtectionMode.NativeBackup)]
+ public void ResolveProtectionMode_UsesFullVhdUntilNativeBackupCliIsAvailable(
+ string sourceVersion,
+ GatewayUpdateProtectionMode expected)
+ {
+ var runner = new FakeWslCommandRunner();
+ var coordinator = CreateCoordinator(
+ runner,
+ protectionMode: GatewayUpdateProtectionMode.NativeBackup);
+
+ Assert.Equal(expected, coordinator.ResolveProtectionMode(sourceVersion));
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_PreUpdateVersionHealthyClosesReceiptWithoutVhdMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ manager.MarkUpdateInProgress(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryResolved, result.State);
+ Assert.Equal(PreviousVersion, result.InstalledVersion);
+ Assert.Equal(1, synchronizations);
+ Assert.Equal(
+ GatewayRollbackPointPhase.RecoveryResolved,
+ Assert.Single(manager.List()).Phase);
+ Assert.Empty(manager.FindPendingUpdates());
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.Equal(0, runner.UnregisterCalls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" &&
+ (call.Arguments.Contains("--export") || call.Arguments.Contains("--import-in-place")));
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_AllowsReceiptFromReplacedGatewayRecord()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "previous-gateway-record",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryResolved, result.State);
+ Assert.Equal(
+ GatewayRollbackPointPhase.RecoveryResolved,
+ Assert.Single(manager.List()).Phase);
+ Assert.Empty(manager.FindPendingUpdates());
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_AcceptedCoreTransactionBlocksAfterGatewayReplacement()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "previous-gateway-record",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ const string requestId = "windows-companion-native-recovery";
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CoreTransaction,
+ requestId);
+ manager.RecordCoreUpdateAccepted(
+ created.Point.Id,
+ $"transaction-{requestId}",
+ new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "replacement-gateway-record",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan() with { GatewayId = "replacement-gateway-record" },
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("Gateway", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(updateRpc.Calls);
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Null(receipt.UpdateCompletionState);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, receipt.Phase);
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_CoreCompletionFailurePreservesPendingReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ const string requestId = "windows-companion-native-recovery-failure";
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CoreTransaction,
+ requestId);
+ manager.RecordCoreUpdateAccepted(
+ created.Point.Id,
+ $"transaction-{requestId}",
+ new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var updateRpc = new FakeGatewayUpdateRpc();
+ updateRpc.Enqueue((_, _, _, _, _) =>
+ Task.FromException(new InvalidOperationException("completion failed")));
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("Core did not accept failed completion", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List()).Phase);
+ Assert.Single(manager.FindPendingUpdates());
+ Assert.Equal(
+ GatewayUpdateCompletionState.Ambiguous,
+ Assert.Single(ReadOwnedManifests()).UpdateCompletionState);
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_PostSynchronizationDriftPreservesPendingReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("changed after synchronization", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List()).Phase);
+ Assert.Single(manager.FindPendingUpdates());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ResolveNativeRecovery_UncertainInstallerDispatchPreservesPendingReceipt(
+ bool ambiguous)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ if (ambiguous)
+ manager.MarkUpdateDispatchAmbiguous(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains(
+ ambiguous ? "Ambiguous" : "Prepared",
+ result.FailureSummary,
+ StringComparison.Ordinal);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List()).Phase);
+ Assert.Single(manager.FindPendingUpdates());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ResolveNativeRecovery_SettledNeverStartedInstallerDispatchIsCancelled(
+ bool ambiguous)
+ {
+ var armedAt = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, () => armedAt);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ if (ambiguous)
+ manager.MarkUpdateDispatchAmbiguous(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok());
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => armedAt.AddMinutes(3),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryResolved, result.State);
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayRollbackPointPhase.RecoveryResolved, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Cancelled, receipt.UpdateDispatchState);
+ Assert.Single(runner.Calls, call => call.Kind == "terminate");
+ Assert.Contains(
+ runner.Calls,
+ call => call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(
+ GatewayVersionAlignmentCommandBuilder.BuildInstallerNotStartedProbe(created.Point.Id)));
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_StartedInstallerPreservesPendingReceipt()
+ {
+ var armedAt = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, () => armedAt);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.EnqueueDistro(new WslCommandResult(1, string.Empty, string.Empty));
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => armedAt.AddMinutes(3),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("started before interruption", result.FailureSummary, StringComparison.Ordinal);
+ Assert.Equal(0, synchronizations);
+ Assert.Single(runner.Calls, call => call.Kind == "terminate");
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Prepared, receipt.UpdateDispatchState);
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_OwnershipDriftBlocksBeforeDistroTermination()
+ {
+ var armedAt = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, () => armedAt);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ runner.RegisteredBasePath = Path.Combine(_tempRoot, "wsl", "replacement");
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => armedAt.AddMinutes(3),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Contains("re-attested", result.FailureSummary, StringComparison.Ordinal);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Prepared, receipt.UpdateDispatchState);
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_MultiplePendingReceiptsBlocksBeforeMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var first = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "first-gateway-record",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var second = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "second-gateway-record",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ foreach (var point in new[] { first.Point!, second.Point! })
+ {
+ manager.ArmUpdateDispatch(
+ point.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ }
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ first.Point!.Id,
+ first.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Contains("ambiguous", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(runner.Calls);
+ Assert.All(ReadOwnedManifests(), receipt =>
+ {
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Prepared, receipt.UpdateDispatchState);
+ });
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_SettledUncertainInstallerAtTargetFinalizesAfterDistroStop()
+ {
+ var armedAt = new DateTimeOffset(2026, 7, 25, 12, 0, 0, TimeSpan.Zero);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, () => armedAt);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ EnqueuePendingAttestation(runner, RequiredVersion);
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ utcNow: () => armedAt.AddMinutes(3),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.Updated, result.State);
+ Assert.Equal(1, synchronizations);
+ Assert.Single(runner.Calls, call => call.Kind == "terminate");
+ Assert.DoesNotContain(
+ runner.Calls,
+ call => call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(
+ GatewayVersionAlignmentCommandBuilder.BuildInstallerNotStartedProbe(created.Point.Id)));
+ var receipt = Assert.Single(ReadOwnedManifests());
+ Assert.Equal(GatewayRollbackPointPhase.PostUpdateHealthy, receipt.Phase);
+ Assert.Equal(GatewayUpdateDispatchState.Accepted, receipt.UpdateDispatchState);
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_HistoricalInstalledTargetDoesNotClaimCurrentAlignment()
+ {
+ const string historicalTarget = "2026.7.22+companion.1";
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ historicalTarget,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(historicalTarget),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(historicalTarget));
+ EnqueuePendingAttestation(runner, historicalTarget);
+ EnqueuePendingAttestation(runner, historicalTarget);
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryResolved, result.State);
+ Assert.Equal(historicalTarget, result.InstalledVersion);
+ Assert.Equal(1, synchronizations);
+ Assert.Equal(
+ GatewayRollbackPointPhase.RecoveryResolved,
+ Assert.Single(manager.List()).Phase);
+ Assert.Empty(manager.FindPendingUpdates());
+ }
+
+ [Fact]
+ public async Task ResolveNativeRecovery_UnexpectedVersionPreservesPendingReceipt()
+ {
+ const string unexpectedVersion = "2026.7.20+companion.9";
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ manager.MarkUpdateInProgress(created.Point.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(unexpectedVersion));
+ var synchronizations = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ manager,
+ (_, _) =>
+ {
+ synchronizations++;
+ return Task.CompletedTask;
+ },
+ gatewayRequestAsync: new FakeGatewayUpdateRpc().SendAsync,
+ connectedGatewayId: () => "local-gateway",
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion]),
+ protectionModeResolver: () => GatewayUpdateProtectionMode.NativeBackup);
+
+ var result = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Equal(unexpectedVersion, result.InstalledVersion);
+ Assert.Equal(0, synchronizations);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List()).Phase);
+ Assert.Single(manager.FindPendingUpdates());
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(6)]
+ [InlineData(7)]
+ public async Task NativeBackup_InvalidReceiptOrExitFailsClosedBeforeMutation(int failureKind)
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.NativeBackupResponse = path => failureKind switch
+ {
+ 1 => Ok("{not-json"),
+ 2 => Ok(JsonSerializer.Serialize(new { archivePath = path, verified = false })),
+ 4 => Ok(JsonSerializer.Serialize(new
+ {
+ archivePath = path + ".other",
+ verified = true,
+ sha256 = NativeBackupSha256,
+ sizeBytes = NativeBackupSizeBytes
+ })),
+ 5 => Ok(JsonSerializer.Serialize(new
+ {
+ archivePath = path,
+ verified = true,
+ sha256 = NativeBackupSha256,
+ sizeBytes = NativeBackupSizeBytes
+ })),
+ 6 => Ok(JsonSerializer.Serialize(new
+ {
+ archivePath = path,
+ verified = true,
+ sha256 = new string('0', 64),
+ sizeBytes = NativeBackupSizeBytes
+ })),
+ 7 => Ok(JsonSerializer.Serialize(new
+ {
+ archivePath = path,
+ verified = true,
+ sha256 = NativeBackupSha256,
+ sizeBytes = NativeBackupSizeBytes + 1
+ })),
+ _ => new WslCommandResult(17, "", "backup failed")
+ };
+ if (failureKind == 5)
+ runner.NativeBackupTransferResult = new WslCommandResult(23, "", "transfer failed");
+ var manager = CreateManager(runner);
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal(
+ failureKind switch
+ {
+ 3 => "native_backup_command_failed",
+ 5 => "native_backup_transfer_failed",
+ 6 => "native_backup_hash_mismatch",
+ 7 => "native_backup_size_mismatch",
+ _ => "native_backup_receipt_invalid"
+ },
+ result.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ }
+
+ [Fact]
+ public async Task NativeBackup_CannotEnterCompanionVhdRestoreLifecycle()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ runner.ClearCalls();
+
+ var restored = await manager.RestoreExplicitAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ created.Point!.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, restored.State);
+ Assert.Equal("point_not_vhd_restore_eligible", restored.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.Equal(0, runner.UnregisterCalls);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--import-in-place"));
+ }
+
+ [Fact]
+ public void RetentionDefault_KeepsOnePreviousVersionAndSevenDays()
+ {
+ Assert.Equal(1, GatewayRollbackRetentionPolicy.Default.RetainPreviousVersions);
+ Assert.Equal(TimeSpan.FromDays(7), GatewayRollbackRetentionPolicy.Default.RetainYoungerThan);
+ }
+
+ [Fact]
+ public async Task FullVhd_PreflightsCapacityBeforeTerminateAndExport()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, availableFreeSpaceBytes: _ => 2L * 1024 * 1024 * 1024);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.FullVhd);
+
+ Assert.Equal(GatewayRollbackOperationState.Created, result.State);
+ Assert.Equal(GatewayUpdateProtectionMode.FullVhd, result.Point!.ProtectionMode);
+ var terminateIndex = runner.Calls.FindIndex(call => call.Kind == "terminate");
+ var exportIndex = runner.Calls.FindIndex(call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ var registrationIndex = runner.Calls.FindIndex(call => call.Kind == "registrations");
+ Assert.True(registrationIndex >= 0 && registrationIndex < terminateIndex);
+ Assert.True(terminateIndex >= 0 && terminateIndex < exportIndex);
+ }
+
+ [Fact]
+ public async Task FullVhd_InsufficientDiskFailsBeforeTerminate()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, availableFreeSpaceBytes: _ => 0);
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.FullVhd);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("vhd_export_insufficient_space", result.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call => call.Kind == "terminate");
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Contains("--export"));
+ }
+
+ [Theory]
+ [InlineData(GatewayRollbackPointPhase.RestoreStaged, true)]
+ [InlineData(GatewayRollbackPointPhase.UnregisterPending, true)]
+ [InlineData(GatewayRollbackPointPhase.DistroUnregistered, true)]
+ [InlineData(GatewayRollbackPointPhase.ImportPending, true)]
+ [InlineData(GatewayRollbackPointPhase.Imported, true)]
+ [InlineData(GatewayRollbackPointPhase.RestoreCancelled, false)]
+ [InlineData(GatewayRollbackPointPhase.RestoreHealthy, false)]
+ public async Task DispatchReceipt_RemainsOwnedAcrossRestorePhases(
+ GatewayRollbackPointPhase phase,
+ bool unresolved)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ manager.ArmUpdateDispatch(
+ point.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(point.Point.Id);
+ SetPointPhase(point.Point.Id, phase);
+
+ var restarted = CreateManager(runner);
+
+ Assert.False(restarted.HasUnreadableReceipt());
+ Assert.Equal(point.Point.Id, Assert.Single(restarted.List()).Id);
+ Assert.Empty(restarted.FindPendingUpdates());
+ if (unresolved)
+ Assert.Equal(point.Point.Id, Assert.Single(restarted.FindUnresolvedRestores()).Id);
+ else
+ Assert.Empty(restarted.FindUnresolvedRestores());
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksOwnedDistroRestoreAfterGatewayRecordIdChanges()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, GatewayRollbackPointPhase.ImportPending);
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Equal(point.Point.Id, result.RollbackPointId);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_BlocksPendingUpdateAfterGatewayRecordIdChanges()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(point.Point!.Id);
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var probe = await coordinator.ProbeAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, probe.State);
+ Assert.Equal(point.Point.Id, probe.RollbackPointId);
+ Assert.Empty(runner.Calls);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, result.State);
+ Assert.Equal(point.Point.Id, result.RollbackPointId);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "distro" && call.Arguments.Any(argument =>
+ argument.Contains("openclaw update", StringComparison.Ordinal)));
+ Assert.Single(manager.List());
+ }
+
+ [Fact]
+ public async Task RestoreAsync_AllowsExactUpdateInProgressPointAfterGatewayRecordIdChanges()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(point.Point!.Id);
+ runner.ClearCalls();
+ runner.ListResult = new WslCommandResult(5, "", "");
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "replacement-gateway-record", point.Point.Id, point.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.NotEqual(GatewayRollbackOperationState.OwnershipMismatch, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RejectsAmbiguousUpdateInProgressReceiptsWithoutMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var first = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "first-gateway-record", PreviousVersion, RequiredVersion);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var second = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "second-gateway-record", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(first.Point!.Id);
+ manager.MarkUpdateInProgress(second.Point!.Id);
+ runner.ClearCalls();
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "replacement-gateway-record", first.Point.Id, first.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.AmbiguousRecovery, result.State);
+ Assert.Empty(runner.Calls);
+ Assert.All(manager.List(), point =>
+ Assert.Equal(GatewayRollbackPointPhase.UpdateInProgress, point.Phase));
+
+ var probe = await new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager)
+ .ProbeAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, probe.State);
+ Assert.Contains("ambiguous", probe.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("{")]
+ [InlineData("{}")]
+ public async Task ProbeAndFreshUpdate_BlockUnreadableReceiptDirectory(string? manifestContents)
+ {
+ const string pointId = "20260723T120000000Z-0123456789abcdef0123456789abcdef";
+ var pointDirectory = Path.Combine(
+ _tempRoot, "gateway-rollback-points", "OpenClawGateway", pointId);
+ Directory.CreateDirectory(pointDirectory);
+ if (manifestContents is not null)
+ File.WriteAllText(Path.Combine(pointDirectory, "manifest.json"), manifestContents);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var probe = await coordinator.ProbeAsync(EligiblePlan());
+ var update = await coordinator.UpdateAsync(EligiblePlan());
+ var create = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, probe.State);
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, update.State);
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, create.State);
+ Assert.Equal("rollback_receipt_unreadable", create.FailureCode);
+ Assert.True(coordinator.HasUnreadableRollbackReceipt());
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RejectsCrossIdRequestForPointOtherThanUniquePendingReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var pending = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "historical-gateway-record", PreviousVersion, RequiredVersion);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var other = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "other-historical-record", PreviousVersion, RequiredVersion);
+ manager.MarkUpdateInProgress(pending.Point!.Id);
+ runner.ClearCalls();
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "replacement-gateway-record", other.Point!.Id, other.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.OwnershipMismatch, result.State);
+ Assert.Equal(other.Point.Id, result.Point?.Id);
+ Assert.Empty(runner.Calls);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.List(), point => point.Id == pending.Point.Id).Phase);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_FailsAmbiguousBeforeProbeWhenMultipleRestoresAreUnresolved()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var first = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var second = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ SetPointPhase(first.Point!.Id, GatewayRollbackPointPhase.RestoreStaged);
+ SetPointPhase(second.Point!.Id, GatewayRollbackPointPhase.ImportPending);
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var result = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, result.State);
+ Assert.Null(result.RollbackPointId);
+ Assert.Contains("ambiguous", result.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task CreateVerifiedAsync_FailsVersionAttestationAndDeletesUnverifiedVhdPayload()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok("2026.7.21+unexpected.9"));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+
+ var result = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("exported_state_attestation_changed", result.FailureCode);
+ var point = Assert.Single(manager.List());
+ Assert.Equal(GatewayRollbackPointVerificationStatus.Failed, point.VerificationStatus);
+ Assert.False(point.RestoreEligible);
+ Assert.False(File.Exists(Path.Combine(
+ _tempRoot, "gateway-rollback-points", "OpenClawGateway", point.Id, "rollback.vhdx")));
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RequiresExactPointConfirmationBeforeLifecycleMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ Assert.True(created.Success);
+ runner.ClearCalls();
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, "different-id");
+
+ Assert.Equal(GatewayRollbackOperationState.ConfirmationRequired, result.State);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task CancelRestore_AllowsOnlyRestoreStagedAndDurablyUnblocksFreshUpdate()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, GatewayRollbackPointPhase.RestoreStaged);
+
+ var cancelled = manager.CancelRestore("OpenClawGateway", "local-gateway", point.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.Cancelled, cancelled.State);
+ Assert.Equal(GatewayRollbackPointPhase.RestoreCancelled, Assert.Single(manager.List()).Phase);
+ Assert.Empty(manager.FindUnresolvedRestores());
+
+ runner.ClearCalls();
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ var update = await new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager)
+ .UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.Aligned, update.State);
+ }
+
+ [Fact]
+ public async Task CoordinatorCancelRestore_RequiresExactPointConfirmationAndSupportsRecordIdDrift()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "previous-gateway-record", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, GatewayRollbackPointPhase.RestoreStaged);
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var unconfirmed = coordinator.CancelRestore(
+ EligiblePlan(), point.Point.Id, "different-point");
+ var cancelled = coordinator.CancelRestore(
+ EligiblePlan(), point.Point.Id, point.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RestoreConfirmationRequired, unconfirmed.State);
+ Assert.Equal(GatewayVersionAlignmentState.RestoreCancelled, cancelled.State);
+ Assert.Equal(GatewayRollbackPointPhase.RestoreCancelled, Assert.Single(manager.List()).Phase);
+ }
+
+ [Theory]
+ [InlineData(GatewayRollbackPointPhase.UnregisterPending)]
+ [InlineData(GatewayRollbackPointPhase.DistroUnregistered)]
+ [InlineData(GatewayRollbackPointPhase.ImportPending)]
+ [InlineData(GatewayRollbackPointPhase.Imported)]
+ public async Task CancelRestore_RequiresSamePointResumeAfterDestructiveBoundary(
+ GatewayRollbackPointPhase phase)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, phase);
+
+ var result = manager.CancelRestore("OpenClawGateway", "local-gateway", point.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.ResumeRequired, result.State);
+ Assert.Equal(phase, Assert.Single(manager.List()).Phase);
+ }
+
+ [Theory]
+ [InlineData(GatewayRollbackPointPhase.UnregisterPending)]
+ [InlineData(GatewayRollbackPointPhase.DistroUnregistered)]
+ [InlineData(GatewayRollbackPointPhase.ImportPending)]
+ [InlineData(GatewayRollbackPointPhase.Imported)]
+ public async Task RestoreAsync_RejectsDifferentPointWhileDestructiveRestoreMustResume(
+ GatewayRollbackPointPhase phase)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var unresolved = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var other = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ SetPointPhase(unresolved.Point!.Id, phase);
+ runner.ClearCalls();
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", other.Point!.Id, other.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.ResumeRequired, result.State);
+ Assert.Equal(unresolved.Point.Id, result.Point!.Id);
+ Assert.Empty(runner.Calls);
+
+ var coordinatorResult = await new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager)
+ .RestoreAsync(EligiblePlan(), other.Point.Id, other.Point.Id);
+ Assert.Equal(GatewayVersionAlignmentState.RestoreFailed, coordinatorResult.State);
+ Assert.Equal(unresolved.Point.Id, coordinatorResult.RollbackPointId);
+ Assert.Contains(unresolved.Point.Id, coordinatorResult.FailureSummary, StringComparison.Ordinal);
+ Assert.Empty(runner.Calls);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_ImportedRestoreBlocksUntilRestoreHealthy()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var point = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ SetPointPhase(point.Point!.Id, GatewayRollbackPointPhase.Imported);
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var blocked = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.RecoveryAvailable, blocked.State);
+ Assert.Empty(runner.Calls);
+
+ manager.MarkRestoreHealthy(point.Point.Id);
+ runner.EnqueueDistro(Ok(RequiredVersion));
+ var unblocked = await coordinator.UpdateAsync(EligiblePlan());
+ Assert.Equal(GatewayVersionAlignmentState.Aligned, unblocked.State);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RecreatesOnlyRegistrationThenProvesVersionIdentityAndSynchronization()
+ {
+ var runner = new FakeWslCommandRunner();
+ runner.Configuration = new(2, 0, 5);
+ var current = new DateTimeOffset(2026, 7, 21, 12, 0, 0, TimeSpan.Zero);
+ var manager = CreateManager(runner, () => current);
+ var older = await CreateHealthyPointAsync(manager, runner, "2026.6.1");
+ current += TimeSpan.FromDays(1);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ var createdPointId = created.Point!.Id;
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 201);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ EnqueuePendingAttestation(runner, PreviousVersion);
+ runner.BeforeUnregister = () =>
+ Assert.Equal(
+ GatewayRollbackPointPhase.UnregisterPending,
+ manager.List().Single(point => point.Id == createdPointId).Phase);
+ runner.BeforeImport = () =>
+ Assert.Equal(
+ GatewayRollbackPointPhase.ImportPending,
+ manager.List().Single(point => point.Id == createdPointId).Phase);
+ var syncCalls = 0;
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager, (_, _) => { syncCalls++; return Task.CompletedTask; });
+
+ var result = await coordinator.RestoreAsync(EligiblePlan(), created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.Restored, result.State);
+ Assert.Equal(PreviousVersion, result.InstalledVersion);
+ Assert.Equal(1, runner.UnregisterCalls);
+ Assert.Contains(runner.Calls, call => call.Kind == "direct" && call.Arguments[0] == "--import-in-place");
+ Assert.Equal(1, runner.ConfigureCalls);
+ Assert.Equal(new WslDistroConfiguration(2, 0, 5), runner.Configuration);
+ Assert.Equal(1, syncCalls);
+ Assert.Equal(
+ GatewayRollbackPointPhase.RestoreHealthy,
+ coordinator.ListRollbackPoints().Single(point => point.Id == createdPointId).Phase);
+ Assert.Contains(coordinator.ListRollbackPoints(), point => point.Id == older.Id);
+ }
+
+ [Fact]
+ public async Task SchemaV3Manifest_RemainsVerifiableAndVhdRestoreable()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.FullVhd);
+ var manifestPath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ created.Point!.Id,
+ "manifest.json");
+ var manifestJson = JsonNode.Parse(File.ReadAllText(manifestPath))!.AsObject();
+ manifestJson["SchemaVersion"] = 3;
+ manifestJson.Remove("ProtectionMode");
+ manifestJson.Remove("NativeBackupSha256");
+ manifestJson.Remove("NativeBackupSizeBytes");
+ File.WriteAllText(manifestPath, manifestJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+
+ Assert.True(await manager.VerifyAsync(created.Point.Id));
+
+ runner.ClearCalls();
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 221);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ var restored = await manager.RestoreExplicitAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.Restored, restored.State);
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [Theory]
+ [InlineData(GatewayUpdateProtectionMode.NativeBackup)]
+ [InlineData(GatewayUpdateProtectionMode.FullVhd)]
+ public async Task TransactionReceiptPersistsForBothProtectionModes(
+ GatewayUpdateProtectionMode protectionMode)
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ if (protectionMode == GatewayUpdateProtectionMode.NativeBackup)
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ else
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ protectionMode);
+ var requestId = $"request-{protectionMode}";
+
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CoreTransaction,
+ requestId);
+ manager.RecordCoreUpdateAccepted(
+ created.Point.Id,
+ $"transaction-{protectionMode}",
+ new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
+
+ var receipt = Assert.Single(manager.FindPendingUpdates());
+ Assert.Equal(protectionMode, receipt.ProtectionMode);
+ Assert.Equal(GatewayUpdateDispatchState.Accepted, receipt.UpdateDispatchState);
+ Assert.Equal(requestId, receipt.UpdateRequestId);
+ Assert.Equal($"transaction-{protectionMode}", receipt.UpdateTransactionId);
+ Assert.True(new GatewayVersionAlignmentCoordinator(
+ runner,
+ RequiredVersion,
+ manager).HasVerifiedPendingUpdate());
+ }
+
+ [Fact]
+ public async Task RestoreAsync_VerifiesCompleteLegacyAllowlistBeforeRestoreHealthy()
+ {
+ const string completeArray = """["system.run","system.which","camera.snap"]""";
+ var runner = new FakeWslCommandRunner { Configuration = new(2, 0, 5) };
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, LegacyVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "historical-gateway-record", LegacyVersion, RequiredVersion);
+ manager.RecordNodeCommandAllowSnapshot(created.Point!.Id, completeArray);
+ manager.ArmUpdateDispatch(
+ created.Point.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 211);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(LegacyVersion));
+ runner.EnqueueDistro(Ok(completeArray));
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ EnqueuePendingAttestation(runner, LegacyVersion);
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager, (_, _) => Task.CompletedTask);
+
+ var result = await coordinator.RestoreAsync(
+ EligiblePlan() with { GatewayId = "replacement-gateway-record" },
+ created.Point.Id,
+ created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.Restored, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.RestoreHealthy, Assert.Single(coordinator.ListRollbackPoints()).Phase);
+ Assert.Contains(runner.Calls, call =>
+ call.Kind == "distro" &&
+ call.Arguments.SequenceEqual(ConfigCommand("get gateway.nodes.allowCommands --json")));
+ }
+
+ [Fact]
+ public async Task RestoreAsync_BlocksRestoreHealthyWhenLiveIdentityChangesAfterSynchronization()
+ {
+ var runner = new FakeWslCommandRunner { Configuration = new(2, 0, 5) };
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 214);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ runner.EnqueueDistro(Ok(PreviousVersion));
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var coordinator = new GatewayVersionAlignmentCoordinator(
+ runner, RequiredVersion, manager, (_, _) => Task.CompletedTask);
+
+ var result = await coordinator.RestoreAsync(
+ EligiblePlan(), created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayVersionAlignmentState.RestoreVerificationFailed, result.State);
+ Assert.Equal(GatewayRollbackPointPhase.Imported, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_FailedImportKeepsReceiptAndRetriesWithoutSecondUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 202);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.ImportResults.Enqueue(new WslCommandResult(17, "", "secret"));
+
+ var failed = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.ImportPending, failed.State);
+ Assert.Equal(GatewayRollbackPointPhase.ImportPending, Assert.Single(manager.List()).Phase);
+ Assert.Equal(1, runner.UnregisterCalls);
+
+ runner.Distros = [];
+ runner.ImportResults.Enqueue(Ok());
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ var retried = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "replacement-gateway-record", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.Restored, retried.State);
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_TransientRetryFailurePreservesImportPendingAndNeverUnregistersAgain()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 207);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.ImportResults.Enqueue(new WslCommandResult(17, "", ""));
+ var importFailed = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+ Assert.Equal(GatewayRollbackOperationState.ImportPending, importFailed.State);
+
+ runner.ListResult = new WslCommandResult(5, "", "");
+ var probeFailed = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, probeFailed.State);
+ Assert.Equal(GatewayRollbackPointPhase.ImportPending, Assert.Single(manager.List()).Phase);
+
+ runner.ListResult = null;
+ runner.Distros = [new("OpenClawGateway", "Stopped", 2)];
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+ var finalized = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.Restored, finalized.State);
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ImportPendingPathCollisionDoesNotRegressReceiptPhase()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 208);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.ImportResults.Enqueue(new WslCommandResult(17, "", ""));
+ var importFailed = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+ Assert.Equal(GatewayRollbackOperationState.ImportPending, importFailed.State);
+
+ File.WriteAllText(Path.Combine(runner.InstallDirectory!, "unexpected.txt"), "fixture");
+ var collision = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, collision.State);
+ Assert.Equal(GatewayRollbackPointPhase.ImportPending, Assert.Single(manager.List()).Phase);
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RecreatesInvalidStageFromVerifiedRollbackPoint()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 205);
+ var stagePath = Path.Combine(
+ _tempRoot, "gateway-rollback-staging", "OpenClawGateway", $"{created.Point!.Id}.vhdx");
+ Directory.CreateDirectory(Path.GetDirectoryName(stagePath)!);
+ File.WriteAllBytes(stagePath, [1, 2, 3]);
+ File.WriteAllBytes(stagePath + ".partial", [4, 5, 6]);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.EnqueueDistro(Ok("openclaw\n1000"));
+
+ var restored = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.Restored, restored.State);
+ Assert.False(File.Exists(stagePath + ".partial"));
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [ReparsePointFact]
+ public async Task RestoreAsync_RejectsReparseBackedStageBeforeUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 209);
+ var stagingRoot = Path.Combine(_tempRoot, "gateway-rollback-staging");
+ var stageDirectory = Path.Combine(stagingRoot, "OpenClawGateway");
+ var redirected = Path.Combine(_tempRoot, "redirected-staging");
+ var stagePath = Path.Combine(stageDirectory, $"{created.Point!.Id}.vhdx");
+ Directory.CreateDirectory(stagingRoot);
+ Directory.CreateDirectory(redirected);
+ Directory.CreateSymbolicLink(stageDirectory, redirected);
+ File.WriteAllBytes(Path.Combine(redirected, Path.GetFileName(stagePath)), [1, 2, 3]);
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, result.State);
+ Assert.Equal("restore_stage_reparse_boundary", result.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [ReparsePointFact]
+ public async Task RestoreAsync_RejectsReparseBackedStagingRootBeforeUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ var stagingRoot = Path.Combine(_tempRoot, "gateway-rollback-staging");
+ var redirected = Path.Combine(_tempRoot, "redirected-staging-root");
+ Directory.CreateDirectory(redirected);
+ Directory.CreateSymbolicLink(stagingRoot, redirected);
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, result.State);
+ Assert.Equal("restore_stage_reparse_boundary", result.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_RevalidatesStageImmediatelyBeforeUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 212);
+ runner.EnqueueDistro(Ok(MachineId));
+ var stagePath = Path.Combine(
+ _tempRoot, "gateway-rollback-staging", "OpenClawGateway", $"{created.Point!.Id}.vhdx");
+ var registrationProbes = 0;
+ runner.BeforeListRegistrations = () =>
+ {
+ registrationProbes++;
+ if (registrationProbes == 2)
+ WriteFakeVhd(stagePath, marker: 213);
+ };
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("restore_stage_changed_before_unregister", result.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+ Assert.Equal(GatewayRollbackPointPhase.RestoreStaged, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ReverifiesPromotedVhdImmediatelyBeforeImport()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 210);
+ runner.EnqueueDistro(Ok(MachineId));
+ var registrationProbes = 0;
+ runner.BeforeListRegistrations = () =>
+ {
+ registrationProbes++;
+ if (registrationProbes == 3)
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 211);
+ };
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("restore_import_vhd_mismatch", result.FailureCode);
+ Assert.DoesNotContain(runner.Calls, call =>
+ call.Kind == "direct" && call.Arguments.Count > 0 && call.Arguments[0] == "--import-in-place");
+ Assert.Equal(GatewayRollbackPointPhase.ImportPending, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_FailsRetryableWhenRegistrationReadbackDoesNotMatch()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 206);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.IgnoreConfigurationWrites = true;
+
+ var failed = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.ImportPending, failed.State);
+ Assert.Equal("restored_registration_mismatch", failed.FailureCode);
+ Assert.Equal(GatewayRollbackPointPhase.ImportPending, Assert.Single(manager.List()).Phase);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ImportPendingSameNameWithDifferentIdentityFailsClosed()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 203);
+ runner.EnqueueDistro(Ok(MachineId));
+ runner.ImportResults.Enqueue(new WslCommandResult(17, "", ""));
+ await manager.RestoreExplicitAsync("OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ runner.EnqueueDistro(Ok("fedcba9876543210fedcba9876543210"));
+ var collision = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.SameNameCollision, collision.State);
+ Assert.Equal(1, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_DistroEnumerationFailureStopsBeforeUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.ListResult = new WslCommandResult(5, "", "private detail");
+
+ var result = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.VerificationFailed, result.State);
+ Assert.Equal("distro_list_failed", result.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task RestoreAsync_ValidatesRegisteredBasePathAndContentsBeforeUnregister()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ runner.ClearCalls();
+ runner.Distros = [new("OpenClawGateway", "Running", 2)];
+ WriteFakeVhd(Path.Combine(runner.InstallDirectory!, "ext4.vhdx"), marker: 204);
+ runner.RegisteredBasePath = Path.Combine(_tempRoot, "unexpected-registration-root");
+
+ var wrongBasePath = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point!.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, wrongBasePath.State);
+ Assert.Equal("registration_base_path_mismatch", wrongBasePath.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+
+ runner.RegisteredBasePath = runner.InstallDirectory;
+ File.WriteAllText(Path.Combine(runner.InstallDirectory!, "unexpected.txt"), "not private data");
+ var unexpectedContents = await manager.RestoreExplicitAsync(
+ "OpenClawGateway", "local-gateway", created.Point.Id, created.Point.Id);
+
+ Assert.Equal(GatewayRollbackOperationState.InstallPathCollision, unexpectedContents.State);
+ Assert.Equal("install_path_not_exclusive", unexpectedContents.FailureCode);
+ Assert.Equal(0, runner.UnregisterCalls);
+ }
+
+ [Fact]
+ public async Task Cleanup_CombinesCountFloorAndAgeAndNeverDeletesNewestKnownGood()
+ {
+ var now = new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero);
+ var current = now - TimeSpan.FromDays(40);
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner, () => current);
+
+ var old = await CreateHealthyPointAsync(manager, runner, "2026.5.1");
+ current = now - TimeSpan.FromDays(10);
+ var recent = await CreateHealthyPointAsync(manager, runner, "2026.6.1");
+ current = now;
+ var latest = await CreateHealthyPointAsync(manager, runner, "2026.7.1");
+
+ var firstCleanup = await manager.CleanupAsync(new(1, TimeSpan.FromDays(30)));
+ Assert.Equal(1, firstCleanup);
+ Assert.Equal([latest.Id, recent.Id], manager.List().Select(point => point.Id));
+
+ var secondCleanup = await manager.CleanupAsync(new(1, null));
+ Assert.Equal(1, secondCleanup);
+ Assert.Equal(latest.Id, Assert.Single(manager.List()).Id);
+ Assert.NotEqual(old.Id, latest.Id);
+ }
+
+ [Fact]
+ public async Task Cleanup_IndefiniteRetentionNeverDeletesAndPendingPointIsNotEligibleForCleanup()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ var first = await CreateHealthyPointAsync(manager, runner, "2026.6.1");
+ EnqueueCreateAttestation(runner, "2026.7.1");
+ var pending = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", "2026.7.1", RequiredVersion);
+
+ Assert.Equal(0, await manager.CleanupAsync(new(-1, null)));
+ Assert.Equal(2, manager.List().Count);
+ Assert.Equal(1, await manager.CleanupAsync(new(1, null)));
+ Assert.DoesNotContain(manager.List(), point => point.Id == first.Id);
+ Assert.Equal(pending.Point!.Id, Assert.Single(manager.List()).Id);
+ }
+
+ [Fact]
+ public async Task Cleanup_RemovesCrashLeftExportPartialForUnverifiedPoint()
+ {
+ var runner = new FakeWslCommandRunner
+ {
+ ExportResult = new WslCommandResult(9, "", "")
+ };
+ var manager = CreateManager(runner);
+ EnqueueCreateAttestation(runner, PreviousVersion);
+ var failed = await manager.CreateVerifiedAsync(
+ "OpenClawGateway", "local-gateway", PreviousVersion, RequiredVersion);
+ var partialPath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ failed.Point!.Id,
+ "rollback.vhdx.partial");
+ var nativePartialPath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ failed.Point.Id,
+ "openclaw-backup.tar.gz.partial");
+ WriteFakeVhd(partialPath);
+ File.WriteAllBytes(nativePartialPath, [1, 2, 3, 4]);
+
+ await manager.CleanupAsync(GatewayRollbackRetentionPolicy.Default);
+
+ Assert.False(File.Exists(partialPath));
+ Assert.False(File.Exists(nativePartialPath));
+ Assert.Equal(failed.Point.Id, Assert.Single(manager.List()).Id);
+ }
+
+ [Fact]
+ public async Task Cleanup_PreservesFailedNativeArtifactForPendingUpdateReceipt()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ var archivePath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ created.Point.Id,
+ "openclaw-backup.tar.gz");
+ File.WriteAllBytes(archivePath, [9, 9, 9]);
+ Assert.False(await manager.VerifyAsync(created.Point.Id));
+
+ var removed = await manager.CleanupAsync(GatewayRollbackRetentionPolicy.Default);
+
+ Assert.Equal(0, removed);
+ Assert.True(File.Exists(archivePath));
+ var pending = Assert.Single(manager.FindPendingUpdates());
+ Assert.Equal(created.Point.Id, pending.Id);
+ Assert.Equal(GatewayRollbackPointVerificationStatus.Failed, pending.VerificationStatus);
+ }
+
+ [Fact]
+ public async Task NativeRecoveryAndCleanup_BlockUnreadableReceiptWithoutMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var manager = CreateManager(runner);
+ EnqueueNativeBackupAttestation(runner, PreviousVersion);
+ var created = await manager.CreateVerifiedAsync(
+ "OpenClawGateway",
+ "local-gateway",
+ PreviousVersion,
+ RequiredVersion,
+ GatewayUpdateProtectionMode.NativeBackup);
+ manager.ArmUpdateDispatch(
+ created.Point!.Id,
+ GatewayPackageTarget.Official(RequiredVersion),
+ GatewayPackageUpdateRoute.CompanionInstaller,
+ requestId: null);
+ manager.MarkInstallerDispatchAccepted(created.Point.Id);
+ manager.MarkUpdateInProgress(created.Point.Id);
+
+ const string unreadablePointId = "20260726T120000000Z-0123456789abcdef0123456789abcdef";
+ Directory.CreateDirectory(Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ unreadablePointId));
+ runner.ClearCalls();
+ var coordinator = new GatewayVersionAlignmentCoordinator(runner, RequiredVersion, manager);
+
+ var recovery = await coordinator.ResolveNativeRecoveryAsync(
+ EligiblePlan(),
+ created.Point.Id,
+ created.Point.Id);
+ var cleanup = await manager.CleanupAsync(GatewayRollbackRetentionPolicy.Default);
+
+ Assert.Equal(GatewayVersionAlignmentState.VerificationFailed, recovery.State);
+ Assert.Contains("cannot be read", recovery.FailureSummary, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(0, cleanup);
+ Assert.Equal(
+ GatewayRollbackPointPhase.UpdateInProgress,
+ Assert.Single(manager.FindPendingUpdates()).Phase);
+ Assert.Empty(runner.Calls);
+ await Assert.ThrowsAsync(
+ () => coordinator.CleanupRollbackPointsAsync());
+ }
+
+ [Fact]
+ public async Task ConcurrentOperation_ReturnsBusyWithoutDuplicateMutation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ runner.EnqueueDistro(async _ => { started.SetResult(); await release.Task; return Ok(RequiredVersion); });
+ var coordinator = CreateCoordinator(runner);
+
+ var first = coordinator.ProbeAsync(EligiblePlan());
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ var duplicate = await coordinator.UpdateAsync(EligiblePlan());
+
+ Assert.Equal(GatewayVersionAlignmentState.Busy, duplicate.State);
+ release.SetResult();
+ Assert.Equal(GatewayVersionAlignmentState.Aligned, (await first).State);
+ }
+
+ [Fact]
+ public async Task CleanupRollbackPointsAsync_WaitsForActiveAlignmentOperation()
+ {
+ var runner = new FakeWslCommandRunner();
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ runner.EnqueueDistro(async _ =>
+ {
+ started.SetResult();
+ await release.Task;
+ return Ok(RequiredVersion);
+ });
+ var coordinator = CreateCoordinator(runner);
+
+ var probe = coordinator.ProbeAsync(EligiblePlan());
+ await started.Task.WaitAsync(TimeSpan.FromSeconds(2));
+ var cleanup = coordinator.CleanupRollbackPointsAsync();
+
+ Assert.NotSame(cleanup, await Task.WhenAny(cleanup, Task.Delay(100)));
+ release.SetResult();
+
+ Assert.Equal(GatewayVersionAlignmentState.Aligned, (await probe).State);
+ Assert.Equal(0, await cleanup);
+ }
+
+ private void SetPointPhase(string pointId, GatewayRollbackPointPhase phase)
+ {
+ var manifestPath = Path.Combine(
+ _tempRoot,
+ "gateway-rollback-points",
+ "OpenClawGateway",
+ pointId,
+ "manifest.json");
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter() }
+ };
+ var manifest = JsonSerializer.Deserialize(
+ File.ReadAllText(manifestPath),
+ options) ?? throw new InvalidOperationException("Fixture manifest could not be read.");
+ File.WriteAllText(
+ manifestPath,
+ JsonSerializer.Serialize(manifest with { Phase = phase, UpdatedAtUtc = DateTimeOffset.UtcNow }, options));
+ }
+
+ private IReadOnlyList ReadOwnedManifests()
+ {
+ var options = new JsonSerializerOptions
+ {
+ Converters = { new JsonStringEnumConverter() }
+ };
+ return Directory.EnumerateFiles(
+ Path.Combine(_tempRoot, "gateway-rollback-points", "OpenClawGateway"),
+ "manifest.json",
+ SearchOption.AllDirectories)
+ .Select(path => JsonSerializer.Deserialize(
+ File.ReadAllText(path), options)!)
+ .ToArray();
+ }
+
+ private async Task CreateHealthyPointAsync(
+ GatewayRollbackPointManager manager,
+ FakeWslCommandRunner runner,
+ string version)
+ {
+ EnqueueCreateAttestation(runner, version);
+ var created = await manager.CreateVerifiedAsync("OpenClawGateway", "local-gateway", version, RequiredVersion);
+ manager.MarkPostUpdateHealthy(created.Point!.Id);
+ return manager.List().Single(point => point.Id == created.Point.Id);
+ }
+
+ private GatewayVersionAlignmentCoordinator CreateCoordinator(
+ FakeWslCommandRunner runner,
+ Func? synchronize = null,
+ FakeGatewayUpdateRpc? updateRpc = null,
+ Func? connectedGatewayId = null,
+ Func? clock = null,
+ GatewayUpdateProtectionMode protectionMode = GatewayUpdateProtectionMode.FullVhd)
+ {
+ updateRpc ??= new FakeGatewayUpdateRpc();
+ return new(
+ runner,
+ GatewayPackageTarget.Official(RequiredVersion),
+ CreateManager(runner),
+ synchronize,
+ gatewayRequestAsync: updateRpc.SendAsync,
+ connectedGatewayId: connectedGatewayId ?? (() => "local-gateway"),
+ routePolicy: new GatewayPackageUpdateRoutePolicy([PreviousVersion, LegacyVersion]),
+ utcNow: clock,
+ protectionModeResolver: () => protectionMode);
+ }
+
+ private GatewayRollbackPointManager CreateManager(
+ FakeWslCommandRunner runner,
+ Func? clock = null,
+ Func? availableFreeSpaceBytes = null)
+ {
+ runner.InstallDirectory = Path.Combine(_tempRoot, "wsl", "OpenClawGateway");
+ Directory.CreateDirectory(runner.InstallDirectory);
+ var liveVhdPath = Path.Combine(runner.InstallDirectory, "ext4.vhdx");
+ if (!File.Exists(liveVhdPath))
+ WriteFakeVhd(liveVhdPath);
+ return new(runner, _tempRoot, "OpenClawGateway", clock, availableFreeSpaceBytes);
+ }
+
+ private static GatewayHostAccessPlan EligiblePlan() => new(
+ "local-gateway",
+ GatewayTerminalTarget.Wsl,
+ "OpenClawGateway",
+ null,
+ null,
+ true,
+ "Open terminal",
+ "Open terminal",
+ null);
+
+ private static string UpdateJson(string version) =>
+ $$"""{"status":"ok","mode":"npm","after":{"version":"{{version}}"},"steps":[],"durationMs":1}""";
+
+ private static WslCommandResult Ok(string output = "") => new(0, output, "");
+
+ private static void WriteFakeVhd(string path, byte marker = 0)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, [.. "vhdxfile"u8.ToArray(), marker, .. Enumerable.Range(0, 64).Select(value => (byte)value)]);
+ }
+
+ private static IReadOnlyList ProbeCommand() =>
+ ["bash", "-lc", $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw --version"];
+
+ private static IReadOnlyList