diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0a3e8f7..6aef211ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,115 @@ jobs: exit 1 fi + gateway-composed: + needs: repo-hygiene + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + sha256: ${{ steps.package.outputs.sha256 }} + steps: + - name: Fail if repo hygiene failed + if: ${{ needs.repo-hygiene.result != 'success' }} + shell: bash + run: | + echo "repo-hygiene failed; see the repo-hygiene job output." >&2 + exit 1 + + - name: Checkout frozen OpenClaw main source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: openclaw/openclaw + ref: 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" + + - 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 +447,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 +527,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_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 +586,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 +617,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..eb587ccb4 100644 --- a/.github/workflows/gateway-lkg-update.yml +++ b/.github/workflows/gateway-lkg-update.yml @@ -35,8 +35,8 @@ jobs: if not 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 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 +45,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 +56,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,27 +90,47 @@ 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("tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs"), + r'(ExpectedLkgVersion\s*=\s*")([^"]+)(")', + "ExpectedLkgVersion constant", + ), + ) + 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}") + path.write_text(updated, encoding="utf-8") PY - name: Commit and push branch 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 + tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs + ) + if git diff --quiet -- "${update_paths[@]}"; then echo "No LKG change detected after update write." 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}" @@ -121,7 +147,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 and its contract test + + 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/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..99af0467f 100644 --- a/scripts/validate-mxc-e2e.ps1 +++ b/scripts/validate-mxc-e2e.ps1 @@ -148,10 +148,65 @@ function Set-ProcessEnv { [Environment]::SetEnvironmentVariable($Name, $Value, "Process") } +function Assert-ReviewedComposedGatewayPackage { + param( + [Parameter(Mandatory = $true)][string]$PackageSpec, + [Parameter(Mandatory = $true)][string]$ExpectedSha256, + [Parameter(Mandatory = $true)][string]$HostDistroName + ) + + $composedUri = $null + if (-not [Uri]::TryCreate($PackageSpec, [UriKind]::Absolute, [ref]$composedUri) -or + ($composedUri.Scheme -ne [Uri]::UriSchemeHttp -and $composedUri.Scheme -ne [Uri]::UriSchemeHttps) -or + -not $composedUri.AbsolutePath.EndsWith(".tgz", [StringComparison]::OrdinalIgnoreCase)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must be an absolute HTTP(S) URL for a reviewed composed .tgz package." + } + if (-not [string]::IsNullOrEmpty($composedUri.UserInfo)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC cannot contain credentials." + } + if ($ExpectedSha256 -notmatch "^[a-fA-F0-9]{64}$") { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 must be the reviewed composed-package SHA-256." + } + if ($HostDistroName -notmatch "^OpenClawE2EPackageHost-[A-Za-z0-9-]+$") { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO must identify the disposable package host." + } + + $normalizedSha256 = $ExpectedSha256.ToLowerInvariant() + $expectedFileName = "openclaw-composed-$normalizedSha256.tgz" + if (-not [string]::Equals( + [IO.Path]::GetFileName($composedUri.AbsolutePath), + $expectedFileName, + [StringComparison]::Ordinal)) { + throw "Composed gateway package URL is not bound to the reviewed SHA-256. Use Start-E2EGatewayPackageHost.ps1 output." + } + + $hostPackagePath = "/tmp/openclaw-e2e-package-host/$expectedFileName" + $hostHashOutput = [string](& wsl.exe -d $HostDistroName -u root -- sha256sum -- $hostPackagePath) + if ($LASTEXITCODE -ne 0 -or $hostHashOutput -notmatch "^([a-fA-F0-9]{64})\s" -or + $Matches[1].ToLowerInvariant() -ne $normalizedSha256) { + throw "The disposable package host does not contain the reviewed composed gateway package." + } + $hostMode = [string](& wsl.exe -d $HostDistroName -u root -- stat -c "%a" -- $hostPackagePath) + if ($LASTEXITCODE -ne 0 -or $hostMode.Trim() -ne "444") { + throw "The reviewed composed gateway package is not read-only in the disposable package host." + } + $hostAddresses = @( + ((& wsl.exe -d $HostDistroName -u root -- hostname -I) -split "\s+") | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if ($LASTEXITCODE -ne 0 -or $hostAddresses -notcontains $composedUri.Host) { + throw "The composed gateway package URL is not served by the proven disposable package host." + } + + Write-Host "Composed gateway package identity proven: distro=$HostDistroName sha256=$normalizedSha256" -ForegroundColor Green +} + $trackedEnvVars = @( "OPENCLAW_REPO_ROOT", "OPENCLAW_RUN_E2E", - "OPENCLAW_RUN_MXC_E2E" + "OPENCLAW_RUN_MXC_E2E", + "OPENCLAW_E2E_GATEWAY_SOURCE", + "OPENCLAW_E2E_GATEWAY_VERSION" ) $previousEnv = @{} foreach ($name in $trackedEnvVars) { @@ -159,9 +214,26 @@ foreach ($name in $trackedEnvVars) { } try { + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must name the reviewed composed HTTP(S) .tgz package before formal MXC validation." + } + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 must identify the reviewed composed package before formal MXC validation." + } + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO must identify the disposable package host before formal MXC validation." + } + + Assert-ReviewedComposedGatewayPackage ` + -PackageSpec $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC ` + -ExpectedSha256 $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256 ` + -HostDistroName $env:OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO + Set-ProcessEnv -Name "OPENCLAW_REPO_ROOT" -Value $repoRoot Set-ProcessEnv -Name "OPENCLAW_RUN_E2E" -Value "1" Set-ProcessEnv -Name "OPENCLAW_RUN_MXC_E2E" -Value "1" + Set-ProcessEnv -Name "OPENCLAW_E2E_GATEWAY_VERSION" -Value $null + Set-ProcessEnv -Name "OPENCLAW_E2E_GATEWAY_SOURCE" -Value "composed" Write-Host "OpenClaw MXC validation" Write-Host " Repo: $repoRoot" diff --git a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs index 738a47889..f27408acd 100644 --- a/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs +++ b/src/OpenClaw.SetupEngine/GatewayLkgVersion.cs @@ -3,7 +3,7 @@ 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 LkgVersion = "2026.7.1"; public static string ResolveLkgVersion() => LkgVersion; diff --git a/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs b/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs new file mode 100644 index 000000000..104a283f9 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs @@ -0,0 +1,81 @@ +using System.Text.Json; + +namespace OpenClaw.SetupEngine; + +internal sealed record GatewayReloadRecoveryState( + int Version, + string DistroName, + string? ReloadMode, + DateTimeOffset CreatedAtUtc); + +internal static class GatewayReloadRecoveryStore +{ + internal const int CurrentVersion = 1; + internal const string FileName = "setup-gateway-reload-recovery.json"; + + internal static string GetPath(SetupContext ctx) => Path.Combine(ctx.LocalDataDir, FileName); + + internal static GatewayReloadRecoveryState? Load(SetupContext ctx) + { + var path = GetPath(ctx); + GatewayReloadRecoveryState? state; + try + { + state = JsonSerializer.Deserialize( + File.ReadAllText(path), + SetupConfig.JsonOptions); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + throw new InvalidDataException($"Gateway reload recovery marker is unreadable: {ex.Message}", ex); + } + + if (state is null) + throw new InvalidDataException("Gateway reload recovery marker must contain a JSON object."); + if (state.Version != CurrentVersion) + throw new InvalidDataException($"Gateway reload recovery marker version {state.Version} is unsupported."); + if (string.IsNullOrWhiteSpace(state.DistroName)) + throw new InvalidDataException("Gateway reload recovery marker is missing the distro name."); + if (state.ReloadMode is not null && !IsSupportedReloadMode(state.ReloadMode)) + throw new InvalidDataException($"Gateway reload recovery marker contains unsupported reload mode '{state.ReloadMode}'."); + + return state; + } + + internal static void Save(SetupContext ctx, string? reloadMode) + { + var distroName = ctx.DistroName; + if (string.IsNullOrWhiteSpace(distroName)) + throw new InvalidOperationException("Cannot suspend gateway reload without a target distro name."); + if (reloadMode is not null && !IsSupportedReloadMode(reloadMode)) + throw new InvalidOperationException($"Cannot suspend gateway reload with unsupported restore mode '{reloadMode}'."); + + var state = new GatewayReloadRecoveryState( + CurrentVersion, + distroName, + reloadMode, + DateTimeOffset.UtcNow); + AtomicFile.WriteAllText( + GetPath(ctx), + JsonSerializer.Serialize(state, SetupConfig.JsonWriteOptions)); + } + + internal static void Clear(SetupContext ctx) + { + try + { + File.Delete(GetPath(ctx)); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // Clearing an optional recovery marker is idempotent on a fresh install. + } + } + + internal static bool IsSupportedReloadMode(string? reloadMode) => reloadMode is + "off" or "hot" or "restart" or "hybrid"; +} diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index d73234c5c..4f3c01dea 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -154,8 +154,9 @@ public sealed class GatewayConfig public string Bind { get; set; } = "loopback"; public string? InstallUrl { get; set; } public string? Version { get; set; } + public string? ExpectedPackageSha256 { get; set; } public int HealthTimeoutSeconds { get; set; } = 90; - public string ReloadMode { get; set; } = "hot"; + public string ReloadMode { get; set; } = "hybrid"; public string AuthMode { get; set; } = "token"; public Dictionary? ExtraConfig { get; set; } } diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs index 1161b6a21..20f312d1b 100644 --- a/src/OpenClaw.SetupEngine/SetupPipeline.cs +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -40,6 +40,7 @@ public static class SetupStepFactory { public static List BuildWizardOnlySteps() => [ + new RecoverGatewayReloadStep(), new RunGatewayWizardStep(), new WindowsNodeBootstrapContextStep(), ]; @@ -61,9 +62,11 @@ public static List BuildDefaultSteps() new InstallCliStep(), new InstallTailscaleStep(), new AuthorizeTailscaleStep(), + new RecoverGatewayReloadStep(), new ConfigureGatewayStep(), new InstallGatewayServiceStep(), - new StartGatewayStep(), + new WaitForGatewayHealthStep(), + new StartKeepaliveStep(), new FinalizeTailscaleServeStep(), new MintBootstrapTokenStep(), new PairOperatorStep(), @@ -71,7 +74,6 @@ public static List BuildDefaultSteps() new VerifyEndToEndStep(), new RunGatewayWizardStep(), new WindowsNodeBootstrapContextStep(), - new StartKeepaliveStep(), ]; } } diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 66b2a851a..b514a5eee 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -4,6 +4,7 @@ using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.RegularExpressions; using OpenClaw.Connection; using OpenClaw.Shared; @@ -287,6 +288,9 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return delete; } ctx.Logger.Decision("No stale distro found", "skip cleanup"); + var recoveryResult = DiscardRecoveryForRemovedDistro(ctx); + if (!recoveryResult.IsSuccess) + return recoveryResult; return StepResult.Ok("No stale distro to clean"); } @@ -315,12 +319,39 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati // Wait for port to be released ctx.Logger.Info("Waiting for port release after distro termination..."); await PreflightPortStep.WaitForPortFreeAsync(ctx.Config.GatewayPort, ctx.Config.Gateway.Bind, ctx.Logger, ct); + var recoveryResult = DiscardRecoveryForRemovedDistro(ctx); + if (!recoveryResult.IsSuccess) + return recoveryResult; return StepResult.Ok($"Unregistered stale distro '{distro}'"); } return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}"); } + private static StepResult DiscardRecoveryForRemovedDistro(SetupContext ctx) + { + GatewayReloadRecoveryState? recovery; + try + { + recovery = GatewayReloadRecoveryStore.Load(ctx); + } + catch (Exception ex) + { + return StepResult.Fail($"Cannot inspect gateway reload recovery state during cleanup: {ex.Message}", ex); + } + + if (recovery is not null && + !string.Equals(recovery.DistroName, ctx.DistroName, StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail( + $"Gateway reload recovery marker targets distro '{recovery.DistroName}', " + + $"but cleanup targets '{ctx.DistroName}'. Refusing to discard recovery state."); + } + + GatewayReloadRecoveryStore.Clear(ctx); + return StepResult.Ok("Gateway reload recovery state discarded for removed distro"); + } + internal static async Task DeleteDistroDirectoryWithRetries( SetupContext ctx, string distroName, @@ -1249,14 +1280,23 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati string installScript; try { - installScript = BuildInstallCommand(installUrl, ctx.Config.Gateway.Version); + installScript = BuildInstallCommand( + installUrl, + ctx.Config.Gateway.Version, + ctx.Config.Gateway.ExpectedPackageSha256); } catch (ArgumentException ex) { return StepResult.Fail(ex.Message); } - var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct); + var inputViaStdin = !string.IsNullOrWhiteSpace(ctx.Config.Gateway.ExpectedPackageSha256); + var result = await ctx.Commands.RunInWslAsync( + distro, + installScript, + TimeSpan.FromMinutes(5), + ct: ct, + inputViaStdin: inputViaStdin); if (result.ExitCode != 0) return StepResult.Fail($"CLI install failed (exit {result.ExitCode}): {result.Stderr}"); @@ -1289,9 +1329,15 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati return StepResult.Fail("CLI installed but not found in any known location"); } - internal static string BuildInstallCommand(string installUrl, string? requestedVersion) + internal static string BuildInstallCommand( + string installUrl, + string? requestedVersion, + string? expectedPackageSha256 = null) { var escapedUrl = WslShellQuoting.EscapePosixSingleQuoteInner(installUrl); + if (!string.IsNullOrWhiteSpace(expectedPackageSha256)) + return BuildVerifiedPackageInstallCommand(escapedUrl, requestedVersion, expectedPackageSha256); + if (string.IsNullOrWhiteSpace(requestedVersion)) return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; @@ -1303,6 +1349,44 @@ internal static string BuildInstallCommand(string installUrl, string? requestedV return $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash -s -- --version '{escapedVersion}'"; } + private static string BuildVerifiedPackageInstallCommand( + string escapedInstallUrl, + string? requestedVersion, + string expectedPackageSha256) + { + var normalizedSha256 = expectedPackageSha256.Trim().ToLowerInvariant(); + if (normalizedSha256.Length != 64 || !normalizedSha256.All(Uri.IsHexDigit)) + throw new ArgumentException("Expected gateway package SHA-256 must contain exactly 64 hexadecimal characters."); + + var packageSpec = requestedVersion?.Trim(); + if (string.IsNullOrWhiteSpace(packageSpec) || + packageSpec.Contains('\n') || + packageSpec.Contains('\r') || + !Uri.TryCreate(packageSpec, UriKind.Absolute, out var packageUri) || + (packageUri.Scheme != Uri.UriSchemeHttp && packageUri.Scheme != Uri.UriSchemeHttps) || + !packageUri.AbsolutePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(packageUri.UserInfo)) + { + throw new ArgumentException( + "Expected gateway package SHA-256 requires Version to be a credential-free HTTP(S) .tgz URL."); + } + + var escapedPackageSpec = WslShellQuoting.EscapePosixSingleQuoteInner(packageSpec); + var packageCurlOptions = packageUri.Scheme == Uri.UriSchemeHttps + ? "--proto '=https' --tlsv1.2" + : "--proto '=http'"; + + return + "download_dir=\"$(mktemp -d /tmp/openclaw-install.XXXXXX)\"" + + " && trap 'rm -rf -- \"$download_dir\"' EXIT" + + " && package_path=\"$download_dir/openclaw.tgz\"" + + " && installer_path=\"$download_dir/install-cli.sh\"" + + $" && curl -fsSL {packageCurlOptions} '{escapedPackageSpec}' -o \"$package_path\"" + + $" && printf '%s %s\\n' '{normalizedSha256}' \"$package_path\" | sha256sum --check --strict -" + + $" && curl -fsSL --proto '=https' --tlsv1.2 '{escapedInstallUrl}' -o \"$installer_path\"" + + " && bash \"$installer_path\" --version \"$package_path\""; + } + private static async Task EnsureCliOnDefaultPathAsync( SetupContext ctx, string distro, @@ -1357,9 +1441,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 +1493,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 +1507,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 +1549,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 +1600,27 @@ 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(gw.Version) + ?? 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 +1645,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 +1679,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 +1748,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 +2327,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 +2378,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/Models.cs b/src/OpenClaw.Shared/Models.cs index 93baa963d..20f45a615 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,25 @@ public static class CommandCenterCommandGroups ]; } +public static class GatewayNodeCommandPolicyConfig +{ + public const string CurrentAllowKey = "gateway.nodes.commands.allow"; + public const string LegacyAllowKey = "gateway.nodes.allowCommands"; + + public static string? ResolveAllowKey(string? gatewayVersion) + { + var version = gatewayVersion?.Trim(); + if (string.IsNullOrEmpty(version)) + return null; + + 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) + ? LegacyAllowKey + : CurrentAllowKey; + } +} + public static class CommandCenterDiagnostics { private static readonly IReadOnlyDictionary s_severityPriority = @@ -1248,17 +1271,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 +1311,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 +1449,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 +1542,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 +1559,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 +1578,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 +1596,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.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/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.E2ETests/Setup/E2ESetupFixture.cs b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs index 507cfecc2..d7c7ab600 100644 --- a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs +++ b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs @@ -221,7 +221,8 @@ public async Task DisposeAsync() private void WriteConfig() { - var lkgVersion = GatewayLkgVersion.ResolveLkgVersion(); + var gatewayPackageSpec = GatewayE2EPackageSpec.Resolve(); + var expectedGatewayPackageSha256 = GatewayE2EPackageSpec.ResolveExpectedSha256(); var config = new { DistroName = _distroName, @@ -258,7 +259,8 @@ private void WriteConfig() }, Gateway = new { - Version = lkgVersion + Version = gatewayPackageSpec, + ExpectedPackageSha256 = expectedGatewayPackageSha256 } }; diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs new file mode 100644 index 000000000..f4bbe09fa --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -0,0 +1,136 @@ +namespace OpenClaw.E2ETests.Setup; + +internal static class GatewayE2EPackageSpec +{ + internal const string EnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC"; + internal const string Sha256EnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256"; + internal const string HostDistroEnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO"; + internal const string SourceEnvVar = "OPENCLAW_E2E_GATEWAY_SOURCE"; + internal const string VersionEnvVar = "OPENCLAW_E2E_GATEWAY_VERSION"; + + internal static string Resolve() => Resolve( + Environment.GetEnvironmentVariable(SourceEnvVar), + Environment.GetEnvironmentVariable(EnvVar), + Environment.GetEnvironmentVariable(Sha256EnvVar), + Environment.GetEnvironmentVariable(HostDistroEnvVar), + Environment.GetEnvironmentVariable(VersionEnvVar), + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion()); + + internal static string ResolveNodeCommandAllowConfigKey() + => OpenClaw.Shared.GatewayNodeCommandPolicyConfig.ResolveAllowKey(Resolve()) + ?? OpenClaw.Shared.GatewayNodeCommandPolicyConfig.CurrentAllowKey; + + internal static string? ResolveExpectedSha256() => ResolveExpectedSha256( + Environment.GetEnvironmentVariable(SourceEnvVar), + Environment.GetEnvironmentVariable(Sha256EnvVar)); + + internal static string? ResolveExpectedSha256(string? sourceRaw, string? sha256Raw) + { + var source = sourceRaw?.Trim(); + if (!string.Equals(source, "composed", StringComparison.OrdinalIgnoreCase)) + return null; + + var sha256 = sha256Raw?.Trim(); + if (string.IsNullOrWhiteSpace(sha256) || sha256.Length != 64 || !sha256.All(Uri.IsHexDigit)) + { + throw new InvalidOperationException( + $"{Sha256EnvVar} must be the reviewed composed SHA-256 when {SourceEnvVar}=composed."); + } + + return sha256.ToLowerInvariant(); + } + + internal static string Resolve( + string? sourceRaw, + string? packageRaw, + string? sha256Raw, + string? hostDistroRaw, + string? versionRaw, + string lkgSpec) + { + var source = sourceRaw?.Trim(); + if (string.Equals(source, "lkg", StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(packageRaw)) + throw new InvalidOperationException($"{EnvVar} must be empty when {SourceEnvVar}=lkg."); + if (!string.IsNullOrWhiteSpace(sha256Raw)) + throw new InvalidOperationException($"{Sha256EnvVar} must be empty when {SourceEnvVar}=lkg."); + if (!string.IsNullOrWhiteSpace(hostDistroRaw)) + throw new InvalidOperationException($"{HostDistroEnvVar} must be empty when {SourceEnvVar}=lkg."); + if (!string.IsNullOrWhiteSpace(versionRaw)) + throw new InvalidOperationException($"{VersionEnvVar} must be empty when {SourceEnvVar}=lkg."); + return lkgSpec; + } + + if (string.Equals(source, "official", StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(packageRaw)) + throw new InvalidOperationException($"{EnvVar} must be empty when {SourceEnvVar}=official."); + if (!string.IsNullOrWhiteSpace(sha256Raw)) + throw new InvalidOperationException($"{Sha256EnvVar} must be empty when {SourceEnvVar}=official."); + if (!string.IsNullOrWhiteSpace(hostDistroRaw)) + throw new InvalidOperationException($"{HostDistroEnvVar} must be empty when {SourceEnvVar}=official."); + + var version = versionRaw?.Trim(); + if (string.IsNullOrWhiteSpace(version) || + !System.Text.RegularExpressions.Regex.IsMatch( + version, + "^\\d{4}\\.\\d+\\.\\d+-[0-9A-Za-z]+(?:[.-][0-9A-Za-z-]+)*$")) + { + throw new InvalidOperationException( + $"{VersionEnvVar} must be an exact prerelease version when {SourceEnvVar}=official."); + } + + return version; + } + + if (!string.Equals(source, "composed", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"{SourceEnvVar} must be 'lkg', 'official', or 'composed'."); + if (!string.IsNullOrWhiteSpace(versionRaw)) + throw new InvalidOperationException($"{VersionEnvVar} must be empty when {SourceEnvVar}=composed."); + if (string.IsNullOrWhiteSpace(packageRaw)) + throw new InvalidOperationException($"{EnvVar} is required when {SourceEnvVar}=composed."); + if (string.IsNullOrWhiteSpace(sha256Raw) || + sha256Raw.Trim().Length != 64 || + !sha256Raw.Trim().All(Uri.IsHexDigit)) + { + throw new InvalidOperationException( + $"{Sha256EnvVar} must be the reviewed composed SHA-256 when {SourceEnvVar}=composed."); + } + if (string.IsNullOrWhiteSpace(hostDistroRaw) || + !System.Text.RegularExpressions.Regex.IsMatch( + hostDistroRaw.Trim(), + "^OpenClawE2EPackageHost-[A-Za-z0-9-]+$")) + { + throw new InvalidOperationException( + $"{HostDistroEnvVar} must identify the disposable package host when {SourceEnvVar}=composed."); + } + + var value = packageRaw.Trim(); + if (value.Contains('\r') || value.Contains('\n')) + throw new InvalidOperationException($"{EnvVar} cannot contain newlines."); + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + !uri.AbsolutePath.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"{EnvVar} must be an absolute HTTP(S) URL for a built .tgz package."); + } + if (!string.IsNullOrEmpty(uri.UserInfo)) + throw new InvalidOperationException($"{EnvVar} cannot contain credentials."); + + var normalizedSha256 = sha256Raw.Trim().ToLowerInvariant(); + var expectedFileName = $"openclaw-composed-{normalizedSha256}.tgz"; + if (!string.Equals( + Path.GetFileName(uri.AbsolutePath), + expectedFileName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"{EnvVar} must name the content-addressed package for {Sha256EnvVar}."); + } + + return value; + } +} diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs new file mode 100644 index 000000000..97f805978 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs @@ -0,0 +1,245 @@ +using Xunit; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class GatewayE2EPackageSpecTests +{ + private const string FallbackLkg = "2099.1.2"; + private const string OfficialVersion = "2099.1.3-beta.4"; + private const string ComposedSha256 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private const string ComposedHostDistro = "OpenClawE2EPackageHost-Composed"; + + [Fact] + public void PackageHostScript_LeavesWslInstallTargetAbsentUntilInstall() + { + var script = File.ReadAllText(Path.Combine( + RepositoryRoot(), "scripts", "Start-E2EGatewayPackageHost.ps1")); + var install = script.IndexOf("& wsl.exe --install", StringComparison.Ordinal); + var preRegistrationMarker = script.IndexOf( + "Write-OwnershipMarker -MarkerPath $preRegistrationMarkerPath", + StringComparison.Ordinal); + var registeredMarker = script.IndexOf( + "Write-OwnershipMarker -MarkerPath $markerPath", + StringComparison.Ordinal); + + Assert.True(install >= 0); + Assert.True(preRegistrationMarker >= 0 && preRegistrationMarker < install); + Assert.True(registeredMarker > install); + Assert.DoesNotContain( + "New-Item -ItemType Directory -Path $installLocationFull", + script, + StringComparison.Ordinal); + Assert.Contains("sha256sum -- $hostPackagePath", script, StringComparison.Ordinal); + Assert.Contains("chmod 0444 $hostPackagePath", script, StringComparison.Ordinal); + Assert.Contains("$packageSpec = \"http://${hostAddress}:$Port/$hostPackageName\"", script, StringComparison.Ordinal); + Assert.Contains("package_sha256=$actualSha256", script, StringComparison.Ordinal); + } + + [Fact] + public void FormalMxcValidator_VerifiesComposedDigestBeforeE2E() + { + var script = File.ReadAllText(Path.Combine( + RepositoryRoot(), "scripts", "validate-mxc-e2e.ps1")); + var digestVerification = script.IndexOf( + "Assert-ReviewedComposedGatewayPackage", + script.IndexOf("try {", StringComparison.Ordinal), + StringComparison.Ordinal); + var e2eInvocation = script.IndexOf( + "Run Gateway MXC E2E proofs", + StringComparison.Ordinal); + + Assert.True(digestVerification >= 0 && digestVerification < e2eInvocation); + Assert.Contains("OPENCLAW_E2E_GATEWAY_PACKAGE_SHA256", script, StringComparison.Ordinal); + Assert.Contains("OPENCLAW_E2E_GATEWAY_PACKAGE_HOST_DISTRO", script, StringComparison.Ordinal); + Assert.Contains("\"OPENCLAW_E2E_GATEWAY_VERSION\"", script, StringComparison.Ordinal); + Assert.Contains( + "Set-ProcessEnv -Name \"OPENCLAW_E2E_GATEWAY_VERSION\" -Value $null", + script, + StringComparison.Ordinal); + Assert.Contains("openclaw-composed-$normalizedSha256.tgz", script, StringComparison.Ordinal); + Assert.Contains("sha256sum -- $hostPackagePath", script, StringComparison.Ordinal); + Assert.Contains("stat -c \"%a\" -- $hostPackagePath", script, StringComparison.Ordinal); + Assert.DoesNotContain("Invoke-WebRequest", script, StringComparison.Ordinal); + } + + [Fact] + public void E2EConfig_CarriesReviewedDigestIntoSetupEngine() + { + var fixture = File.ReadAllText(Path.Combine( + RepositoryRoot(), "tests", "OpenClaw.E2ETests", "Setup", "E2ESetupFixture.cs")); + + Assert.Contains( + "var expectedGatewayPackageSha256 = GatewayE2EPackageSpec.ResolveExpectedSha256();", + fixture, + StringComparison.Ordinal); + Assert.Contains( + "ExpectedPackageSha256 = expectedGatewayPackageSha256", + fixture, + StringComparison.Ordinal); + } + + [Fact] + public void PackageHostScript_RejectsDigestMismatchBeforeWslMutation() + { + var root = Path.Combine(Path.GetTempPath(), $"openclaw-package-host-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var packagePath = Path.Combine(root, "composed.tgz"); + File.WriteAllText(packagePath, "not-the-reviewed-composed-package"); + var distroName = "OpenClawE2EPackageHost-DigestMismatch"; + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "pwsh.exe", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-File", + Path.Combine(RepositoryRoot(), "scripts", "Start-E2EGatewayPackageHost.ps1"), + "-PackagePath", packagePath, + "-ExpectedSha256", new string('0', 64), + "-DistroName", distroName, + "-InstallLocation", Path.Combine(root, distroName), + "-OwnershipToken", "digest-mismatch-test", + "-GitHubOutput", Path.Combine(root, "github-output.txt"), + }) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start PowerShell."); + var standardOutput = process.StandardOutput.ReadToEnd(); + var standardError = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + Assert.NotEqual(0, process.ExitCode); + Assert.Contains( + "Gateway composed package SHA-256 mismatch", + standardOutput + standardError, + StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(root, distroName))); + Assert.False(File.Exists(Path.Combine(root, "github-output.txt"))); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Resolve_UsesLkgOnlyWhenExplicitlySelected() + { + Assert.Equal(FallbackLkg, GatewayE2EPackageSpec.Resolve( + "lkg", null, null, null, null, FallbackLkg)); + Assert.Equal(FallbackLkg, GatewayE2EPackageSpec.Resolve( + " LKG ", "", "", "", "", FallbackLkg)); + } + + [Fact] + public void Resolve_UsesExactOfficialPrerelease() + { + Assert.Equal(OfficialVersion, GatewayE2EPackageSpec.Resolve( + "official", null, null, null, OfficialVersion, FallbackLkg)); + } + + [Fact] + public void Resolve_AcceptsBuiltPackageUrl() + { + const string packageSpec = + "http://127.0.0.1:38677/openclaw-composed-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.tgz"; + Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve( + "composed", packageSpec, ComposedSha256, ComposedHostDistro, null, FallbackLkg)); + } + + [Fact] + public void ResolveExpectedSha256_CarriesOnlyValidatedComposedDigest() + { + Assert.Equal( + ComposedSha256, + GatewayE2EPackageSpec.ResolveExpectedSha256(" composed ", ComposedSha256.ToUpperInvariant())); + Assert.Null(GatewayE2EPackageSpec.ResolveExpectedSha256("official", null)); + Assert.Throws(() => + GatewayE2EPackageSpec.ResolveExpectedSha256("composed", "not-a-digest")); + } + + [Fact] + public void Resolve_RejectsContentAddressedUrlForDifferentDigest() + { + var differentDigest = new string('f', 64); + var packageSpec = $"https://example.test/openclaw-composed-{differentDigest}.tgz"; + + Assert.Throws(() => + GatewayE2EPackageSpec.Resolve( + "composed", packageSpec, ComposedSha256, ComposedHostDistro, null, FallbackLkg)); + } + + [Theory] + [InlineData(null, null, null, null, null)] + [InlineData("", null, null, null, null)] + [InlineData("other", null, null, null, null)] + [InlineData("composed", null, ComposedSha256, ComposedHostDistro, null)] + [InlineData("composed", "", ComposedSha256, ComposedHostDistro, null)] + [InlineData("composed", "https://example.test/openclaw-composed.tgz", null, ComposedHostDistro, null)] + [InlineData("composed", "https://example.test/openclaw-composed.tgz", "abc", ComposedHostDistro, null)] + [InlineData("composed", "https://example.test/openclaw-composed.tgz", ComposedSha256, ComposedHostDistro, OfficialVersion)] + [InlineData("composed", "https://example.test/openclaw-composed.tgz", ComposedSha256, null, null)] + [InlineData("composed", "https://example.test/openclaw-composed.tgz", ComposedSha256, "Ubuntu", null)] + [InlineData("official", "https://example.test/openclaw-composed.tgz", null, null, OfficialVersion)] + [InlineData("official", null, ComposedSha256, null, OfficialVersion)] + [InlineData("official", null, null, ComposedHostDistro, OfficialVersion)] + [InlineData("official", null, null, null, null)] + [InlineData("official", null, null, null, "beta")] + [InlineData("official", null, null, null, "2099.1.3")] + [InlineData("lkg", "https://example.test/openclaw-composed.tgz", null, null, null)] + [InlineData("lkg", null, ComposedSha256, null, null)] + [InlineData("lkg", null, null, ComposedHostDistro, null)] + [InlineData("lkg", null, null, null, OfficialVersion)] + public void Resolve_RejectsAmbiguousSourceOrSpec( + string? source, + string? packageSpec, + string? sha256, + string? hostDistro, + string? version) + { + Assert.Throws(() => + GatewayE2EPackageSpec.Resolve( + source, packageSpec, sha256, hostDistro, version, FallbackLkg)); + } + + [Theory] + [InlineData("2026.7.2")] + [InlineData("file:///tmp/openclaw-composed.tgz")] + [InlineData("https://user:secret@example.test/openclaw-composed.tgz")] + [InlineData("https://example.test/openclaw-composed.zip")] + [InlineData("https://example.test/openclaw-composed.tgz\n--dangerous")] + public void Resolve_RejectsUnsafeOrNonPackageOverrides(string packageSpec) + { + Assert.Throws(() => + GatewayE2EPackageSpec.Resolve( + "composed", packageSpec, ComposedSha256, ComposedHostDistro, null, FallbackLkg)); + } + + private static string RepositoryRoot() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT") is { Length: > 0 } configured) + { + return configured; + } + + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "openclaw-windows-node.slnx"))) + { + return current.FullName; + } + current = current.Parent; + } + throw new DirectoryNotFoundException("Could not locate the repository root."); + } +} diff --git a/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs index 9a4c53612..a23145d7c 100644 --- a/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/MxcSetupAndConnectTests.cs @@ -197,17 +197,18 @@ private async Task AssertPrimaryTrayReadyAndGatewayCliHealthyAsync() AssertNoPendingRequests(nodes.Stdout); Assert.Contains("windows", nodes.Stdout, StringComparison.OrdinalIgnoreCase); + var nodeCommandAllowConfigKey = GatewayE2EPackageSpec.ResolveNodeCommandAllowConfigKey(); var allowCommands = await _fixture.RunInWslAsync( - "openclaw config get gateway.nodes.allowCommands --json", + $"openclaw config get {nodeCommandAllowConfigKey} --json", TimeSpan.FromSeconds(30), env); - AssertCommandSucceeded(allowCommands, "read gateway.nodes.allowCommands before MXC proof"); + AssertCommandSucceeded(allowCommands, $"read {nodeCommandAllowConfigKey} before MXC proof"); using var allowCommandsDoc = JsonDocument.Parse(ExtractJsonValue(allowCommands.Stdout)); var allowed = ReadStringArray(allowCommandsDoc.RootElement); Assert.Contains(allowed, command => command == "system.run"); Assert.Contains(allowed, command => command == "system.run.prepare"); Assert.Contains(allowed, command => command == "system.which"); - Console.WriteLine("[E2E] gateway.nodes.allowCommands includes system.run/system.run.prepare/system.which"); + Console.WriteLine($"[E2E] {nodeCommandAllowConfigKey} includes system.run/system.run.prepare/system.which"); using var statusDoc = await _fixture.Client!.CallToolExpectSuccessAsync("app.status"); AssertReadyStatus(statusDoc.RootElement); diff --git a/tests/OpenClaw.E2ETests/Setup/RevocationAndRecoveryTests.cs b/tests/OpenClaw.E2ETests/Setup/RevocationAndRecoveryTests.cs index d41c73925..d5d1358b3 100644 --- a/tests/OpenClaw.E2ETests/Setup/RevocationAndRecoveryTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/RevocationAndRecoveryTests.cs @@ -79,7 +79,7 @@ public async Task RealGateway_DeviceRemoval_RecoversThroughSharedTokenReconnect( Assert.True(credentials.HasOperatorToken, $"Expected replacement operator token in {credentials.IdentityDir}"); Assert.True(credentials.HasNodeToken, $"Expected replacement node token in {credentials.IdentityDir}"); - await _fixture.WaitForConnectionReady(TimeSpan.FromSeconds(120)); + await ApprovePendingNodeTrustAndReconnectAsync(deviceId); await _fixture.WaitForNodeListReady(TimeSpan.FromSeconds(90)); using var statusDoc = await _fixture.Client!.CallToolExpectSuccessAsync("app.status"); AssertReadyStatus(statusDoc.RootElement); @@ -94,6 +94,66 @@ public async Task RealGateway_DeviceRemoval_RecoversThroughSharedTokenReconnect( } } + private async Task ApprovePendingNodeTrustAndReconnectAsync(string nodeId) + { + var deadline = DateTime.UtcNow.AddSeconds(30); + string lastOutput = ""; + string? requestId = null; + + while (DateTime.UtcNow < deadline && requestId is null) + { + using var approvals = await _fixture.Client!.CallToolExpectSuccessAsync( + "app.connection.pendingApprovals"); + lastOutput = approvals.RootElement.GetRawText(); + requestId = ReadPendingNodeTrustRequestId(approvals.RootElement, nodeId); + if (requestId is null) + await Task.Delay(500); + } + + Assert.False( + string.IsNullOrWhiteSpace(requestId), + $"Expected a pending node-trust request for {nodeId} after device recovery. Last output: {lastOutput}"); + + using (var approve = await _fixture.Client!.CallToolExpectSuccessAsync( + "app.connection.approveNodePairing", + new { requestId })) + { + var decision = approve.RootElement.GetProperty("decision"); + Assert.Equal("node", decision.GetProperty("kind").GetString()); + Assert.Equal("approve", decision.GetProperty("action").GetString()); + Assert.Equal(requestId, decision.GetProperty("requestId").GetString()); + Assert.True(decision.GetProperty("succeeded").GetBoolean()); + } + + using var reconnect = await _fixture.Client!.CallToolExpectSuccessAsync( + "app.connection.reconnectNode"); + Assert.True(reconnect.RootElement.GetProperty("reconnected").GetBoolean()); + await _fixture.WaitForConnectionReady(TimeSpan.FromSeconds(120)); + } + + private static string? ReadPendingNodeTrustRequestId(JsonElement root, string nodeId) + { + if (!root.TryGetProperty("nodePending", out var pending) || + pending.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var request in pending.EnumerateArray()) + { + if (!request.TryGetProperty("nodeId", out var requestNodeId) || + !string.Equals(requestNodeId.GetString(), nodeId, StringComparison.OrdinalIgnoreCase) || + !request.TryGetProperty("requestId", out var requestId)) + { + continue; + } + + return requestId.GetString(); + } + + return null; + } + private static void AssertCommandSucceeded(CommandResult result, string description) { Assert.False(result.TimedOut, $"{description} timed out"); diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 2d8a07eae..0864011b7 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -8,7 +8,7 @@ namespace OpenClaw.E2ETests.Setup; /// Defines the xUnit test collection that shares the E2ESetupFixture. /// All tests in this collection run against a single setup pipeline execution. /// -[CollectionDefinition("E2E Setup")] +[CollectionDefinition("E2E Setup", DisableParallelization = true)] public class E2ESetupCollection : ICollectionFixture { } /// @@ -113,6 +113,7 @@ public async Task FullSetup_WslAndGatewayConfiguration_FilesValidated() TimeSpan.FromSeconds(15)); AssertCommandSucceeded(openClawJsonProbe, "probe WSL openclaw.json"); Console.WriteLine($"[E2E] WSL openclaw.json probe:\n{openClawJsonProbe.Stdout}"); + var nodeCommandAllowConfigKey = GatewayE2EPackageSpec.ResolveNodeCommandAllowConfigKey(); if (openClawJsonProbe.Stdout.Contains('{', StringComparison.Ordinal)) { @@ -123,7 +124,9 @@ public async Task FullSetup_WslAndGatewayConfiguration_FilesValidated() AssertJsonPath(root, ["gateway", "bind"], "loopback"); AssertJsonPath(root, ["gateway", "auth", "mode"], "token"); - var allowCommands = ReadStringArray(GetJsonPath(root, ["gateway", "nodes", "allowCommands"])); + var allowCommands = nodeCommandAllowConfigKey == "gateway.nodes.allowCommands" + ? ReadStringArray(GetJsonPath(root, ["gateway", "nodes", "allowCommands"])) + : ReadStringArray(GetJsonPath(root, ["gateway", "nodes", "commands", "allow"])); Assert.Equal(new CapabilitiesConfig().GetEnabledCommandIds().ToArray(), allowCommands.Order(StringComparer.OrdinalIgnoreCase).ToArray()); } @@ -140,10 +143,10 @@ public async Task FullSetup_WslAndGatewayConfiguration_FilesValidated() Assert.Contains("token", gatewayAuthMode.Stdout); var cliAllowCommands = await _fixture.RunInWslAsync( - "openclaw config get gateway.nodes.allowCommands", + $"openclaw config get {nodeCommandAllowConfigKey}", TimeSpan.FromSeconds(15)); - AssertCommandSucceeded(cliAllowCommands, "read gateway.nodes.allowCommands"); - Console.WriteLine($"[E2E] gateway.nodes.allowCommands: {cliAllowCommands.Stdout}"); + AssertCommandSucceeded(cliAllowCommands, $"read {nodeCommandAllowConfigKey}"); + Console.WriteLine($"[E2E] {nodeCommandAllowConfigKey}: {cliAllowCommands.Stdout}"); var expectedCommands = new CapabilitiesConfig().GetEnabledCommandIds().ToArray(); var effectiveCommands = ParseJsonArrayFromOutput(cliAllowCommands.Stdout); Assert.Equal(expectedCommands, effectiveCommands.Order(StringComparer.OrdinalIgnoreCase).ToArray()); diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs index dc410cc1b..6b1573463 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -3,6 +3,8 @@ namespace OpenClaw.SetupEngine.Tests; [Collection(EnvironmentVariableCollection.Name)] public sealed class GatewayLkgVersionTests { + private const string ExpectedLkgVersion = "2026.7.1"; + [Fact] public void ResolveLkgVersion_ReturnsEmbeddedLkg() { @@ -18,6 +20,7 @@ public void ApplyToConfig_SetsGatewayVersionWhenUnset() config.Gateway.Version = null; GatewayLkgVersion.ApplyToConfig(config); + Assert.Equal(ExpectedLkgVersion, GatewayLkgVersion.LkgVersion); Assert.Equal(GatewayLkgVersion.LkgVersion, config.Gateway.Version); } @@ -30,5 +33,6 @@ public void ApplyToConfig_DoesNotSetGatewayVersionForCustomInstallUrl() GatewayLkgVersion.ApplyToConfig(config); Assert.Null(config.Gateway.Version); + Assert.Equal("https://contoso.example/install-cli.sh", config.Gateway.InstallUrl); } } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index efea60100..e09231287 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -84,6 +84,26 @@ public void EffectiveGatewayUrl_PreferExplicitUrl() Assert.Equal("ws://custom:1234", config.EffectiveGatewayUrl); } + [Fact] + public void GatewayExpectedPackageSha256_RoundTripsThroughConfigFile() + { + var path = Path.Combine(_tempDir, "digest-config.json"); + var expectedSha256 = new string('a', 64); + var config = new SetupConfig + { + Gateway = new GatewayConfig + { + Version = "https://example.test/openclaw.tgz", + ExpectedPackageSha256 = expectedSha256 + } + }; + File.WriteAllText(path, JsonSerializer.Serialize(config, SetupConfig.JsonWriteOptions)); + + var loaded = SetupConfig.LoadFromFile(path); + + Assert.Equal(expectedSha256, loaded.Gateway.ExpectedPackageSha256); + } + [Fact] public void TailscaleConfig_NormalizesHostnameAndRejectsIncompatibleGatewaySettings() { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs index b5bcf9ec4..e6d01f77c 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs @@ -60,7 +60,7 @@ public void BuildDefaultSteps_IncludesCurrentSetupFlow() { var steps = SetupStepFactory.BuildDefaultSteps(); - Assert.Equal(24, steps.Count); + Assert.Equal(25, steps.Count); Assert.IsType(steps[0]); Assert.IsType(steps[1]); Assert.IsType(steps[2]); @@ -73,15 +73,19 @@ public void BuildDefaultSteps_IncludesCurrentSetupFlow() Assert.Equal(lockdownIndex + 1, cliInstallIndex); Assert.IsType(steps[cliInstallIndex + 1]); Assert.IsType(steps[cliInstallIndex + 2]); + var configureGatewayIndex = steps.FindIndex(s => s is ConfigureGatewayStep); + Assert.IsType(steps[configureGatewayIndex - 1]); var installServiceIndex = steps.FindIndex(s => s is InstallGatewayServiceStep); - Assert.IsType(steps[installServiceIndex + 1]); - Assert.IsType(steps[installServiceIndex + 2]); + Assert.IsType(steps[installServiceIndex + 1]); + Assert.IsType(steps[installServiceIndex + 2]); + Assert.IsType(steps[installServiceIndex + 3]); + Assert.DoesNotContain(steps, s => s is StartGatewayStep); Assert.Contains(steps, s => s is RunGatewayWizardStep); var pairNodeIndex = steps.FindIndex(s => s is PairNodeStep); Assert.IsType(steps[pairNodeIndex + 1]); var wizardIndex = steps.FindIndex(s => s is RunGatewayWizardStep); Assert.IsType(steps[wizardIndex + 1]); - Assert.IsType(steps[^1]); + Assert.True(installServiceIndex < wizardIndex); } [Theory] @@ -127,6 +131,7 @@ public void BuildWizardOnlySteps_FinalizesWindowsNodeContextAfterWizard() Assert.Collection( steps, + step => Assert.IsType(step), step => Assert.IsType(step), step => Assert.IsType(step)); } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index c7515c399..83ad07c07 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -801,6 +801,103 @@ public async Task CleanupStaleGateway_SkippedWhenCleanBeforeRunFalse() Assert.True(step.CanSkip(ctx)); } + [Fact] + public async Task CleanupStaleDistro_DiscardsReloadRecoveryAfterProvingDistroAbsent() + { + var commands = new FakeCommandRunner(_ => Ok()); + var ctx = CreateContext(commands: commands); + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + var result = await new CleanupStaleDistroStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task CleanupStaleDistro_SucceedsWhenRecoveryDirectoryNeverExisted() + { + var commands = new FakeCommandRunner(_ => Ok()); + var ctx = CreateContext(commands: commands); + Directory.Delete(ctx.LocalDataDir, recursive: true); + Assert.False(Directory.Exists(ctx.LocalDataDir)); + + var result = await new CleanupStaleDistroStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.False(Directory.Exists(ctx.LocalDataDir)); + } + + [Fact] + public async Task CleanupStaleDistro_PreservesReloadRecoveryForDifferentDistro() + { + var commands = new FakeCommandRunner(_ => Ok()); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "old-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + ctx.DistroName = "new-distro"; + + var result = await new CleanupStaleDistroStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("Refusing to discard recovery state", result.Message); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task CleanupStaleDistro_PreservesReloadRecoveryWhenDistroListFails() + { + var commands = new FakeCommandRunner(_ => Fail("WSL list failed")); + var ctx = CreateContext(commands: commands); + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + var result = await new CleanupStaleDistroStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task RecoverGatewayReload_RestoresPendingModeBeforeApplyingCurrentConfiguration() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("GATEWAY_CONFIGURED") => Ok("GATEWAY_CONFIGURED"), + var value when value.Contains("config set gateway.reload.mode 'restart'") => Ok(), + var value when value.Contains("config set gateway.reload.mode 'hot'") => Ok(), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext( + new SetupConfig + { + CleanBeforeRun = false, + Gateway = new GatewayConfig + { + Version = "https://127.0.0.1/openclaw-composed.tgz", + ReloadMode = "hot", + }, + }, + commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "restart"); + + var result = await new SetupPipeline( + [new RecoverGatewayReloadStep(), new ConfigureGatewayStep()], + rollbackOnFailureOverride: false).RunAsync(ctx); + + Assert.Equal(PipelineOutcome.Success, result.Outcome); + Assert.Contains("config set gateway.reload.mode 'restart'", commands.WslCalls[0].Command); + var configureCall = Assert.Single(commands.WslCalls, call => call.Command.Contains("GATEWAY_CONFIGURED")); + Assert.Contains("config set gateway.reload.mode 'hot'", configureCall.Command); + Assert.True(commands.WslCalls.IndexOf(configureCall) > 0); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + // ─── InstallCliStep: URL validation and quoting ─── [Fact] @@ -936,6 +1033,72 @@ public void InstallCli_BuildInstallCommand_EscapesSingleQuotesInUrlAndVersion() Assert.Equal("curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli'\\''s.sh' | bash -s -- --version '2026.5.22'\\''a'", command); } + [Fact] + public void InstallCli_BuildInstallCommand_VerifiesDownloadedPackageBeforeInstallerConsumesIt() + { + const string packageUrl = "http://172.24.1.2:38677/openclaw-composed.tgz"; + const string expectedSha256 = + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + var command = InstallCliStep.BuildInstallCommand( + "https://openclaw.ai/install-cli.sh", + packageUrl, + expectedSha256); + + var packageDownload = command.IndexOf(packageUrl, StringComparison.Ordinal); + var digestCheck = command.IndexOf("sha256sum --check --strict -", StringComparison.Ordinal); + var installerInvocation = command.IndexOf( + "bash \"$installer_path\" --version \"$package_path\"", + StringComparison.Ordinal); + + Assert.True(packageDownload >= 0 && packageDownload < digestCheck); + Assert.True(digestCheck >= 0 && digestCheck < installerInvocation); + Assert.Contains("&& printf '%s %s\\n' '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'", command, StringComparison.Ordinal); + Assert.Contains("&& curl -fsSL --proto '=http'", command, StringComparison.Ordinal); + Assert.DoesNotContain($"--version '{packageUrl}'", command, StringComparison.Ordinal); + } + + [Fact] + public async Task InstallCli_DigestMismatchFailsBeforePostInstallVerification() + { + var commands = new FakeCommandRunner( + _ => Fail("Windows commands are not expected"), + (_, command, _) => command.Contains("sha256sum --check", StringComparison.Ordinal) + ? Fail("openclaw.tgz: FAILED") + : Fail($"unexpected post-install command: {command}")); + var ctx = CreateContext(new SetupConfig + { + Gateway = new GatewayConfig + { + Version = "http://172.24.1.2:38677/openclaw-composed.tgz", + ExpectedPackageSha256 = new string('a', 64) + } + }, commands); + + var result = await new InstallCliStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("CLI install failed", result.Message, StringComparison.Ordinal); + var installCall = Assert.Single(commands.WslCalls); + Assert.Contains("sha256sum --check --strict -", installCall.Command, StringComparison.Ordinal); + Assert.Contains("&& bash \"$installer_path\" --version \"$package_path\"", installCall.Command, StringComparison.Ordinal); + Assert.True(installCall.InputViaStdin); + } + + [Theory] + [InlineData("not-a-digest", "http://example.test/openclaw.tgz")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "2026.7.2-beta.3")] + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "http://user:secret@example.test/openclaw.tgz")] + public void InstallCli_BuildInstallCommand_RejectsInvalidDigestContract( + string expectedSha256, + string packageSpec) + { + Assert.Throws(() => InstallCliStep.BuildInstallCommand( + "https://openclaw.ai/install-cli.sh", + packageSpec, + expectedSha256)); + } + [Fact] public async Task PreflightWsl_FailsForUnsupportedDirectInstallVersion() { @@ -1565,6 +1728,1046 @@ public void ConfigureGateway_RejectsUnsafeExtraConfigKeys(string key) Assert.False(ConfigureGatewayStep.IsSafeExtraConfigKey(key)); } + [Theory] + [InlineData("https://127.0.0.1/openclaw-composed.tgz", "hot")] + [InlineData("https://127.0.0.1/openclaw-composed.tgz", "restart")] + public void ConfigureGateway_PreservesSupportedReloadModesForCurrentSchema( + string version, + string reloadMode) + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig + { + Version = version, + ReloadMode = reloadMode, + }, + 18789, + "'[]'"); + + Assert.Contains($"openclaw config set gateway.reload.mode '{reloadMode}'", commands); + } + + [Theory] + [InlineData("2026.6.11", "hot")] + [InlineData("2026.6.11", "restart")] + [InlineData("2026.7.1", "hot")] + [InlineData("2026.7.1", "restart")] + [InlineData("2026.7.2-beta.3", "hot")] + [InlineData("2026.7.2-beta.3", "restart")] + public void ConfigureGateway_PreservesLegacyReloadModesForLegacySchemas( + string version, + string reloadMode) + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig { Version = version, ReloadMode = reloadMode }, + 18789, + "'[]'"); + + Assert.Contains($"openclaw config set gateway.reload.mode '{reloadMode}'", commands); + } + + [Fact] + public void ConfigureGateway_WritesCurrentNestedNodeCommandAllowlist() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig(), + 18789, + "'[\"system.run\"]'"); + + Assert.Contains( + "openclaw config set gateway.nodes.commands.allow '[\"system.run\"]'", + commands); + Assert.DoesNotContain("gateway.nodes.allowCommands", commands, StringComparison.Ordinal); + } + + [Fact] + public void ConfigureGateway_PreservesPinnedLkgNodeCommandAllowlistContract() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig { Version = GatewayLkgVersion.LkgVersion }, + 18789, + "'[\"system.run\"]'"); + + Assert.Contains( + "openclaw config set gateway.nodes.allowCommands '[\"system.run\"]'", + commands); + Assert.DoesNotContain("gateway.nodes.commands.allow", commands, StringComparison.Ordinal); + } + + [Fact] + public void ConfigureGateway_PreservesOfficialBetaNodeCommandAllowlistContract() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig { Version = "2026.7.2-beta.3" }, + 18789, + "'[\"system.run\"]'"); + + Assert.Contains( + "openclaw config set gateway.nodes.allowCommands '[\"system.run\"]'", + commands); + Assert.DoesNotContain("gateway.nodes.commands.allow", commands, StringComparison.Ordinal); + } + + [Fact] + public void ConfigureGateway_PreservesLegacyNodeCommandAllowlistContract() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig { Version = "2026.6.11" }, + 18789, + "'[\"system.run\"]'"); + + Assert.Contains( + "openclaw config set gateway.nodes.allowCommands '[\"system.run\"]'", + commands); + Assert.DoesNotContain("gateway.nodes.commands.allow", commands, StringComparison.Ordinal); + } + + [Fact] + public void ConfigureGateway_ExtraConfigOverridesReloadMode() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig + { + Version = "https://127.0.0.1/openclaw-composed.tgz", + ExtraConfig = new Dictionary + { + ["gateway.reload.mode"] = "restart", + }, + }, + 18789, + "'[]'"); + + Assert.DoesNotContain("openclaw config set gateway.reload.mode off", commands); + Assert.Equal(1, commands.Split("gateway.reload.mode", StringSplitOptions.None).Length - 1); + Assert.Contains("openclaw config set gateway.reload.mode 'restart'", commands); + } + + [Fact] + public void ConfigureGateway_EffectiveReloadModeUsesExtraConfigOverride() + { + var config = new GatewayConfig + { + Version = "https://127.0.0.1/openclaw-composed.tgz", + ReloadMode = "hot", + ExtraConfig = new Dictionary + { + ["gateway.reload.mode"] = "restart", + }, + }; + + Assert.Equal("restart", ConfigureGatewayStep.GetEffectiveReloadMode(config)); + } + + [Fact] + public async Task StartGateway_RestartUsesRestartCommandAndWaitsForHealth() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("ss -tlnp")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartRejectsUnrelatedPortOwner() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => + Ok("LISTEN 0 4096 127.0.0.1:18789 users:((\"python\",pid=42,fd=3))"), + var value when value.Contains("systemctl --user show openclaw-gateway.service") => Ok("967"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("already in use by another or unattributable process", result.Message, StringComparison.Ordinal); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartRejectsPortOwnershipProbeFailure() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("ss -tlnp") + ? Fail("ss unavailable") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("could not inspect port ownership", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain( + commands.WslCalls, + call => call.Command.Contains("systemctl --user show openclaw-gateway.service")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartRejectsUnattributableListener() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("ss -tlnp") + ? Ok("LISTEN 0 4096 127.0.0.1:18789 0.0.0.0:*") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("could not be attributed", result.Message, StringComparison.Ordinal); + Assert.DoesNotContain( + commands.WslCalls, + call => call.Command.Contains("systemctl --user show openclaw-gateway.service")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartAcceptsListenerOwnedByManagedGatewayService() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => + Ok("LISTEN 0 511 0.0.0.0:18789 0.0.0.0:* users:((\"MainThread\",pid=967,fd=27))"), + var value when value.Contains("systemctl --user show openclaw-gateway.service") => Ok("967"), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains( + commands.WslCalls, + call => call.Command.Contains("systemctl --user show openclaw-gateway.service")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartRejectsUnattributableListenerAlongsideManagedListener() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => + Ok( + """ + LISTEN 0 511 0.0.0.0:18789 0.0.0.0:* users:(("MainThread",pid=967,fd=27)) + LISTEN 0 511 [::]:18789 [::]:* + """), + var value when value.Contains("systemctl --user show openclaw-gateway.service") => Ok("967"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("could not be attributed", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_RestartIgnoresListenersOnOtherPorts() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => + Ok( + """ + LISTEN 0 511 0.0.0.0:18789 0.0.0.0:* users:(("MainThread",pid=967,fd=27)) + LISTEN 0 511 127.0.0.1:18888 0.0.0.0:* users:(("python",pid=42,fd=3)) + """), + var value when value.Contains("systemctl --user show openclaw-gateway.service") => Ok("967"), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task StartGateway_ReusesListenerOwnedByManagedGatewayService() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => + Ok("LISTEN 0 511 0.0.0.0:18789 0.0.0.0:* users:((\"MainThread\",pid=967,fd=27))"), + var value when value.Contains("systemctl --user show openclaw-gateway.service") => Ok("967"), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new StartGatewayStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway start")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("curl -s")); + } + + [Fact] + public async Task SetupWizard_RestoreReloadModeRestartsAndVerifiesGateway() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config set gateway.reload.mode 'hot'") => Ok(), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext( + new SetupConfig { Gateway = new GatewayConfig { ReloadMode = "hot" } }, + commands); + ctx.DistroName = "test-distro"; + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hot'")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task SetupWizard_SuspendReloadModeRestartsAndVerifiesGateway() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config get gateway.reload.mode --json") => Ok("\"restart\""), + var value when value.Contains("config set gateway.reload.mode off") => Ok(), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new SetupWizardRunner(ctx).SuspendReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Equal(5, commands.WslCalls.Count); + Assert.Contains("config get gateway.reload.mode --json", commands.WslCalls[0].Command); + Assert.Contains("config set gateway.reload.mode off", commands.WslCalls[1].Command); + Assert.Contains("ss -tlnp", commands.WslCalls[2].Command); + Assert.Contains("openclaw gateway restart", commands.WslCalls[3].Command); + Assert.Contains("curl -s", commands.WslCalls[4].Command); + var recovery = GatewayReloadRecoveryStore.Load(ctx); + Assert.NotNull(recovery); + Assert.Equal("restart", recovery.ReloadMode); + Assert.Equal("test-distro", recovery.DistroName); + } + + [Fact] + public async Task SetupWizard_SuspendReloadModePreservesAbsentConfigPath() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config get gateway.reload.mode --json") => + Fail("Config path not found: gateway.reload.mode"), + var value when value.Contains("config set gateway.reload.mode off") => Ok(), + var value when value.Contains("config unset gateway.reload.mode") => Ok(), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var runner = new SetupWizardRunner(ctx); + var result = await runner.SuspendReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + var recovery = GatewayReloadRecoveryStore.Load(ctx); + Assert.NotNull(recovery); + Assert.Null(recovery.ReloadMode); + + var restoreResult = await runner.RestoreReloadModeAsync(); + + Assert.True(restoreResult.IsSuccess, restoreResult.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config unset gateway.reload.mode")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hybrid'")); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_RestoreAbsentReloadModeIsIdempotentAfterUnsetSucceeded() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config unset gateway.reload.mode") => + Fail("Config path not found: gateway.reload.mode. Nothing was changed."), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, reloadMode: null); + + var result = await new SetupWizardRunner(ctx).RestoreReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config unset gateway.reload.mode")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_WritesRecoveryMarkerBeforeSuspendingReload() + { + var markerObserved = false; + SetupContext? ctx = null; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("config get gateway.reload.mode --json")) + return Ok("\"hot\""); + if (command.Contains("config set gateway.reload.mode off")) + { + markerObserved = GatewayReloadRecoveryStore.Load(ctx!) is + { + DistroName: "test-distro", + ReloadMode: "hot", + }; + return Ok(); + } + if (command.Contains("ss -tlnp")) + return Ok(); + if (command.Contains("openclaw gateway restart")) + return Ok(); + if (command.Contains("curl -s")) + return Ok("200"); + return Fail($"Unexpected command: {command}"); + }); + ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new SetupWizardRunner(ctx).SuspendReloadModeAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.True(markerObserved); + } + + [Fact] + public async Task SetupWizard_DoesNotMutateWslWhenRecoveryMarkerCannotBePersisted() + { + var blockedLocalDataDir = Path.Combine(_localTempDir, "blocked"); + await File.WriteAllTextAsync(blockedLocalDataDir, "not-a-directory"); + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => command.Contains("config get gateway.reload.mode --json") + ? Ok("\"hot\"") + : Fail($"Unexpected WSL command: {command}")); + var logger = new SetupLogger(filePath: null, LogLevel.Trace); + var ctx = new SetupContext( + new SetupConfig(), + logger, + new TransactionJournal(filePath: null), + commands, + CancellationToken.None, + dataDir: _tempDir, + localDataDir: blockedLocalDataDir); + ctx.DistroName = "test-distro"; + + var result = await new SetupWizardRunner(ctx).BeginReloadSuspensionAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains("Failed to suspend gateway reload", result.Message); + Assert.Single(commands.WslCalls); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("config set")); + } + + [Fact] + public async Task SetupWizard_ReconcilesRecoveryBeforeSkippingWizard() + { + var commands = CreateReloadRestorationRunner(); + var ctx = CreateContext(new SetupConfig + { + SkipWizard = true, + Gateway = new GatewayConfig { ReloadMode = "restart" }, + }, commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + var result = await new SetupWizardRunner(ctx).RunAsync(CancellationToken.None); + + Assert.Equal(StepOutcome.Skipped, result.Outcome); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hot'")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("gateway.reload.mode off")); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_BeginSuspensionReconcilesPreviousRecoveryFirst() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("config set gateway.reload.mode 'restart'")) + return Ok(); + if (command.Contains("config get gateway.reload.mode --json")) + return Ok("\"hot\""); + if (command.Contains("config set gateway.reload.mode off")) + return Ok(); + if (command.Contains("ss -tlnp")) + return Ok(); + if (command.Contains("openclaw gateway restart")) + return Ok(); + if (command.Contains("curl -s")) + return Ok("200"); + return Fail($"Unexpected command: {command}"); + }); + var ctx = CreateContext(new SetupConfig + { + Gateway = new GatewayConfig { ReloadMode = "hot" }, + }, commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "restart"); + + var result = await new SetupWizardRunner(ctx).BeginReloadSuspensionAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains("config set gateway.reload.mode 'restart'", commands.WslCalls[0].Command); + var offCall = commands.WslCalls.FindIndex(call => call.Command.Contains("gateway.reload.mode off")); + Assert.True(offCall > 0); + var recovery = GatewayReloadRecoveryStore.Load(ctx); + Assert.NotNull(recovery); + Assert.Equal("hot", recovery.ReloadMode); + } + + [Fact] + public async Task SetupWizard_FailedRecoveryLeavesMarkerForRetry() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode 'hot'") + ? Fail("restore failed") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + var result = await new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); + + Assert.False(result.IsSuccess); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_FailedRecoveryRestartLeavesMarkerForRetry() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("config set gateway.reload.mode 'hot'")) + return Ok(); + if (command.Contains("openclaw gateway restart")) + return Fail("restart failed"); + return Fail($"Unexpected command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + var result = await new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); + + Assert.False(result.IsSuccess); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_SuccessfulRetryClearsInMemoryRecoveryState() + { + var allowRestore = false; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("config get gateway.reload.mode --json")) + return Ok("\"hot\""); + if (command.Contains("config set gateway.reload.mode off")) + return Fail("suspension failed"); + if (command.Contains("config set gateway.reload.mode 'hot'")) + return allowRestore ? Ok() : Fail("restore failed"); + if (command.Contains("ss -tlnp")) + return Ok(); + if (command.Contains("openclaw gateway restart")) + return Ok(); + if (command.Contains("curl -s")) + return Ok("200"); + return Fail($"Unexpected command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + var runner = new SetupWizardRunner(ctx); + + var failedSuspension = await runner.BeginReloadSuspensionAsync(); + Assert.False(failedSuspension.IsSuccess); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + + allowRestore = true; + var recovered = await runner.ReconcilePendingReloadRecoveryAsync(); + Assert.True(recovered.IsSuccess, recovered.Message); + + Directory.CreateDirectory(GatewayReloadRecoveryStore.GetPath(ctx)); + commands.WslCalls.Clear(); + var failedMarkerWrite = await runner.BeginReloadSuspensionAsync(); + + Assert.False(failedMarkerWrite.IsSuccess); + Assert.Empty(commands.WslCalls); + } + + [Fact] + public void SetupWizard_RecoveryMarkerContainsOnlyBoundedMetadata() + { + var ctx = CreateContext(); + ctx.DistroName = "test-distro"; + + GatewayReloadRecoveryStore.Save(ctx, "hot"); + + using var marker = JsonDocument.Parse(File.ReadAllText(GatewayReloadRecoveryStore.GetPath(ctx))); + var propertyNames = marker.RootElement.EnumerateObject() + .Select(property => property.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + Assert.Equal(["CreatedAtUtc", "DistroName", "ReloadMode", "Version"], propertyNames); + } + + [Fact] + public async Task SetupWizard_MalformedRecoveryFailsClosedWithoutWslMutation() + { + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => Fail($"Unexpected WSL command: {command}")); + var ctx = CreateContext(new SetupConfig { SkipWizard = true }, commands); + await File.WriteAllTextAsync(GatewayReloadRecoveryStore.GetPath(ctx), "{not-json"); + + var result = await new SetupWizardRunner(ctx).RunAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("Cannot reconcile gateway reload recovery state", result.Message); + Assert.Empty(commands.WslCalls); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_UnreadableRecoveryPathFailsClosedWithoutWslMutation() + { + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => Fail($"Unexpected WSL command: {command}")); + var ctx = CreateContext(new SetupConfig { SkipWizard = true }, commands); + Directory.CreateDirectory(GatewayReloadRecoveryStore.GetPath(ctx)); + + var result = await new SetupWizardRunner(ctx).RunAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("Cannot reconcile gateway reload recovery state", result.Message); + Assert.Empty(commands.WslCalls); + Assert.True(Directory.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_UnsupportedRecoveryModeFailsClosedWithoutWslMutation() + { + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => Fail($"Unexpected WSL command: {command}")); + var ctx = CreateContext(new SetupConfig { SkipWizard = true }, commands); + await File.WriteAllTextAsync( + GatewayReloadRecoveryStore.GetPath(ctx), + """ + { + "Version": 1, + "DistroName": "OpenClawGateway", + "ReloadMode": "bogus", + "CreatedAtUtc": "2026-07-18T00:00:00Z" + } + """); + + var result = await new SetupWizardRunner(ctx).RunAsync(CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("unsupported reload mode", result.Message); + Assert.Empty(commands.WslCalls); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_RecoveryForDifferentDistroFailsClosed() + { + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => Fail($"Unexpected WSL command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "old-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + ctx.DistroName = "new-distro"; + + var result = await new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains("Refusing to mutate either distro", result.Message); + Assert.Empty(commands.WslCalls); + Assert.True(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_RecoveryAcceptsEquivalentDistroNameCasing() + { + var commands = CreateReloadRestorationRunner(); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + GatewayReloadRecoveryStore.Save(ctx, "hot"); + ctx.DistroName = "TEST-DISTRO"; + + var result = await new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); + + Assert.True(result.IsSuccess, result.Message); + Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); + } + + [Fact] + public async Task SetupWizard_BeginReloadSuspensionRestoresWhenSuspensionFails() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("config get gateway.reload.mode --json")) + return Ok("\"hot\""); + if (command.Contains("config set gateway.reload.mode off")) + return Fail("reload suspension failed"); + if (command.Contains("config set gateway.reload.mode 'hot'")) + return Ok(); + if (command.Contains("ss -tlnp")) + return Ok(); + if (command.Contains("openclaw gateway restart")) + return Ok(); + if (command.Contains("curl -s") && command.Contains("/health")) + return Ok("200"); + return Fail($"Unexpected command: {command}"); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new SetupWizardRunner(ctx).BeginReloadSuspensionAsync(); + + Assert.False(result.IsSuccess); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hot'")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task InstallGatewayService_ActivatesOnceWithoutStartingOrPolling() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("openclaw gateway install --force") => Ok(), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new InstallGatewayServiceStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Single(commands.WslCalls, call => call.Command.Contains("openclaw gateway install --force")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway start")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("curl -s")); + } + + [Fact] + public async Task WaitForGatewayHealth_PollsWithoutStartingOrInstalling() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new WaitForGatewayHealthStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.True(result.IsSuccess, result.Message); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("curl -s")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("gateway install")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("gateway start")); + } + + [Fact] + public async Task WaitForGatewayHealth_RejectsUnrelatedPortOwnerBeforePolling() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("ss -tlnp") + ? Ok("LISTEN 0 4096 127.0.0.1:18789 users:((\"other-service\",pid=42,fd=7))") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var result = await new WaitForGatewayHealthStep().ExecuteAsync(ctx, CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Contains("already in use by another or unattributable process", result.Message); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("curl -s")); + } + + [Fact] + public async Task SetupWizard_DoesNotSuspendReloadWhenRegistryLoadThrows() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + + var registryPath = Path.Combine(_tempDir, "gateways.json"); + await using var registryLock = new FileStream( + registryPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None); + + await Assert.ThrowsAsync(() => new SetupWizardRunner(ctx).RunAsync(CancellationToken.None)); + + Assert.Empty(commands.WslCalls); + } + + [Fact] + public async Task SetupWizard_SkipWizardDoesNotRunOrSuspendGateway() + { + var commands = new FakeCommandRunner( + _ => Fail("Unexpected local command"), + (_, command, _) => Fail($"Unexpected WSL command: {command}")); + var ctx = CreateContext(new SetupConfig { SkipWizard = true }, commands); + + var result = await new SetupWizardRunner(ctx).RunAsync(CancellationToken.None); + + Assert.Equal(StepOutcome.Skipped, result.Outcome); + Assert.Empty(commands.WslCalls); + } + + [Theory] + [InlineData(ConnectionStatus.Error)] + [InlineData(ConnectionStatus.Disconnected)] + public void SetupWizard_ReconnectWaitIgnoresTransientConnectionFailures(ConnectionStatus status) + { + var outcome = PairOperatorStep.ConnectionOutcomeForStatus( + status, + pairingRequired: false, + authFailed: false, + waitThroughTransientErrors: true); + + Assert.Null(outcome); + } + + [Theory] + [InlineData(ConnectionStatus.Error)] + [InlineData(ConnectionStatus.Disconnected)] + public void SetupWizard_NormalConnectionWaitStillFailsFast(ConnectionStatus status) + { + var outcome = PairOperatorStep.ConnectionOutcomeForStatus( + status, + pairingRequired: false, + authFailed: false, + waitThroughTransientErrors: false); + + Assert.Equal(PairOperatorStep.ConnectionOutcome.Error, outcome); + } + + [Fact] + public void SetupWizard_ReconnectWaitStillSurfacesPairingRequired() + { + var outcome = PairOperatorStep.ConnectionOutcomeForStatus( + ConnectionStatus.Disconnected, + pairingRequired: true, + authFailed: false, + waitThroughTransientErrors: true); + + Assert.Equal(PairOperatorStep.ConnectionOutcome.PairingRequired, outcome); + } + + [Theory] + [InlineData(ConnectionStatus.Error)] + [InlineData(ConnectionStatus.Disconnected)] + public void SetupWizard_ReconnectWaitFailsFastOnAuthenticationError(ConnectionStatus status) + { + var outcome = PairOperatorStep.ConnectionOutcomeForStatus( + status, + pairingRequired: false, + authFailed: true, + waitThroughTransientErrors: true); + + Assert.Equal(PairOperatorStep.ConnectionOutcome.Error, outcome); + } + + [Fact] + public async Task SetupWizard_OrchestratorRestoresAfterSuccessfulWizard() + { + var commands = CreateReloadRestorationRunner(); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + var runner = new SetupWizardRunner(ctx); + + var result = await runner.RunWithReloadRestorationAsync(() => + { + runner.MarkReloadSuspended(); + return Task.FromResult(StepResult.Ok("wizard complete")); + }); + + Assert.True(result.IsSuccess, result.Message); + AssertReloadRestorationCompleted(commands); + } + + [Fact] + public async Task SetupWizard_OrchestratorRestoresBeforePropagatingCancellation() + { + var commands = CreateReloadRestorationRunner(); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + var runner = new SetupWizardRunner(ctx); + + await Assert.ThrowsAsync(() => + runner.RunWithReloadRestorationAsync(async () => + { + runner.MarkReloadSuspended(); + await Task.Yield(); + throw new OperationCanceledException(); + })); + + AssertReloadRestorationCompleted(commands); + } + + [Fact] + public async Task SetupWizard_OrchestratorReturnsRestorationFailure() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode 'hybrid'") + ? Fail("restore failed") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + var runner = new SetupWizardRunner(ctx); + + var result = await runner.RunWithReloadRestorationAsync(() => + { + runner.MarkReloadSuspended(); + return Task.FromResult(StepResult.Ok("wizard complete")); + }); + + Assert.False(result.IsSuccess); + Assert.Contains("Failed to restore gateway.reload.mode", result.Message); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [Fact] + public async Task SetupWizard_OrchestratorPrioritizesRestorationFailureOverCancellation() + { + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => command.Contains("config set gateway.reload.mode 'hybrid'") + ? Fail("restore failed") + : Fail($"Unexpected command: {command}")); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + var runner = new SetupWizardRunner(ctx); + + var result = await runner.RunWithReloadRestorationAsync(async () => + { + runner.MarkReloadSuspended(); + await Task.Yield(); + throw new OperationCanceledException(); + }); + + Assert.False(result.IsSuccess); + Assert.Contains("Failed to restore gateway.reload.mode", result.Message); + var aggregate = Assert.IsType(result.Error); + Assert.Contains(aggregate.InnerExceptions, error => error is OperationCanceledException); + } + + private static FakeCommandRunner CreateReloadRestorationRunner() => + new( + _ => Ok(), + (_, command, _) => command switch + { + var value when value.Contains("config set gateway.reload.mode 'hybrid'") => Ok(), + var value when value.Contains("config set gateway.reload.mode 'hot'") => Ok(), + var value when value.Contains("ss -tlnp") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + _ => Fail($"Unexpected command: {command}"), + }); + + private static void AssertReloadRestorationCompleted(FakeCommandRunner commands) + { + Assert.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hybrid'")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("curl -s")); + } + [Fact] public void ConfigureGateway_AddsDevicePairPublicUrlForLoopbackGateway() { diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index 3fb021718..f306e99d1 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -1380,7 +1380,7 @@ public void NodeCapabilityHealthInfo_SeparatesSafeAndDangerousPolicyBlocks() } }; - var info = NodeCapabilityHealthInfo.FromNode(node); + var info = NodeCapabilityHealthInfo.FromNode(node, "2026.7.3"); Assert.Contains("canvas.present", info.MissingSafeAllowlistCommands); Assert.Contains("screen.snapshot", info.MissingSafeAllowlistCommands); @@ -1425,15 +1425,21 @@ public void NodeCapabilityHealthInfo_WarnsSpecificallyForBlockedBrowserProxy() } }; - var info = NodeCapabilityHealthInfo.FromNode(node); + var info = NodeCapabilityHealthInfo.FromNode(node, "2026.7.3"); Assert.Contains("browser.proxy", info.BrowserApprovedCommands); Assert.Contains("browser.proxy", info.MissingBrowserAllowlistCommands); Assert.DoesNotContain("browser.proxy", info.MissingMacParityCommands); Assert.Contains(info.Warnings, w => w.Title == "Browser proxy command is filtered by gateway policy" && - w.RepairAction == "Copy browser proxy allowlist repair command" && - w.CopyText == "openclaw config set gateway.nodes.allowCommands '[\"browser.proxy\"]'"); + w.RepairAction == "Copy browser proxy allowlist merge guidance" && + w.CopyText?.Contains( + "openclaw config get gateway.nodes.commands.allow --json", + StringComparison.Ordinal) == true && + w.CopyText.Contains("Preserve every existing entry", StringComparison.Ordinal) && + !w.CopyText.Contains( + "openclaw config set gateway.nodes.commands.allow '[\"browser.proxy\"]'", + StringComparison.Ordinal)); Assert.DoesNotContain(info.Warnings, w => w.Title == "Some node commands are filtered"); } @@ -1485,12 +1491,101 @@ public void NodeCapabilityHealthInfo_TreatsDisabledCommandsAsSettingsChoice() } [Fact] - public void BuildAllowCommandsRepairCommand_IsStableAndDeduplicated() + public void BuildAllowCommandsMergeGuidance_PreservesExistingArray() + { + var guidance = CommandCenterDiagnostics.BuildAllowCommandsMergeGuidance( + ["screen.snapshot", "canvas.present", "screen.snapshot"], + "2026.7.3"); + + Assert.Contains( + "openclaw config get gateway.nodes.commands.allow --json", + guidance, + StringComparison.Ordinal); + Assert.Contains("Preserve every existing entry", guidance, StringComparison.Ordinal); + Assert.Contains("canvas.present, screen.snapshot", guidance, StringComparison.Ordinal); + Assert.DoesNotContain( + "openclaw config set gateway.nodes.commands.allow '[\"canvas.present\",\"screen.snapshot\"]'", + guidance, + StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveAllowKey_ReturnsNullWhenGatewayVersionUnavailable(string? gatewayVersion) + { + Assert.Null(GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)); + } + + [Fact] + public void BuildAllowCommandsMergeGuidance_OmitsVersionSpecificCommandsWithoutGatewayVersion() { - var command = CommandCenterDiagnostics.BuildAllowCommandsRepairCommand( - ["screen.snapshot", "canvas.present", "screen.snapshot"]); + var guidance = CommandCenterDiagnostics.BuildAllowCommandsMergeGuidance( + ["browser.proxy"]); - Assert.Equal("openclaw config set gateway.nodes.allowCommands '[\"canvas.present\",\"screen.snapshot\"]'", command); + Assert.Contains("Gateway version unavailable", guidance, StringComparison.Ordinal); + Assert.Contains("Preserve every existing entry", guidance, StringComparison.Ordinal); + Assert.DoesNotContain("gateway.nodes.", guidance, StringComparison.Ordinal); + Assert.DoesNotContain("openclaw config get", guidance, StringComparison.Ordinal); + Assert.DoesNotContain("openclaw config set", guidance, StringComparison.Ordinal); + } + + [Theory] + [InlineData("2026.6.11")] + [InlineData("2026.7.2-beta.3")] + public void BuildAllowCommandsMergeGuidance_UsesLegacyKeyForSupportedLegacyGateway( + string gatewayVersion) + { + var guidance = CommandCenterDiagnostics.BuildAllowCommandsMergeGuidance( + ["browser.proxy"], + gatewayVersion); + + Assert.Contains( + "openclaw config get gateway.nodes.allowCommands --json", + guidance, + StringComparison.Ordinal); + Assert.Contains( + "openclaw config set gateway.nodes.allowCommands ''", + guidance, + StringComparison.Ordinal); + } + + [Theory] + [InlineData("2026.6.11")] + [InlineData("2026.7.2-beta.3")] + public void NodeCapabilityHealthInfo_UsesGatewayVersionForLegacyRepairGuidance( + string gatewayVersion) + { + var node = new GatewayNodeInfo + { + NodeId = "node-1", + DisplayName = "Windows Node", + Platform = "windows", + IsOnline = true, + Commands = ["browser.proxy", "screen.record"], + Permissions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["browser.proxy"] = false, + ["screen.record"] = false + } + }; + + var info = NodeCapabilityHealthInfo.FromNode(node, gatewayVersion); + + Assert.Contains(info.Warnings, warning => + warning.Title == "Browser proxy command is filtered by gateway policy" && + warning.CopyText?.Contains( + "openclaw config get gateway.nodes.allowCommands --json", + StringComparison.Ordinal) == true && + warning.CopyText.Contains("Preserve every existing entry", StringComparison.Ordinal) && + !warning.CopyText.Contains( + "openclaw config set gateway.nodes.allowCommands '[\"browser.proxy\"]'", + StringComparison.Ordinal)); + Assert.Contains(info.Warnings, warning => + warning.Title == "Privacy-sensitive commands are currently blocked" && + warning.CopyText?.Contains("gateway.nodes.allowCommands", StringComparison.Ordinal) == true && + warning.CopyText?.Contains("gateway.nodes.commands.allow", StringComparison.Ordinal) == false); } [Theory] @@ -1552,13 +1647,57 @@ public void TryBuildNodeApprovalCommand_DistinguishesApprovalFromDiscovery() public void BuildDangerousCommandOptInGuidance_IsStableAndDoesNotEmitRepairCommand() { var guidance = CommandCenterDiagnostics.BuildDangerousCommandOptInGuidance( - ["screen.record", "camera.snap", "screen.record"]); + ["screen.record", "camera.snap", "screen.record"], + "2026.7.3"); Assert.Contains("camera.snap, screen.record", guidance); Assert.Contains("Do not use wildcards", guidance); Assert.DoesNotContain("openclaw config set", guidance, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void BuildDangerousCommandOptInGuidance_OmitsAllowlistKeyWithoutGatewayVersion() + { + var guidance = CommandCenterDiagnostics.BuildDangerousCommandOptInGuidance( + ["screen.record"]); + + Assert.Contains("Gateway version unavailable", guidance, StringComparison.Ordinal); + Assert.DoesNotContain("gateway.nodes.", guidance, StringComparison.Ordinal); + Assert.DoesNotContain("openclaw config", guidance, StringComparison.Ordinal); + } + + [Fact] + public void NodeCapabilityHealthInfo_DoesNotOfferAllowlistRepairWithoutGatewayVersion() + { + var node = new GatewayNodeInfo + { + NodeId = "node-unknown-gateway", + DisplayName = "Windows Node", + Platform = "windows", + IsOnline = true, + Commands = ["canvas.present", "browser.proxy", "screen.record"], + Permissions = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["canvas.present"] = false, + ["browser.proxy"] = false, + ["screen.record"] = false + } + }; + + var info = NodeCapabilityHealthInfo.FromNode(node); + var allowlistWarnings = info.Warnings + .Where(warning => warning.Category == "allowlist") + .ToList(); + + Assert.NotEmpty(allowlistWarnings); + Assert.All(allowlistWarnings, warning => + { + Assert.Null(warning.RepairAction); + Assert.Null(warning.CopyText); + Assert.DoesNotContain("gateway.nodes.", warning.Detail, StringComparison.Ordinal); + }); + } + [Fact] public void SortAndDedupeWarnings_PrioritizesAndRemovesDuplicates() { diff --git a/tests/OpenClaw.Shared.Tests/ReadmeValidationTests.cs b/tests/OpenClaw.Shared.Tests/ReadmeValidationTests.cs index 8c906d3d4..87386763e 100644 --- a/tests/OpenClaw.Shared.Tests/ReadmeValidationTests.cs +++ b/tests/OpenClaw.Shared.Tests/ReadmeValidationTests.cs @@ -16,30 +16,42 @@ public void ReadmeAllowCommandsJsonExample_IsValid() var matches = Regex.Matches(readmeContent, jsonPattern); Assert.True(matches.Count > 0, "No JSON code blocks found in README."); - var allowCommandsBlocks = matches + var commandPolicyBlocks = matches .Select(m => m.Groups[1].Value) - .Where(json => json.Contains("\"allowCommands\"", StringComparison.Ordinal)) + .Where(json => json.Contains("\"commands\"", StringComparison.Ordinal)) .ToList(); - Assert.True(allowCommandsBlocks.Count > 0, "No JSON block containing 'allowCommands' found in README."); + Assert.True(commandPolicyBlocks.Count > 0, "No JSON block containing the node command policy found in README."); - foreach (var allowCommandsJson in allowCommandsBlocks) + foreach (var commandPolicyJson in commandPolicyBlocks) { - using var doc = JsonDocument.Parse(allowCommandsJson); + using var doc = JsonDocument.Parse(commandPolicyJson); var root = doc.RootElement; Assert.True(root.TryGetProperty("gateway", out var gateway), "JSON should have a 'gateway' property."); Assert.True(gateway.TryGetProperty("nodes", out var nodes), "gateway should have a 'nodes' property."); - Assert.True(nodes.TryGetProperty("allowCommands", out var allowCommands), "nodes should have an 'allowCommands' property."); - Assert.Equal(JsonValueKind.Array, allowCommands.ValueKind); + Assert.True(nodes.TryGetProperty("commands", out var commands), "nodes should have a 'commands' property."); + Assert.True(commands.TryGetProperty("allow", out var allowedCommands), "commands should have an 'allow' property."); + Assert.Equal(JsonValueKind.Array, allowedCommands.ValueKind); - foreach (var command in allowCommands.EnumerateArray()) + foreach (var command in allowedCommands.EnumerateArray()) { Assert.Equal(JsonValueKind.String, command.ValueKind); } } } + [Fact] + public void ReadmeNodeCommandPolicy_DocumentsSupportedLegacyGatewayKeys() + { + var readmeContent = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "README.md")); + + Assert.Contains("2026.6.11", readmeContent, StringComparison.Ordinal); + Assert.Contains("2026.7.2-beta.3", readmeContent, StringComparison.Ordinal); + Assert.Contains("gateway.nodes.allowCommands", readmeContent, StringComparison.Ordinal); + Assert.Contains("gateway.nodes.commands.allow", readmeContent, StringComparison.Ordinal); + } + private static string GetRepositoryRoot() { var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); diff --git a/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs index 76906a127..dc45a68fe 100644 --- a/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatModelChoiceTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using OpenClaw.Chat; using OpenClaw.Shared; @@ -196,6 +197,24 @@ public void IsTrackingDefault_DetectsEmptyOrNull(string? id, bool expected) => public void FormatContextWindow_FormatsCompactly(int contextWindow, string expected) => Assert.Equal(expected, ChatModelLabels.FormatContextWindow(contextWindow)); + [Fact] + public void FormatContextWindow_UsesInvariantDecimalSeparatorUnderPtPt() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-PT"); + CultureInfo.CurrentUICulture = new CultureInfo("pt-PT"); + Assert.Equal("1.5M", ChatModelLabels.FormatContextWindow(1_500_000)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + // ── Meta segment ───────────────────────────────────────────────────── [Fact] diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs index 2979b51e5..d1111742c 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPageNodeApprovalSourceTests.cs @@ -17,7 +17,9 @@ public void CommandCenterFallback_ProjectsLocalDeclarationsAsUnverified() "CommandCenterBrowserProxyAuthWarningPolicy.cs"); var appSource = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); - Assert.Contains("NodeCapabilityHealthInfo.FromLocalDeclarations(localNode)", builderSource); + Assert.Contains( + "NodeCapabilityHealthInfo.FromLocalDeclarations(localNode, gatewayVersion)", + builderSource); Assert.DoesNotContain("NodeCapabilityHealthInfo.FromNode(localNode)", builderSource); Assert.Contains("var hasAuthoritativePendingLocalNodeTrust =", builderSource); Assert.Contains("string.Equals(node.NodeId, localNodeId, StringComparison.OrdinalIgnoreCase)", builderSource); diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 03a536c5b..190c335dc 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using OpenClaw.Shared; using OpenClaw.Connection; using OpenClawTray.Services; @@ -659,6 +660,27 @@ public void MetricsLine_FormatsMillionsTokenCount() Assert.Contains("2.5M tokens", summary.MetricsLine); } + [Fact] + public void MetricsLine_UsesInvariantDecimalSeparatorUnderPtPt() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-PT"); + CultureInfo.CurrentUICulture = new CultureInfo("pt-PT"); + var usage = new GatewayUsageInfo { CostUsd = 0, TotalTokens = 2_500_000 }; + var summary = Build(Base(ConnectionStatus.Connected, usage: usage)); + Assert.Contains("2.5M tokens", summary.MetricsLine); + Assert.DoesNotContain("2,5M tokens", summary.MetricsLine); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + [Fact] public void ActiveSession_ContextPercentUsesTotalTokensWhenInputOutputZero() { diff --git a/tests/OpenClaw.Tray.Tests/WorkspaceFilesModelTests.cs b/tests/OpenClaw.Tray.Tests/WorkspaceFilesModelTests.cs index 4bc3e32ef..debef72f6 100644 --- a/tests/OpenClaw.Tray.Tests/WorkspaceFilesModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/WorkspaceFilesModelTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Text.Json; using OpenClaw.Shared; using OpenClawTray.Pages; @@ -372,6 +373,24 @@ public void FormatSize_ProducesHumanReadable(long bytes, string expected) Assert.Equal(expected, WorkspaceFilesModel.FormatSize(bytes)); } + [Fact] + public void FormatSize_UsesInvariantDecimalSeparatorUnderPtPt() + { + var originalCulture = CultureInfo.CurrentCulture; + var originalUiCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("pt-PT"); + CultureInfo.CurrentUICulture = new CultureInfo("pt-PT"); + Assert.Equal("2.0 KB", WorkspaceFilesModel.FormatSize(2048)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + } + [Fact] public void FormatSize_NullOrNegative_IsEmpty() {