From a112b2375b72d9b5dc753d52312362923f64e5ab Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:33:05 +0100 Subject: [PATCH 01/14] fix(setup): validate current OpenClaw candidate --- .github/workflows/ci.yml | 169 +++- docs/TEST_COVERAGE.md | 1 + docs/WINDOWS_NODE_TESTING.md | 2 +- scripts/Start-E2EGatewayPackageHost.ps1 | 230 +++++ scripts/Stop-E2EGatewayPackageHost.ps1 | 97 ++ .../GatewayReloadRecoveryState.cs | 81 ++ src/OpenClaw.SetupEngine/SetupPipeline.cs | 6 +- src/OpenClaw.SetupEngine/SetupSteps.cs | 172 +++- src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 259 +++++- .../Setup/E2ESetupFixture.cs | 4 +- .../Setup/GatewayE2EPackageSpec.cs | 32 + .../Setup/GatewayE2EPackageSpecTests.cs | 73 ++ .../Setup/SetupAndConnectTests.cs | 2 +- .../SetupPipelineTests.cs | 13 +- .../SetupStepsTests.cs | 867 ++++++++++++++++++ 15 files changed, 1944 insertions(+), 64 deletions(-) create mode 100644 scripts/Start-E2EGatewayPackageHost.ps1 create mode 100644 scripts/Stop-E2EGatewayPackageHost.ps1 create mode 100644 src/OpenClaw.SetupEngine/GatewayReloadRecoveryState.cs create mode 100644 tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs create mode 100644 tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd0a3e8f7..144931e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,137 @@ jobs: exit 1 fi + gateway-candidate: + 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@v7 + with: + repository: openclaw/openclaw + ref: 0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 + fetch-depth: 0 + + - name: Setup Node 24 + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Activate pinned pnpm + shell: bash + run: | + corepack enable + corepack prepare pnpm@11.2.2 --activate + + - name: Compose main with exact PR 110382 and PR 111956 patches + shell: bash + run: | + set -euo pipefail + base_sha=0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 + wizard_base=5abcad51c81daa4ba4b570cccde06eecd8a437f1 + wizard_head=8287e118f593f10f55a7419d27b28f739652f774 + package_base=9e0c5f94b5cdf0090a17b3f36c5a74c5b36a1c71 + package_head=c2770d527a29a65ad8bb3decc27f35ab528ca224 + wizard_paths="$RUNNER_TEMP/pr110382-expected-paths.txt" + package_paths="$RUNNER_TEMP/pr111956-expected-paths.txt" + combined_paths="$RUNNER_TEMP/combined-expected-paths.txt" + staged_paths="$RUNNER_TEMP/combined-staged-paths.txt" + wizard_patch="$RUNNER_TEMP/pr110382.patch" + package_patch="$RUNNER_TEMP/pr111956.patch" + + cat >"$wizard_paths" <<'EOF' + apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift + packages/gateway-protocol/src/schema/wizard.ts + src/gateway/gateway.test.ts + src/gateway/server-methods/wizard.test.ts + src/gateway/server-methods/wizard.ts + src/gateway/server-wizard-sessions.test.ts + src/gateway/server-wizard-sessions.ts + src/wizard/session.ts + src/wizard/setup.finalize.test.ts + src/wizard/setup.finalize.ts + EOF + cat >"$package_paths" <<'EOF' + scripts/package-openclaw-for-docker.d.mts + scripts/package-openclaw-for-docker.mjs + test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts + EOF + cat "$wizard_paths" "$package_paths" | sort >"$combined_paths" + + test "$(git rev-parse HEAD)" = "$base_sha" + git fetch --no-tags origin \ + "+refs/pull/110382/head:refs/remotes/origin/pr-110382" \ + "+refs/pull/111956/head:refs/remotes/origin/pr-111956" + test "$(git rev-parse refs/remotes/origin/pr-110382)" = "$wizard_head" + test "$(git rev-parse refs/remotes/origin/pr-111956)" = "$package_head" + git merge-base --is-ancestor "$wizard_base" "$wizard_head" + git merge-base --is-ancestor "$package_base" "$package_head" + git diff --name-only "$wizard_base" "$wizard_head" | sort | diff -u "$wizard_paths" - + git diff --name-only "$package_base" "$package_head" | sort | diff -u "$package_paths" - + git diff --binary "$wizard_base" "$wizard_head" -- $(cat "$wizard_paths") >"$wizard_patch" + git diff --binary "$package_base" "$package_head" -- $(cat "$package_paths") >"$package_patch" + test -s "$wizard_patch" + test -s "$package_patch" + sha256sum "$wizard_patch" >"$RUNNER_TEMP/pr110382-patch.SHA256SUM" + sha256sum "$package_patch" >"$RUNNER_TEMP/pr111956-patch.SHA256SUM" + git apply --index "$wizard_patch" + git apply --index "$package_patch" + git diff --cached --name-only | sort >"$staged_paths" + diff -u "$combined_paths" "$staged_paths" + git diff --cached --check + + - name: Build immutable composed gateway package + id: package + shell: bash + run: | + set -euo pipefail + test "$(node -p "require('./package.json').version")" = "2026.7.2" + pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + node scripts/package-openclaw-for-docker.mjs \ + --allow-unreleased-changelog \ + --source-dir . \ + --output-dir "$RUNNER_TEMP/gateway-candidate" \ + --output-name openclaw-2026.7.2-main-pr110382-pr111956.tgz + tarball="$RUNNER_TEMP/gateway-candidate/openclaw-2026.7.2-main-pr110382-pr111956.tgz" + sha256="$(sha256sum "$tarball" | awk '{print $1}')" + packaged_version="$(tar -xOf "$tarball" package/package.json | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).version))")" + test "$packaged_version" = "2026.7.2" + wizard_patch_sha256="$(awk '{print $1}' "$RUNNER_TEMP/pr110382-patch.SHA256SUM")" + package_patch_sha256="$(awk '{print $1}' "$RUNNER_TEMP/pr111956-patch.SHA256SUM")" + test "${#sha256}" -eq 64 + test "${#wizard_patch_sha256}" -eq 64 + test "${#package_patch_sha256}" -eq 64 + printf '%s %s\n' "$sha256" "$(basename "$tarball")" >"$RUNNER_TEMP/gateway-candidate/SHA256SUMS" + printf '{"source_ref":"main","source_sha":"%s","fixes":[{"pr":110382,"base":"%s","head":"%s","patch_sha256":"%s"},{"pr":111956,"base":"%s","head":"%s","patch_sha256":"%s"}],"changed_paths":["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift","packages/gateway-protocol/src/schema/wizard.ts","scripts/package-openclaw-for-docker.d.mts","scripts/package-openclaw-for-docker.mjs","src/gateway/gateway.test.ts","src/gateway/server-methods/wizard.test.ts","src/gateway/server-methods/wizard.ts","src/gateway/server-wizard-sessions.test.ts","src/gateway/server-wizard-sessions.ts","src/wizard/session.ts","src/wizard/setup.finalize.test.ts","src/wizard/setup.finalize.ts","test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],"package_version":"%s","package_sha256":"%s"}\n' \ + 0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 \ + 5abcad51c81daa4ba4b570cccde06eecd8a437f1 \ + 8287e118f593f10f55a7419d27b28f739652f774 \ + "$wizard_patch_sha256" \ + 9e0c5f94b5cdf0090a17b3f36c5a74c5b36a1c71 \ + c2770d527a29a65ad8bb3decc27f35ab528ca224 \ + "$package_patch_sha256" \ + "$packaged_version" \ + "$sha256" >"$RUNNER_TEMP/gateway-candidate/provenance.json" + echo "sha256=$sha256" >>"$GITHUB_OUTPUT" + + - name: Upload composed gateway candidate + uses: actions/upload-artifact@v7 + with: + name: openclaw-main-pr110382-pr111956-candidate + path: ${{ runner.temp }}/gateway-candidate/ + if-no-files-found: error + retention-days: 7 + test: needs: repo-hygiene if: ${{ !cancelled() }} @@ -338,8 +469,8 @@ jobs: majorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }} e2etests: - needs: repo-hygiene - if: ${{ !cancelled() }} + needs: [repo-hygiene, gateway-candidate] + if: ${{ needs.repo-hygiene.result == 'success' && needs.gateway-candidate.result == 'success' }} runs-on: windows-latest timeout-minutes: ${{ matrix.timeout_minutes }} strategy: @@ -348,7 +479,7 @@ jobs: include: - name: setup-connect timeout_minutes: 45 - filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" - name: revocation-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests @@ -357,10 +488,10 @@ jobs: 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-candidate.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-candidate job output." exit 1 - uses: actions/checkout@v7 @@ -395,9 +526,28 @@ jobs: - name: Build E2E Tests run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 + - name: Download composed gateway candidate + uses: actions/download-artifact@v8 + with: + name: openclaw-main-pr110382-pr111956-candidate + path: TestResults/GatewayCandidate + + - name: Start disposable WSL package host + id: gateway_package_host + shell: pwsh + run: | + .\scripts\Start-E2EGatewayPackageHost.ps1 ` + -PackagePath "TestResults\GatewayCandidate\openclaw-2026.7.2-main-pr110382-pr111956.tgz" ` + -ExpectedSha256 "${{ needs.gateway-candidate.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_PACKAGE_SPEC: ${{ steps.gateway_package_host.outputs.package_spec }} shell: pwsh run: | dotnet test tests/OpenClaw.E2ETests ` @@ -448,6 +598,15 @@ jobs: } } + - name: Stop disposable WSL package host + if: always() + 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/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 84b0c0cb0..9feaf0d44 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -93,6 +93,7 @@ dotnet test # Explicit local E2E run $env:OPENCLAW_RUN_E2E = "1" +$env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC = "http://:38677/openclaw-candidate.tgz" dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj -r win-x64 # Formal MXC validation path. This sets the required integration/E2E env vars diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 915e0ea69..fbfbaf36b 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -174,7 +174,7 @@ 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. +- Use `.\scripts\validate-mxc-e2e.ps1` for normal local validation. Set `OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC` to the reviewed HTTP(S) `.tgz` candidate first; the harness fails closed rather than silently installing a different Gateway package. 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 `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. ```powershell diff --git a/scripts/Start-E2EGatewayPackageHost.ps1 b/scripts/Start-E2EGatewayPackageHost.ps1 new file mode 100644 index 000000000..01d09e1d1 --- /dev/null +++ b/scripts/Start-E2EGatewayPackageHost.ps1 @@ -0,0 +1,230 @@ +<# +.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 candidate 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" + $hostPackagePath = "$hostDirectory/$($package.Name)" + + & 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 gateway candidate into the package-host distro." } + + $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/$($package.Name)" + $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 + "distro_name=$DistroName" | Out-File -FilePath $GitHubOutput -Encoding utf8 -Append + Write-Host "Gateway E2E candidate 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/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/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..8aac67582 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -287,6 +287,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 +318,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, @@ -1357,6 +1387,16 @@ 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(); +} + public sealed class ConfigureGatewayStep : SetupStep { internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl"; @@ -1451,7 +1491,6 @@ 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} """; @@ -1501,6 +1540,11 @@ openclaw config set gateway.auth.allowTailscale {trustTailscaleAuth} } } + if (gw.ExtraConfig?.ContainsKey("gateway.reload.mode") != true) + { + configCommands += $"\n openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(gw.ReloadMode)}"; + } + return configCommands; } @@ -1528,6 +1572,11 @@ 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) => + 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 +1605,49 @@ 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 portConflict = await StartGatewayStep.CheckPortOwnershipAsync(ctx, ct); + return portConflict ?? await StartGatewayStep.WaitForHealthAsync(ctx, ct); + } +} + public sealed class StartGatewayStep : SetupStep { 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); - - if (!string.IsNullOrWhiteSpace(portCheck.Stdout) && portCheck.Stdout.Contains($":{ctx.Config.GatewayPort}")) + // A restart intentionally targets the service already holding this port. + if (!restart) { - 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($"Port {ctx.Config.GatewayPort} appears to be in use by openclaw — proceeding"); + var portConflict = await CheckPortOwnershipAsync(ctx, ct); + if (portConflict is not null) + return portConflict; } - // 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 +1661,42 @@ 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 2>/dev/null | grep ':{ctx.Config.GatewayPort}\\b' || true", + TimeSpan.FromSeconds(10), ct: ct); + + if (string.IsNullOrWhiteSpace(portCheck.Stdout) || !portCheck.Stdout.Contains($":{ctx.Config.GatewayPort}")) + return null; + + 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($"Port {ctx.Config.GatewayPort} appears to be in use by openclaw — proceeding"); + return 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 +2176,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 +2227,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..12b28307e 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,62 @@ 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 + ?? 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/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs index 507cfecc2..a0b27957a 100644 --- a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs +++ b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs @@ -221,7 +221,7 @@ public async Task DisposeAsync() private void WriteConfig() { - var lkgVersion = GatewayLkgVersion.ResolveLkgVersion(); + var gatewayPackageSpec = GatewayE2EPackageSpec.Resolve(); var config = new { DistroName = _distroName, @@ -258,7 +258,7 @@ private void WriteConfig() }, Gateway = new { - Version = lkgVersion + Version = gatewayPackageSpec } }; diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs new file mode 100644 index 000000000..fcb80b095 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -0,0 +1,32 @@ +namespace OpenClaw.E2ETests.Setup; + +internal static class GatewayE2EPackageSpec +{ + internal const string EnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC"; + + internal static string Resolve() => Resolve(Environment.GetEnvironmentVariable(EnvVar)); + + internal static string Resolve(string? raw) + { + if (raw is null) + throw new InvalidOperationException( + $"{EnvVar} is required so E2E cannot silently install a different gateway package."); + + var value = raw.Trim(); + if (value.Length == 0) + throw new InvalidOperationException($"{EnvVar} cannot be empty when set."); + 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."); + + return value; + } +} diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs new file mode 100644 index 000000000..51da0d308 --- /dev/null +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs @@ -0,0 +1,73 @@ +using Xunit; + +namespace OpenClaw.E2ETests.Setup; + +public sealed class GatewayE2EPackageSpecTests +{ + [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); + } + + [Fact] + public void Resolve_RejectsMissingPackageSpec() + { + var error = Assert.Throws(() => GatewayE2EPackageSpec.Resolve(null)); + Assert.Contains(GatewayE2EPackageSpec.EnvVar, error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolve_AcceptsBuiltPackageUrl() + { + const string packageSpec = "http://127.0.0.1:38677/openclaw-candidate.tgz"; + Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve(packageSpec)); + } + + [Theory] + [InlineData("")] + [InlineData("2026.7.2")] + [InlineData("file:///tmp/openclaw-candidate.tgz")] + [InlineData("https://user:secret@example.test/openclaw-candidate.tgz")] + [InlineData("https://example.test/openclaw-candidate.zip")] + [InlineData("https://example.test/openclaw-candidate.tgz\n--dangerous")] + public void Resolve_RejectsUnsafeOrNonPackageOverrides(string packageSpec) + { + Assert.Throws(() => GatewayE2EPackageSpec.Resolve(packageSpec)); + } + + 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/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 2d8a07eae..9e4ec5060 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 { } /// 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..4ee78cfc7 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -801,6 +801,97 @@ 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("config set gateway.reload.mode 'restart'") => Ok(), + var value when value.Contains("openclaw gateway restart") => Ok(), + var value when value.Contains("curl -s") => Ok("200"), + var value when value.Contains("GATEWAY_CONFIGURED") => Ok("GATEWAY_CONFIGURED"), + _ => Fail($"Unexpected command: {command}"), + }); + var ctx = CreateContext( + new SetupConfig + { + CleanBeforeRun = false, + Gateway = new GatewayConfig { 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] @@ -1565,6 +1656,782 @@ public void ConfigureGateway_RejectsUnsafeExtraConfigKeys(string key) Assert.False(ConfigureGatewayStep.IsSafeExtraConfigKey(key)); } + [Fact] + public void ConfigureGateway_WritesConfiguredReloadMode() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig { ReloadMode = "hot" }, + 18789, + "'[]'"); + + Assert.Contains("openclaw config set gateway.reload.mode 'hot'", commands); + Assert.DoesNotContain("openclaw config set gateway.reload.mode off", commands); + } + + [Fact] + public void ConfigureGateway_ExtraConfigOverridesReloadMode() + { + var commands = ConfigureGatewayStep.BuildConfigCommands( + new GatewayConfig + { + 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 + { + 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.DoesNotContain(commands.WslCalls, call => call.Command.Contains("ss -tlnp")); + Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + + [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("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(4, 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("openclaw gateway restart", commands.WslCalls[2].Command); + Assert.Contains("curl -s", commands.WslCalls[3].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("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 'hot'")); + 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("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("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("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("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("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 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 'hot'") + ? 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 'hot'") + ? 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 'hot'") => 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 'hot'")); + 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() { From dbb5b9e221ebc87ee4454cb6a792464efab9f231 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:09:16 +0100 Subject: [PATCH 02/14] test(setup): preserve node trust recovery proof --- .../Setup/RevocationAndRecoveryTests.cs | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) 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"); From 15a9eabb1ef088b36d78cf42fe26b580ad019742 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:28:45 +0100 Subject: [PATCH 03/14] fix: restore current gateway LKG and pt-PT proofs --- docs/CONNECTION_PROTOCOL_RESEARCH.md | 2 +- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 2 +- .../GatewayLkgVersionTests.cs | 2 ++ .../ChatModelChoiceTests.cs | 19 ++++++++++++++++ .../TrayDashboardSummaryBuilderTests.cs | 22 +++++++++++++++++++ .../WorkspaceFilesModelTests.cs | 19 ++++++++++++++++ 6 files changed, 64 insertions(+), 2 deletions(-) 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/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/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs index dc410cc1b..e43889402 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -8,6 +8,7 @@ public void ResolveLkgVersion_ReturnsEmbeddedLkg() { var version = GatewayLkgVersion.ResolveLkgVersion(); + Assert.Equal("2026.7.1", version); Assert.Equal(GatewayLkgVersion.LkgVersion, version); } @@ -30,5 +31,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.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/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() { From caa0e2cbdca6d2a76d3c6b645599fae90b04b7cc Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:19:25 +0100 Subject: [PATCH 04/14] test(ci): preserve official gateway E2E lane --- .github/workflows/ci.yml | 25 ++++++++++++++----- .../Setup/GatewayE2EPackageSpec.cs | 13 +++++----- .../Setup/GatewayE2EPackageSpecTests.cs | 14 ++++++----- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144931e12..56cace9ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,13 +477,24 @@ jobs: fail-fast: false matrix: include: - - name: setup-connect + - name: lkg-setup-connect + gateway_source: lkg + scenario: setup-connect timeout_minutes: 45 filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" - - name: revocation-recovery + - name: candidate-setup-connect + gateway_source: candidate + scenario: setup-connect + timeout_minutes: 45 + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" + - name: candidate-revocation-recovery + gateway_source: candidate + scenario: revocation-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.RevocationAndRecoveryTests - - name: network-recovery + - name: candidate-network-recovery + gateway_source: candidate + scenario: network-recovery timeout_minutes: 25 filter: FullyQualifiedName~OpenClaw.E2ETests.Setup.NetworkRecoveryTests steps: @@ -527,12 +538,14 @@ jobs: run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 - name: Download composed gateway candidate + if: ${{ matrix.gateway_source == 'candidate' }} uses: actions/download-artifact@v8 with: name: openclaw-main-pr110382-pr111956-candidate path: TestResults/GatewayCandidate - name: Start disposable WSL package host + if: ${{ matrix.gateway_source == 'candidate' }} id: gateway_package_host shell: pwsh run: | @@ -547,7 +560,7 @@ jobs: - name: Run E2E Tests (${{ matrix.name }}) env: OPENCLAW_RUN_E2E: 1 - OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC: ${{ steps.gateway_package_host.outputs.package_spec }} + OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC: ${{ matrix.gateway_source == 'candidate' && steps.gateway_package_host.outputs.package_spec || '' }} shell: pwsh run: | dotnet test tests/OpenClaw.E2ETests ` @@ -567,7 +580,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" @@ -599,7 +612,7 @@ jobs: } - name: Stop disposable WSL package host - if: always() + if: ${{ always() && matrix.gateway_source == 'candidate' }} shell: pwsh run: | .\scripts\Stop-E2EGatewayPackageHost.ps1 ` diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs index fcb80b095..95749c512 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -4,17 +4,16 @@ internal static class GatewayE2EPackageSpec { internal const string EnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC"; - internal static string Resolve() => Resolve(Environment.GetEnvironmentVariable(EnvVar)); + internal static string Resolve() => Resolve( + Environment.GetEnvironmentVariable(EnvVar), + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion()); - internal static string Resolve(string? raw) + internal static string Resolve(string? raw, string defaultSpec) { - if (raw is null) - throw new InvalidOperationException( - $"{EnvVar} is required so E2E cannot silently install a different gateway package."); + if (string.IsNullOrWhiteSpace(raw)) + return defaultSpec; var value = raw.Trim(); - if (value.Length == 0) - throw new InvalidOperationException($"{EnvVar} cannot be empty when set."); if (value.Contains('\r') || value.Contains('\n')) throw new InvalidOperationException($"{EnvVar} cannot contain newlines."); if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs index 51da0d308..70e3e0b15 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs @@ -27,21 +27,22 @@ public void PackageHostScript_LeavesWslInstallTargetAbsentUntilInstall() } [Fact] - public void Resolve_RejectsMissingPackageSpec() + public void Resolve_UsesLkgWhenPackageSpecIsMissing() { - var error = Assert.Throws(() => GatewayE2EPackageSpec.Resolve(null)); - Assert.Contains(GatewayE2EPackageSpec.EnvVar, error.Message, StringComparison.Ordinal); + const string lkg = "2026.7.1"; + Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(null, lkg)); + Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve("", lkg)); + Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(" ", lkg)); } [Fact] public void Resolve_AcceptsBuiltPackageUrl() { const string packageSpec = "http://127.0.0.1:38677/openclaw-candidate.tgz"; - Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve(packageSpec)); + Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve(packageSpec, "2026.7.1")); } [Theory] - [InlineData("")] [InlineData("2026.7.2")] [InlineData("file:///tmp/openclaw-candidate.tgz")] [InlineData("https://user:secret@example.test/openclaw-candidate.tgz")] @@ -49,7 +50,8 @@ public void Resolve_AcceptsBuiltPackageUrl() [InlineData("https://example.test/openclaw-candidate.tgz\n--dangerous")] public void Resolve_RejectsUnsafeOrNonPackageOverrides(string packageSpec) { - Assert.Throws(() => GatewayE2EPackageSpec.Resolve(packageSpec)); + Assert.Throws(() => + GatewayE2EPackageSpec.Resolve(packageSpec, "2026.7.1")); } private static string RepositoryRoot() From f82196363f8c2ce0ad920352c69f12157c8cddf9 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:37:27 +0100 Subject: [PATCH 05/14] test(setup): keep candidate proof fail closed --- .github/workflows/ci.yml | 1 + scripts/validate-mxc-e2e.ps1 | 8 +++++- src/OpenClaw.SetupEngine/SetupSteps.cs | 10 ++----- .../Setup/GatewayE2EPackageSpec.cs | 20 ++++++++++--- .../Setup/GatewayE2EPackageSpecTests.cs | 24 ++++++++++++---- .../SetupStepsTests.cs | 28 ++++++++++++++++--- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56cace9ec..7d66a5f56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -560,6 +560,7 @@ jobs: - name: Run E2E Tests (${{ matrix.name }}) env: OPENCLAW_RUN_E2E: 1 + OPENCLAW_E2E_GATEWAY_SOURCE: ${{ matrix.gateway_source }} OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC: ${{ matrix.gateway_source == 'candidate' && steps.gateway_package_host.outputs.package_spec || '' }} shell: pwsh run: | diff --git a/scripts/validate-mxc-e2e.ps1 b/scripts/validate-mxc-e2e.ps1 index cdd2b8be4..83ae48ad0 100644 --- a/scripts/validate-mxc-e2e.ps1 +++ b/scripts/validate-mxc-e2e.ps1 @@ -151,7 +151,8 @@ function Set-ProcessEnv { $trackedEnvVars = @( "OPENCLAW_REPO_ROOT", "OPENCLAW_RUN_E2E", - "OPENCLAW_RUN_MXC_E2E" + "OPENCLAW_RUN_MXC_E2E", + "OPENCLAW_E2E_GATEWAY_SOURCE" ) $previousEnv = @{} foreach ($name in $trackedEnvVars) { @@ -159,9 +160,14 @@ foreach ($name in $trackedEnvVars) { } try { + if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC)) { + throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must name the reviewed HTTP(S) .tgz candidate before formal MXC validation." + } + Set-ProcessEnv -Name "OPENCLAW_REPO_ROOT" -Value $repoRoot Set-ProcessEnv -Name "OPENCLAW_RUN_E2E" -Value "1" Set-ProcessEnv -Name "OPENCLAW_RUN_MXC_E2E" -Value "1" + Set-ProcessEnv -Name "OPENCLAW_E2E_GATEWAY_SOURCE" -Value "candidate" Write-Host "OpenClaw MXC validation" Write-Host " Repo: $repoRoot" diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 8aac67582..24036db91 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -1638,13 +1638,9 @@ private static async Task StartOrRestartAndWaitForHealthAsync( var pathCmd = ctx.WslPathPrefix; var action = restart ? "restart" : "start"; - // A restart intentionally targets the service already holding this port. - if (!restart) - { - var portConflict = await CheckPortOwnershipAsync(ctx, ct); - if (portConflict is not null) - return portConflict; - } + var portConflict = await CheckPortOwnershipAsync(ctx, ct); + if (portConflict is not null) + return portConflict; var start = await ctx.Commands.RunInWslAsync( distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct); diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs index 95749c512..056909f57 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -3,17 +3,29 @@ namespace OpenClaw.E2ETests.Setup; internal static class GatewayE2EPackageSpec { internal const string EnvVar = "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC"; + internal const string SourceEnvVar = "OPENCLAW_E2E_GATEWAY_SOURCE"; internal static string Resolve() => Resolve( + Environment.GetEnvironmentVariable(SourceEnvVar), Environment.GetEnvironmentVariable(EnvVar), OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion()); - internal static string Resolve(string? raw, string defaultSpec) + internal static string Resolve(string? sourceRaw, string? packageRaw, string lkgSpec) { - if (string.IsNullOrWhiteSpace(raw)) - return defaultSpec; + 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."); + return lkgSpec; + } + + if (!string.Equals(source, "candidate", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException($"{SourceEnvVar} must be either 'lkg' or 'candidate'."); + if (string.IsNullOrWhiteSpace(packageRaw)) + throw new InvalidOperationException($"{EnvVar} is required when {SourceEnvVar}=candidate."); - var value = raw.Trim(); + 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) || diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs index 70e3e0b15..0a36a9227 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs @@ -27,19 +27,31 @@ public void PackageHostScript_LeavesWslInstallTargetAbsentUntilInstall() } [Fact] - public void Resolve_UsesLkgWhenPackageSpecIsMissing() + public void Resolve_UsesLkgOnlyWhenExplicitlySelected() { const string lkg = "2026.7.1"; - Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(null, lkg)); - Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve("", lkg)); - Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(" ", lkg)); + Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve("lkg", null, lkg)); + Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(" LKG ", "", lkg)); } [Fact] public void Resolve_AcceptsBuiltPackageUrl() { const string packageSpec = "http://127.0.0.1:38677/openclaw-candidate.tgz"; - Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve(packageSpec, "2026.7.1")); + Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve("candidate", packageSpec, "2026.7.1")); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("other", null)] + [InlineData("candidate", null)] + [InlineData("candidate", "")] + [InlineData("lkg", "https://example.test/openclaw-candidate.tgz")] + public void Resolve_RejectsAmbiguousSourceOrSpec(string? source, string? packageSpec) + { + Assert.Throws(() => + GatewayE2EPackageSpec.Resolve(source, packageSpec, "2026.7.1")); } [Theory] @@ -51,7 +63,7 @@ public void Resolve_AcceptsBuiltPackageUrl() public void Resolve_RejectsUnsafeOrNonPackageOverrides(string packageSpec) { Assert.Throws(() => - GatewayE2EPackageSpec.Resolve(packageSpec, "2026.7.1")); + GatewayE2EPackageSpec.Resolve("candidate", packageSpec, "2026.7.1")); } private static string RepositoryRoot() diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 4ee78cfc7..d7a851cd3 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -1720,10 +1720,28 @@ var value when value.Contains("curl -s") => Ok("200"), var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); Assert.True(result.IsSuccess, result.Message); - Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("ss -tlnp")); + 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.Contains("ss -tlnp") + ? Ok("LISTEN 0 4096 127.0.0.1:18789 users:((\"python\",pid=42,fd=3))") + : 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 process", result.Message, StringComparison.Ordinal); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); + } + [Fact] public async Task SetupWizard_RestoreReloadModeRestartsAndVerifiesGateway() { @@ -1758,6 +1776,7 @@ public async Task SetupWizard_SuspendReloadModeRestartsAndVerifiesGateway() { 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}"), @@ -1768,11 +1787,12 @@ var value when value.Contains("curl -s") => Ok("200"), var result = await new SetupWizardRunner(ctx).SuspendReloadModeAsync(); Assert.True(result.IsSuccess, result.Message); - Assert.Equal(4, commands.WslCalls.Count); + 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("openclaw gateway restart", commands.WslCalls[2].Command); - Assert.Contains("curl -s", commands.WslCalls[3].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); From 7c2d8d7ea6f6058a5259c3f69744a70ac0d32994 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:43:46 +0100 Subject: [PATCH 06/14] docs(testing): select candidate E2E source --- docs/TEST_COVERAGE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 9feaf0d44..41b1d83be 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -93,6 +93,7 @@ dotnet test # Explicit local E2E run $env:OPENCLAW_RUN_E2E = "1" +$env:OPENCLAW_E2E_GATEWAY_SOURCE = "candidate" $env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC = "http://:38677/openclaw-candidate.tgz" dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj -r win-x64 From 970a52d048276c0ee6f05a41446b836d97d7f377 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:14:56 +0100 Subject: [PATCH 07/14] test(ci): keep setup E2E guard fail closed --- .github/workflows/ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d66a5f56..d38fff80a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -481,12 +481,12 @@ jobs: gateway_source: lkg scenario: setup-connect timeout_minutes: 45 - filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" - name: candidate-setup-connect gateway_source: candidate scenario: setup-connect timeout_minutes: 45 - filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" + filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" - name: candidate-revocation-recovery gateway_source: candidate scenario: revocation-recovery @@ -537,6 +537,18 @@ 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 == 'candidate-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 candidate if: ${{ matrix.gateway_source == 'candidate' }} uses: actions/download-artifact@v8 From 1644bec51af26e8287bb3f197cf5b0dee4167c84 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:38:26 +0100 Subject: [PATCH 08/14] fix(setup): verify composed gateway packages --- .github/workflows/ci.yml | 116 ++++++----- docs/CONNECTION_PROTOCOL_RESEARCH.md | 2 +- docs/SETUP_ENGINE_REDESIGN.md | 5 +- docs/TEST_COVERAGE.md | 14 +- docs/WINDOWS_NODE_TESTING.md | 2 +- scripts/Start-E2EGatewayPackageHost.ps1 | 24 ++- scripts/validate-mxc-e2e.ps1 | 72 ++++++- src/OpenClaw.SetupEngine/GatewayLkgVersion.cs | 2 +- src/OpenClaw.SetupEngine/SetupContext.cs | 1 + src/OpenClaw.SetupEngine/SetupSteps.cs | 59 +++++- src/OpenClaw.SetupEngine/default-config.json | 2 + .../Setup/E2ESetupFixture.cs | 4 +- .../Setup/GatewayE2EPackageSpec.cs | 97 ++++++++- .../Setup/GatewayE2EPackageSpecTests.cs | 194 ++++++++++++++++-- .../GatewayLkgVersionTests.cs | 1 - .../SetupConfigTests.cs | 20 ++ .../SetupStepsTests.cs | 66 ++++++ 17 files changed, 588 insertions(+), 93 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d38fff80a..8578766f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: exit 1 fi - gateway-candidate: + gateway-composed: needs: repo-hygiene runs-on: ubuntu-latest timeout-minutes: 45 @@ -41,14 +41,14 @@ jobs: exit 1 - name: Checkout frozen OpenClaw main source - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: repository: openclaw/openclaw - ref: 0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 + ref: b9a28cde42872cfe13051ddd8f1667e34bf59b17 fetch-depth: 0 - name: Setup Node 24 - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 24 @@ -62,11 +62,14 @@ jobs: shell: bash run: | set -euo pipefail - base_sha=0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 - wizard_base=5abcad51c81daa4ba4b570cccde06eecd8a437f1 - wizard_head=8287e118f593f10f55a7419d27b28f739652f774 - package_base=9e0c5f94b5cdf0090a17b3f36c5a74c5b36a1c71 - package_head=c2770d527a29a65ad8bb3decc27f35ab528ca224 + base_sha=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + wizard_base=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + wizard_head=4ef65f07940a601a8bf7ef18fae72270f4843ccb + package_base=b9a28cde42872cfe13051ddd8f1667e34bf59b17 + package_head=070527ddfe32f0156a6258a638e226f731458ecd + fork_repository=https://github.com/TheAngryPit/openclaw.git + wizard_fetch_ref=refs/heads/codex/fix-gateway-wizard-rpc-exits + package_fetch_ref=refs/heads/codex/windows-package-runner-tree-timeout wizard_paths="$RUNNER_TEMP/pr110382-expected-paths.txt" package_paths="$RUNNER_TEMP/pr111956-expected-paths.txt" combined_paths="$RUNNER_TEMP/combined-expected-paths.txt" @@ -94,11 +97,13 @@ jobs: cat "$wizard_paths" "$package_paths" | sort >"$combined_paths" test "$(git rev-parse HEAD)" = "$base_sha" - git fetch --no-tags origin \ - "+refs/pull/110382/head:refs/remotes/origin/pr-110382" \ - "+refs/pull/111956/head:refs/remotes/origin/pr-111956" - test "$(git rev-parse refs/remotes/origin/pr-110382)" = "$wizard_head" - test "$(git rev-parse refs/remotes/origin/pr-111956)" = "$package_head" + # Fetch immutable reviewed commits from the operator fork. The upstream + # PR 111956 object was stale at freeze time, so it is not an authority. + git fetch --no-tags "$fork_repository" \ + "+${wizard_fetch_ref}:refs/remotes/fork/pr-110382" \ + "+${package_fetch_ref}:refs/remotes/fork/pr-111956" + test "$(git rev-parse refs/remotes/fork/pr-110382)" = "$wizard_head" + test "$(git rev-parse refs/remotes/fork/pr-111956)" = "$package_head" git merge-base --is-ancestor "$wizard_base" "$wizard_head" git merge-base --is-ancestor "$package_base" "$package_head" git diff --name-only "$wizard_base" "$wizard_head" | sort | diff -u "$wizard_paths" - @@ -125,9 +130,9 @@ jobs: node scripts/package-openclaw-for-docker.mjs \ --allow-unreleased-changelog \ --source-dir . \ - --output-dir "$RUNNER_TEMP/gateway-candidate" \ + --output-dir "$RUNNER_TEMP/gateway-composed" \ --output-name openclaw-2026.7.2-main-pr110382-pr111956.tgz - tarball="$RUNNER_TEMP/gateway-candidate/openclaw-2026.7.2-main-pr110382-pr111956.tgz" + tarball="$RUNNER_TEMP/gateway-composed/openclaw-2026.7.2-main-pr110382-pr111956.tgz" sha256="$(sha256sum "$tarball" | awk '{print $1}')" packaged_version="$(tar -xOf "$tarball" package/package.json | node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>process.stdout.write(JSON.parse(s).version))")" test "$packaged_version" = "2026.7.2" @@ -136,24 +141,24 @@ jobs: test "${#sha256}" -eq 64 test "${#wizard_patch_sha256}" -eq 64 test "${#package_patch_sha256}" -eq 64 - printf '%s %s\n' "$sha256" "$(basename "$tarball")" >"$RUNNER_TEMP/gateway-candidate/SHA256SUMS" - printf '{"source_ref":"main","source_sha":"%s","fixes":[{"pr":110382,"base":"%s","head":"%s","patch_sha256":"%s"},{"pr":111956,"base":"%s","head":"%s","patch_sha256":"%s"}],"changed_paths":["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift","packages/gateway-protocol/src/schema/wizard.ts","scripts/package-openclaw-for-docker.d.mts","scripts/package-openclaw-for-docker.mjs","src/gateway/gateway.test.ts","src/gateway/server-methods/wizard.test.ts","src/gateway/server-methods/wizard.ts","src/gateway/server-wizard-sessions.test.ts","src/gateway/server-wizard-sessions.ts","src/wizard/session.ts","src/wizard/setup.finalize.test.ts","src/wizard/setup.finalize.ts","test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],"package_version":"%s","package_sha256":"%s"}\n' \ - 0f8a87ad6f1281bc4d69cd9f6d9e3d56ce6011a3 \ - 5abcad51c81daa4ba4b570cccde06eecd8a437f1 \ - 8287e118f593f10f55a7419d27b28f739652f774 \ + printf '%s %s\n' "$sha256" "$(basename "$tarball")" >"$RUNNER_TEMP/gateway-composed/SHA256SUMS" + printf '{"source_ref":"main","source_sha":"%s","fixes":[{"pr":110382,"base":"%s","head":"%s","fetch_repository":"TheAngryPit/openclaw","fetch_ref":"codex/fix-gateway-wizard-rpc-exits","patch_sha256":"%s"},{"pr":111956,"base":"%s","head":"%s","fetch_repository":"TheAngryPit/openclaw","fetch_ref":"codex/windows-package-runner-tree-timeout","upstream_pr_object_state_at_freeze":"stale","patch_sha256":"%s"}],"changed_paths":["apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift","packages/gateway-protocol/src/schema/wizard.ts","scripts/package-openclaw-for-docker.d.mts","scripts/package-openclaw-for-docker.mjs","src/gateway/gateway.test.ts","src/gateway/server-methods/wizard.test.ts","src/gateway/server-methods/wizard.ts","src/gateway/server-wizard-sessions.test.ts","src/gateway/server-wizard-sessions.ts","src/wizard/session.ts","src/wizard/setup.finalize.test.ts","src/wizard/setup.finalize.ts","test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts"],"package_version":"%s","package_sha256":"%s"}\n' \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + 4ef65f07940a601a8bf7ef18fae72270f4843ccb \ "$wizard_patch_sha256" \ - 9e0c5f94b5cdf0090a17b3f36c5a74c5b36a1c71 \ - c2770d527a29a65ad8bb3decc27f35ab528ca224 \ + b9a28cde42872cfe13051ddd8f1667e34bf59b17 \ + 070527ddfe32f0156a6258a638e226f731458ecd \ "$package_patch_sha256" \ "$packaged_version" \ - "$sha256" >"$RUNNER_TEMP/gateway-candidate/provenance.json" + "$sha256" >"$RUNNER_TEMP/gateway-composed/provenance.json" echo "sha256=$sha256" >>"$GITHUB_OUTPUT" - - name: Upload composed gateway candidate - uses: actions/upload-artifact@v7 + - name: Upload composed gateway package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: openclaw-main-pr110382-pr111956-candidate - path: ${{ runner.temp }}/gateway-candidate/ + name: openclaw-main-pr110382-pr111956-composed + path: ${{ runner.temp }}/gateway-composed/ if-no-files-found: error retention-days: 7 @@ -469,8 +474,8 @@ jobs: majorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }} e2etests: - needs: [repo-hygiene, gateway-candidate] - if: ${{ needs.repo-hygiene.result == 'success' && needs.gateway-candidate.result == 'success' }} + needs: [repo-hygiene, gateway-composed] + if: ${{ needs.repo-hygiene.result == 'success' && needs.gateway-composed.result == 'success' }} runs-on: windows-latest timeout-minutes: ${{ matrix.timeout_minutes }} strategy: @@ -479,30 +484,40 @@ jobs: include: - 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: candidate-setup-connect - gateway_source: candidate + - name: official-beta-setup-connect + gateway_source: official + gateway_version: 2026.7.2-beta.3 scenario: setup-connect timeout_minutes: 45 filter: "FullyQualifiedName~OpenClaw.E2ETests.Setup.SetupAndConnectTests|FullyQualifiedName~OpenClaw.E2ETests.Setup.MxcSetupAndConnectTests" - - name: candidate-revocation-recovery - gateway_source: candidate + - 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: candidate-network-recovery - gateway_source: candidate + - 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' || needs.gateway-candidate.result != 'success' }} + if: ${{ needs.repo-hygiene.result != 'success' || needs.gateway-composed.result != 'success' }} shell: pwsh run: | - Write-Error "A prerequisite failed; see repo-hygiene and gateway-candidate job output." + Write-Error "A prerequisite failed; see repo-hygiene and gateway-composed job output." exit 1 - uses: actions/checkout@v7 @@ -538,7 +553,7 @@ jobs: run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 - name: Run gateway package selector tests - if: ${{ matrix.name == 'candidate-setup-connect' }} + if: ${{ matrix.name == 'official-beta-setup-connect' }} shell: pwsh run: | dotnet test tests/OpenClaw.E2ETests ` @@ -549,21 +564,21 @@ jobs: --filter "FullyQualifiedName~OpenClaw.E2ETests.Setup.GatewayE2EPackageSpecTests" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Download composed gateway candidate - if: ${{ matrix.gateway_source == 'candidate' }} - uses: actions/download-artifact@v8 + - name: Download composed gateway package + if: ${{ matrix.gateway_source == 'composed' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: openclaw-main-pr110382-pr111956-candidate - path: TestResults/GatewayCandidate + name: openclaw-main-pr110382-pr111956-composed + path: TestResults/GatewayComposed - name: Start disposable WSL package host - if: ${{ matrix.gateway_source == 'candidate' }} + if: ${{ matrix.gateway_source == 'composed' }} id: gateway_package_host shell: pwsh run: | .\scripts\Start-E2EGatewayPackageHost.ps1 ` - -PackagePath "TestResults\GatewayCandidate\openclaw-2026.7.2-main-pr110382-pr111956.tgz" ` - -ExpectedSha256 "${{ needs.gateway-candidate.outputs.sha256 }}" ` + -PackagePath "TestResults\GatewayComposed\openclaw-2026.7.2-main-pr110382-pr111956.tgz" ` + -ExpectedSha256 "${{ needs.gateway-composed.outputs.sha256 }}" ` -DistroName "OpenClawE2EPackageHost-${{ matrix.name }}" ` -InstallLocation "${{ runner.temp }}\OpenClawE2EPackageHost-${{ matrix.name }}" ` -OwnershipToken "${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}" ` @@ -573,7 +588,10 @@ jobs: env: OPENCLAW_RUN_E2E: 1 OPENCLAW_E2E_GATEWAY_SOURCE: ${{ matrix.gateway_source }} - OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC: ${{ matrix.gateway_source == 'candidate' && steps.gateway_package_host.outputs.package_spec || '' }} + 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 ` @@ -625,7 +643,7 @@ jobs: } - name: Stop disposable WSL package host - if: ${{ always() && matrix.gateway_source == 'candidate' }} + if: ${{ always() && matrix.gateway_source == 'composed' }} shell: pwsh run: | .\scripts\Stop-E2EGatewayPackageHost.ps1 ` diff --git a/docs/CONNECTION_PROTOCOL_RESEARCH.md b/docs/CONNECTION_PROTOCOL_RESEARCH.md index 1d7b2d8bf..e35151058 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.7.1` in +The setup engine currently pins gateway LKG `2026.6.11` 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/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index f759dbd6b..bbf898c47 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -140,6 +140,7 @@ rerun setup with a supported new name. "Bind": "loopback", "InstallUrl": null, "Version": null, + "ExpectedPackageSha256": null, "HealthTimeoutSeconds": 90, "ReloadMode": "hot", "AuthMode": "token", @@ -195,8 +196,8 @@ Executed sequentially. Each step is a small class (30–120 lines) in `SetupStep | 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) | +| 11 | `InstallGatewayServiceStep` | Run supported `openclaw gateway install --force`, which installs and activates or restarts the service | +| 12 | `WaitForGatewayHealthStep` | Separate retryable health-only wait for the activated service (90s timeout); do not issue a redundant start or restart | | 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 41b1d83be..6bbf1caf0 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -93,12 +93,18 @@ dotnet test # Explicit local E2E run $env:OPENCLAW_RUN_E2E = "1" -$env:OPENCLAW_E2E_GATEWAY_SOURCE = "candidate" -$env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC = "http://:38677/openclaw-candidate.tgz" +$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 fbfbaf36b..75b76356d 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -174,7 +174,7 @@ 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. Set `OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC` to the reviewed HTTP(S) `.tgz` candidate first; the harness fails closed rather than silently installing a different Gateway package. 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. +- 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 `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. ```powershell diff --git a/scripts/Start-E2EGatewayPackageHost.ps1 b/scripts/Start-E2EGatewayPackageHost.ps1 index 01d09e1d1..471797188 100644 --- a/scripts/Start-E2EGatewayPackageHost.ps1 +++ b/scripts/Start-E2EGatewayPackageHost.ps1 @@ -81,7 +81,7 @@ function Read-OwnershipMarker { $package = Get-Item -LiteralPath $PackagePath $actualSha256 = (Get-FileHash -LiteralPath $package.FullName -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { - throw "Gateway candidate SHA-256 mismatch: expected $ExpectedSha256, got $actualSha256." + throw "Gateway composed package SHA-256 mismatch: expected $ExpectedSha256, got $actualSha256." } $existingDistros = @(& wsl.exe --list --quiet) -replace "`0", "" | ForEach-Object { $_.Trim() } @@ -133,12 +133,25 @@ try { $tail = $package.FullName.Substring(2).Replace("\", "/") $mountedPackagePath = "/mnt/$drive$tail" $hostDirectory = "/tmp/openclaw-e2e-package-host" - $hostPackagePath = "$hostDirectory/$($package.Name)" + $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 gateway candidate into the package-host distro." } + 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+") | @@ -157,7 +170,7 @@ try { & 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/$($package.Name)" + $packageSpec = "http://${hostAddress}:$Port/$hostPackageName" $readinessDeadline = [DateTime]::UtcNow.AddSeconds(30) $lastReadinessError = $null do { @@ -178,8 +191,9 @@ try { } "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 candidate host ready: distro=$DistroName sha256=$actualSha256" + Write-Host "Gateway E2E composed-package host ready: distro=$DistroName sha256=$actualSha256" } catch { $startupError = $_ $cleanupComplete = $false diff --git a/scripts/validate-mxc-e2e.ps1 b/scripts/validate-mxc-e2e.ps1 index 83ae48ad0..99af0467f 100644 --- a/scripts/validate-mxc-e2e.ps1 +++ b/scripts/validate-mxc-e2e.ps1 @@ -148,11 +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_E2E_GATEWAY_SOURCE" + "OPENCLAW_E2E_GATEWAY_SOURCE", + "OPENCLAW_E2E_GATEWAY_VERSION" ) $previousEnv = @{} foreach ($name in $trackedEnvVars) { @@ -161,13 +215,25 @@ foreach ($name in $trackedEnvVars) { try { if ([string]::IsNullOrWhiteSpace($env:OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC)) { - throw "OPENCLAW_E2E_GATEWAY_PACKAGE_SPEC must name the reviewed HTTP(S) .tgz candidate before formal MXC validation." + 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_SOURCE" -Value "candidate" + 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 f27408acd..738a47889 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.7.1"; + public const string LkgVersion = "2026.6.11"; public static string ResolveLkgVersion() => LkgVersion; diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index d73234c5c..9a771cad6 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -154,6 +154,7 @@ 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 AuthMode { get; set; } = "token"; diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index 24036db91..ebacaabac 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -1279,14 +1279,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}"); @@ -1319,9 +1328,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"; @@ -1333,6 +1348,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, diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 084a18948..5c9194180 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -85,6 +85,8 @@ "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" diff --git a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs index a0b27957a..d7c7ab600 100644 --- a/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs +++ b/tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs @@ -222,6 +222,7 @@ public async Task DisposeAsync() private void WriteConfig() { var gatewayPackageSpec = GatewayE2EPackageSpec.Resolve(); + var expectedGatewayPackageSha256 = GatewayE2EPackageSpec.ResolveExpectedSha256(); var config = new { DistroName = _distroName, @@ -258,7 +259,8 @@ private void WriteConfig() }, Gateway = new { - Version = gatewayPackageSpec + Version = gatewayPackageSpec, + ExpectedPackageSha256 = expectedGatewayPackageSha256 } }; diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs index 056909f57..ec42a96e8 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -3,27 +3,105 @@ 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 Resolve(string? sourceRaw, string? packageRaw, string lkgSpec) + 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, "candidate", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"{SourceEnvVar} must be either 'lkg' or 'candidate'."); + 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}=candidate."); + 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')) @@ -38,6 +116,17 @@ internal static string Resolve(string? sourceRaw, string? packageRaw, string lkg 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 index 0a36a9227..97f805978 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpecTests.cs @@ -4,6 +4,12 @@ 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() { @@ -24,46 +30,198 @@ public void PackageHostScript_LeavesWslInstallTargetAbsentUntilInstall() "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() { - const string lkg = "2026.7.1"; - Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve("lkg", null, lkg)); - Assert.Equal(lkg, GatewayE2EPackageSpec.Resolve(" LKG ", "", lkg)); + 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-candidate.tgz"; - Assert.Equal(packageSpec, GatewayE2EPackageSpec.Resolve("candidate", packageSpec, "2026.7.1")); + 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)] - [InlineData("", null)] - [InlineData("other", null)] - [InlineData("candidate", null)] - [InlineData("candidate", "")] - [InlineData("lkg", "https://example.test/openclaw-candidate.tgz")] - public void Resolve_RejectsAmbiguousSourceOrSpec(string? source, string? packageSpec) + [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, "2026.7.1")); + GatewayE2EPackageSpec.Resolve( + source, packageSpec, sha256, hostDistro, version, FallbackLkg)); } [Theory] [InlineData("2026.7.2")] - [InlineData("file:///tmp/openclaw-candidate.tgz")] - [InlineData("https://user:secret@example.test/openclaw-candidate.tgz")] - [InlineData("https://example.test/openclaw-candidate.zip")] - [InlineData("https://example.test/openclaw-candidate.tgz\n--dangerous")] + [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("candidate", packageSpec, "2026.7.1")); + GatewayE2EPackageSpec.Resolve( + "composed", packageSpec, ComposedSha256, ComposedHostDistro, null, FallbackLkg)); } private static string RepositoryRoot() diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs index e43889402..8b0223862 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayLkgVersionTests.cs @@ -8,7 +8,6 @@ public void ResolveLkgVersion_ReturnsEmbeddedLkg() { var version = GatewayLkgVersion.ResolveLkgVersion(); - Assert.Equal("2026.7.1", version); Assert.Equal(GatewayLkgVersion.LkgVersion, version); } 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/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index d7a851cd3..cf1da039f 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -1027,6 +1027,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() { From 47c8f89c33d6b6715fa14005ee57df57532aa5c3 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:00:57 +0100 Subject: [PATCH 09/14] fix(setup): align gateway policy and restart ownership --- README.md | 62 +++--- docs/MISSION_CONTROL.md | 2 +- docs/OPERATOR_NODE_CONCEPTS.md | 10 +- docs/SETUP_ENGINE_REDESIGN.md | 4 +- docs/WINDOWS_NODE_TESTING.md | 4 +- docs/gateway-node-integration.md | 67 ++++-- src/OpenClaw.SetupEngine/SetupSteps.cs | 129 +++++++++-- src/OpenClaw.Shared/Models.cs | 83 +++++-- .../Services/CommandCenterStateBuilder.cs | 7 +- src/skills/windows-a2ui/SKILL.md | 6 +- .../Setup/GatewayE2EPackageSpec.cs | 3 + .../Setup/MxcSetupAndConnectTests.cs | 7 +- .../Setup/SetupAndConnectTests.cs | 11 +- .../SetupStepsTests.cs | 205 +++++++++++++++++- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 82 ++++++- .../ReadmeValidationTests.cs | 28 ++- .../ConnectionPageNodeApprovalSourceTests.cs | 4 +- 17 files changed, 593 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 3424a7162..4f7a9c121 100644 --- a/README.md +++ b/README.md @@ -251,40 +251,50 @@ Packaged installs declare camera, microphone, and location capabilities. Windows openclaw devices list # Find your Windows device openclaw devices approve # Approve it ``` -4. **Configure gateway allowCommands** - Add the commands you want to allow under `gateway.nodes` in `~/.openclaw/openclaw.json`: +4. **Configure the gateway node-command allowlist** - Match the key to the Gateway version reported by `openclaw --version`. Current/frozen packages use `gateway.nodes.commands.allow`; the supported legacy versions `2026.6.11` and `2026.7.2-beta.3` use `gateway.nodes.allowCommands`. + + Current/frozen schema: ```json { "gateway": { "nodes": { - "allowCommands": [ - "system.notify", - "system.run", - "system.run.prepare", - "system.which", - "system.execApprovals.get", - "system.execApprovals.set", - "canvas.present", - "canvas.hide", - "canvas.navigate", - "canvas.eval", - "canvas.snapshot", - "canvas.a2ui.push", - "canvas.a2ui.pushJSONL", - "canvas.a2ui.reset", - "screen.snapshot", - "camera.list", - "camera.snap", - "camera.clip", - "location.get", - "device.info", - "device.status", - "tts.speak" - ] + "commands": { + "allow": [ + "system.notify", + "system.run", + "system.run.prepare", + "system.which", + "system.execApprovals.get", + "system.execApprovals.set", + "canvas.present", + "canvas.hide", + "canvas.navigate", + "canvas.eval", + "canvas.snapshot", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "screen.snapshot", + "camera.list", + "camera.snap", + "camera.clip", + "location.get", + "device.info", + "device.status", + "tts.speak" + ] + } } } } ``` - > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` and agent-driven audio playback via `tts.speak` should only be added to `allowCommands` when you explicitly want to allow them. + + For `2026.6.11` or `2026.7.2-beta.3`, preserve the complete array above but + move it directly to `gateway.nodes.allowCommands`: remove the `commands` + wrapper and rename its `allow` property to `allowCommands`. Do not shorten + the array when applying this legacy key transformation. + + > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` and agent-driven audio playback via `tts.speak` should only be added to the version-appropriate allowlist when you explicitly want to allow them. 5. **Test it** from your Mac/gateway: ```bash diff --git a/docs/MISSION_CONTROL.md b/docs/MISSION_CONTROL.md index d3629853d..00b6f9115 100644 --- a/docs/MISSION_CONTROL.md +++ b/docs/MISSION_CONTROL.md @@ -83,7 +83,7 @@ Gateway/browser facts: - `browser.proxy` is a canonical node command and included in Windows platform defaults at the gateway policy level. - Gateway policy still requires both gates: - - command allowed by platform defaults or `gateway.nodes.allowCommands` + - command allowed by platform defaults or the version-appropriate explicit allowlist: current/frozen `gateway.nodes.commands.allow`; legacy `2026.6.11` and `2026.7.2-beta.3` `gateway.nodes.allowCommands` - command declared by the node - The browser plugin/node-host contract is: - input: `method`, `path`, optional `query`, `body`, `timeoutMs`, `profile` diff --git a/docs/OPERATOR_NODE_CONCEPTS.md b/docs/OPERATOR_NODE_CONCEPTS.md index d317c9c09..8080a4d00 100644 --- a/docs/OPERATOR_NODE_CONCEPTS.md +++ b/docs/OPERATOR_NODE_CONCEPTS.md @@ -61,10 +61,12 @@ so a new or changed node capability is visible before the gateway can use it. ## Capability Allowlist Node Mode advertises available Windows commands, but the gateway decides which -commands it may call through `gateway.nodes.allowCommands` in -`~/.openclaw/openclaw.json`. Add exact command names such as `screen.snapshot`, -`canvas.present`, or `system.run`; wildcard entries are not expanded by the -gateway. +commands it may call through the explicit allowlist in +`~/.openclaw/openclaw.json`. Current/frozen packages use +`gateway.nodes.commands.allow`; exact legacy versions `2026.6.11` and +`2026.7.2-beta.3` use `gateway.nodes.allowCommands`. Add exact command names +such as `screen.snapshot`, `canvas.present`, or `system.run`; wildcard entries +are not expanded by the gateway. Privacy-sensitive commands, especially `screen.record`, `camera.snap`, `camera.clip`, `stt.transcribe`, `tts.speak`, and `system.run`, should only be diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index bbf898c47..d13b7758b 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -195,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) | +| 10 | `ConfigureGatewayStep` | Write gateway config (bind, port, auth, and explicit node-command allowlist). Current/custom packages use `gateway.nodes.commands.allow`; the pinned `2026.6.11` LKG and CI-pinned `2026.7.2-beta.3` retain their schema-compatible `gateway.nodes.allowCommands` key. | | 11 | `InstallGatewayServiceStep` | Run supported `openclaw gateway install --force`, which installs and activates or restarts the service | -| 12 | `WaitForGatewayHealthStep` | Separate retryable health-only wait for the activated service (90s timeout); do not issue a redundant start or restart | +| 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/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 75b76356d..ee51517de 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -86,7 +86,7 @@ These features need the gateway to send `node.invoke` commands: | `location.get` | Get Windows location | Uses Windows location permission/settings | | `device.info` / `device.status` | Device metadata/status | Returns host/app/locale plus battery/storage/network/uptime payloads | | `browser.proxy` | Proxy browser-control host requests | Requires Browser proxy bridge enabled, a compatible browser-control host listening on gateway port + 2, and matching browser-control auth | -| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in `gateway.nodes.allowCommands` | +| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in the version-appropriate allowlist (`gateway.nodes.commands.allow` current/frozen; `gateway.nodes.allowCommands` on `2026.6.11` and `2026.7.2-beta.3`) | | `stt.transcribe` | Bounded microphone transcription | Requires Speech-to-text enabled in Settings; uses local Whisper.net | | `stt.listen` | Voice-activity microphone transcription | Returns when the user stops speaking or timeout expires | | `stt.status` | Speech-to-text readiness | Returns Whisper.net model download/readiness state | @@ -175,7 +175,7 @@ Local MCP clients also see MCP-only `app.*` commands such as `app.navigate`, `ap - 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 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 `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. +- When reproducing this manually against an existing Gateway, make sure its version-appropriate allowlist includes `system.run`, `system.run.prepare`, and `system.which`: use `gateway.nodes.commands.allow` for current/frozen packages, or `gateway.nodes.allowCommands` for `2026.6.11` and `2026.7.2-beta.3`. Then approve any `pending-reapproval` request with `openclaw nodes approve `. The node can advertise `system.run` while the Gateway still blocks it until both gates are updated. ```powershell .\build.ps1 diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md index 58397a3bc..92acd7d59 100644 --- a/docs/gateway-node-integration.md +++ b/docs/gateway-node-integration.md @@ -50,7 +50,10 @@ REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"] SMS_DANGEROUS_COMMANDS = ["sms.send", "sms.search"] ``` -Even macOS doesn't get `camera.snap` or `camera.clip` by default! They must be added via `gateway.nodes.allowCommands`. +Even macOS doesn't get `camera.snap` or `camera.clip` by default! They must be +added through the version-appropriate allowlist: current/frozen +`gateway.nodes.commands.allow`, or legacy `gateway.nodes.allowCommands` on +`2026.6.11` and `2026.7.2-beta.3`. ### 1.3 How to Enable Privacy-Sensitive Commands for Windows @@ -63,16 +66,22 @@ allow camera capture or screen recording: { gateway: { nodes: { - allowCommands: [ - "camera.snap", - "camera.clip", - "screen.record", - ] + commands: { + allow: [ + "camera.snap", + "camera.clip", + "screen.record", + ] + } } } } ``` +The example above is the current/frozen schema. On `2026.6.11` or +`2026.7.2-beta.3`, put the same array directly under +`gateway.nodes.allowCommands`. + After changing config: ```bash openclaw gateway restart @@ -98,13 +107,16 @@ openclaw nodes approve Then reconnect the node and verify the effective command and capability counts update. Pending declarations are never effective before approval. Older gateways that do not report pending reapproval fields may still require rejecting and re-pairing the node. -### 1.5 `denyCommands` +### 1.5 Explicit deny list You can also explicitly deny commands: ```json5 -{ gateway: { nodes: { denyCommands: ["system.run"] } } } +{ gateway: { nodes: { commands: { deny: ["system.run"] } } } } ``` -`denyCommands` wins over `allowCommands`. +The current/frozen `gateway.nodes.commands.deny` key wins over +`gateway.nodes.commands.allow`. On `2026.6.11` and `2026.7.2-beta.3`, use the +legacy equivalents `gateway.nodes.denyCommands` and +`gateway.nodes.allowCommands`. --- @@ -195,9 +207,12 @@ defaults as a stricter, canonical-platform path: conservative allowlist. Our node should therefore send canonical Windows metadata. SetupEngine also -writes `gateway.nodes.allowCommands` from its enabled capability configuration +writes `gateway.nodes.commands.allow` from its enabled capability configuration for local WSL gateway installs so the first-party Windows companion flow has an explicit gateway policy matching the node's advertised commands. +The production-pinned `2026.6.11` LKG and CI-pinned `2026.7.2-beta.3` predate +that schema migration, so SetupEngine writes their equivalent +`gateway.nodes.allowCommands` key only when either exact version is selected. --- @@ -314,7 +329,7 @@ Recommended gateway defaults: | Command bucket | Windows default? | Reason | |----------------|------------------|--------| | Safe declared companion commands: `canvas.*`, `camera.list`, `location.get`, `screen.snapshot`, `device.info`, `device.status` | Yes | Matches macOS parity and only applies when declared by the node | -| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` | +| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires the version-appropriate explicit allowlist | | Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior | For the first-party Windows companion node, the practical local solution is: @@ -326,13 +341,17 @@ For the first-party Windows companion node, the practical local solution is: ### 5.1 Gateway Node Allowlist Configuration -`gateway.nodes.allowCommands` is the explicit opt-in list the gateway uses after -platform defaults. It should contain exact command names, not broad wildcard -grants, and should not be needed for the normal first-party Windows companion -commands that are allowed by canonical Windows platform policy and declared by -the live node. +`gateway.nodes.commands.allow` is the current/frozen explicit opt-in list the +gateway uses after platform defaults. Exact legacy versions `2026.6.11` and +`2026.7.2-beta.3` use `gateway.nodes.allowCommands` instead. Either form should +contain exact command names, not broad wildcard grants, and should not be needed +for normal first-party Windows companion commands allowed by canonical Windows +platform policy and declared by the live node. -`gateway.nodes.denyCommands` can be used as a final explicit blocklist when you want to suppress a command even if a platform default or allowlist entry would otherwise allow it. +The current/frozen `gateway.nodes.commands.deny` key can be used as a final +explicit blocklist; those two legacy versions use `gateway.nodes.denyCommands`. +The deny list suppresses a command even if a platform default or allowlist entry +would otherwise allow it. Privacy-sensitive commands should stay out of the default safe list and should only be added deliberately: @@ -342,7 +361,12 @@ camera.clip screen.record ``` -After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyCommands`, check Command Center for `pending-reapproval`. Copy and run its exact `openclaw nodes approve ` command, reconnect the Windows node, and verify the effective command and capability counts update. A gateway restart alone does not approve pending declarations. Older gateways without pending reapproval diagnostics may still require re-pairing. +After changing the version-appropriate allow or deny key, check Command Center +for `pending-reapproval`. Copy and run its exact +`openclaw nodes approve ` command, reconnect the Windows node, +and verify the effective command and capability counts update. A gateway restart +alone does not approve pending declarations. Older gateways without pending +reapproval diagnostics may still require re-pairing. ### 5.2 Immediate Code Fixes (This Branch) @@ -365,7 +389,8 @@ After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyComman `platform: "windows"` and `deviceFamily: "Windows"` so the gateway can apply desktop command policy without a global allowlist workaround. - [x] **Keep privacy-sensitive commands explicit opt-in** — `camera.snap`, - `camera.clip`, and `screen.record` remain behind `gateway.nodes.allowCommands`. + `camera.clip`, and `screen.record` remain behind the version-appropriate + explicit allowlist. - [x] **Add `canvas.a2ui.pushJSONL`** — current Mac supports it as a legacy JSONL alias; Windows routes it through the same A2UI push handler The gateway still enforces both gates: the node must declare a command in @@ -377,8 +402,8 @@ only declare `system.run` / `system.which` remain exec-only. When shipping the Windows node, README/wiki should tell users that normal first-party companion commands are available after pairing when the node reports canonical Windows metadata. Users should add `camera.snap`, `camera.clip`, and -`screen.record` to `gateway.nodes.allowCommands` only when they explicitly want -to allow privacy-sensitive camera or screen capture. +`screen.record` to the version-appropriate explicit allowlist only when they +want to allow privacy-sensitive camera or screen capture. > The Windows tray Command Center (`openclaw://commandcenter`) surfaces policy > problems directly, including pending pairing approval and privacy-sensitive > opt-ins. diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index ebacaabac..eff20044e 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; @@ -1453,6 +1454,8 @@ public override Task ExecuteAsync(SetupContext ctx, CancellationToke 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 @@ -1485,7 +1488,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) @@ -1497,9 +1502,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) { @@ -1538,13 +1544,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.nodes.allowCommands {escapedAllowedCommands} + openclaw config set {nodeCommandAllowConfigKey} {escapedAllowedCommands} """; if (tailscale?.Enabled == true) @@ -1601,6 +1608,9 @@ openclaw config set gateway.auth.allowTailscale {trustTailscaleAuth} return configCommands; } + internal static string ResolveNodeCommandAllowConfigKey(GatewayConfig gw) + => GatewayNodeCommandPolicyConfig.ResolveAllowKey(gw.Version); + // Budget = base + per-command, floored. Scales the WSL timeout with the number of // `openclaw config set` invocations the step emits so it cannot silently regress as // BuildConfigCommands grows. @@ -1665,13 +1675,17 @@ public sealed class WaitForGatewayHealthStep : SetupStep public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) { - var portConflict = await StartGatewayStep.CheckPortOwnershipAsync(ctx, ct); - return portConflict ?? await StartGatewayStep.WaitForHealthAsync(ctx, 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)); @@ -1691,9 +1705,22 @@ private static async Task StartOrRestartAndWaitForHealthAsync( var pathCmd = ctx.WslPathPrefix; var action = restart ? "restart" : "start"; - var portConflict = await CheckPortOwnershipAsync(ctx, ct); - if (portConflict is not null) - return portConflict; + var portOwnership = await CheckPortOwnershipAsync(ctx, ct); + if (portOwnership.Failure is not null) + return portOwnership.Failure; + + if (!restart && portOwnership.IsManagedGateway) + { + 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); + } + + if (restart && portOwnership.IsManagedGateway) + { + ctx.Logger.Info( + $"Managed OpenClaw Gateway owns port {ctx.Config.GatewayPort}; delegating its stop/start lifecycle to openclaw gateway restart"); + } var start = await ctx.Commands.RunInWslAsync( distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct); @@ -1723,24 +1750,88 @@ await ctx.Commands.RunInWslAsync( return await WaitForHealthAsync(ctx, ct); } - internal static async Task CheckPortOwnershipAsync(SetupContext ctx, CancellationToken ct) + internal static async Task CheckPortOwnershipAsync( + SetupContext ctx, + CancellationToken ct) { var portCheck = await ctx.Commands.RunInWslAsync( - ctx.DistroName!, $"ss -tlnp 2>/dev/null | grep ':{ctx.Config.GatewayPort}\\b' || true", + ctx.DistroName!, "ss -tlnp", TimeSpan.FromSeconds(10), ct: ct); - if (string.IsNullOrWhiteSpace(portCheck.Stdout) || !portCheck.Stdout.Contains($":{ctx.Config.GatewayPort}")) - return null; + 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.")); + } - if (!portCheck.Stdout.Contains("openclaw", StringComparison.OrdinalIgnoreCase)) + 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} 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.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.")); } - ctx.Logger.Info($"Port {ctx.Config.GatewayPort} appears to be in use by openclaw — proceeding"); - return null; + 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) diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 93baa963d..913d9cf34 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -987,7 +987,9 @@ public class NodeCapabilityHealthInfo public List DisabledBySettingsCommands { get; set; } = new(); public List Warnings { get; set; } = new(); - public static NodeCapabilityHealthInfo FromNode(GatewayNodeInfo node) + public static NodeCapabilityHealthInfo FromNode( + GatewayNodeInfo node, + string? gatewayVersion = null) { var commandSet = new HashSet(node.Commands, StringComparer.OrdinalIgnoreCase); var platform = node.Platform ?? ""; @@ -1051,11 +1053,13 @@ public static NodeCapabilityHealthInfo FromNode(GatewayNodeInfo node) .ToList(); } - info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info); + info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info, gatewayVersion); return info; } - public static NodeCapabilityHealthInfo FromLocalDeclarations(GatewayNodeInfo node) + public static NodeCapabilityHealthInfo FromLocalDeclarations( + GatewayNodeInfo node, + string? gatewayVersion = null) { var info = new NodeCapabilityHealthInfo { @@ -1075,7 +1079,7 @@ public static NodeCapabilityHealthInfo FromLocalDeclarations(GatewayNodeInfo nod .ToList() }; - info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info); + info.Warnings = CommandCenterDiagnostics.BuildNodeWarnings(info, gatewayVersion); return info; } } @@ -1228,6 +1232,21 @@ public static class CommandCenterCommandGroups ]; } +public static class GatewayNodeCommandPolicyConfig +{ + public const string CurrentAllowKey = "gateway.nodes.commands.allow"; + public const string LegacyAllowKey = "gateway.nodes.allowCommands"; + + public static string ResolveAllowKey(string? gatewayVersion) + { + var version = gatewayVersion?.Trim(); + return string.Equals(version, "2026.6.11", StringComparison.OrdinalIgnoreCase) || + string.Equals(version, "2026.7.2-beta.3", StringComparison.OrdinalIgnoreCase) + ? LegacyAllowKey + : CurrentAllowKey; + } +} + public static class CommandCenterDiagnostics { private static readonly IReadOnlyDictionary s_severityPriority = @@ -1248,17 +1267,29 @@ public static List SortAndDedupeWarnings(IEnumerable w.Title, StringComparer.OrdinalIgnoreCase) .ToList(); - public static string BuildAllowCommandsRepairCommand(IEnumerable commands) + public static string BuildAllowCommandsMergeGuidance( + IEnumerable commands, + string? gatewayVersion = null) { - var json = "[" + string.Join(",", commands + var commandList = string.Join(", ", commands .Where(command => !string.IsNullOrWhiteSpace(command)) .Distinct(StringComparer.OrdinalIgnoreCase) - .Order(StringComparer.OrdinalIgnoreCase) - .Select(command => $"\"{command}\"")) + "]"; - return $"openclaw config set gateway.nodes.allowCommands '{json}'"; + .Order(StringComparer.OrdinalIgnoreCase)); + if (string.IsNullOrWhiteSpace(commandList)) + commandList = "none"; + var allowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); + return string.Join(Environment.NewLine, [ + "Read the complete current allowlist before changing it:", + $"openclaw config get {allowKey} --json", + $"Preserve every existing entry and add only: {commandList}", + "Then write the complete updated array:", + $"openclaw config set {allowKey} ''" + ]); } - public static string BuildDangerousCommandOptInGuidance(IEnumerable commands) + public static string BuildDangerousCommandOptInGuidance( + IEnumerable commands, + string? gatewayVersion = null) { var commandList = string.Join(", ", commands .Where(command => !string.IsNullOrWhiteSpace(command)) @@ -1266,12 +1297,13 @@ public static string BuildDangerousCommandOptInGuidance(IEnumerable comm .Order(StringComparer.OrdinalIgnoreCase)); if (string.IsNullOrWhiteSpace(commandList)) commandList = "none"; + var allowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); return string.Join(Environment.NewLine, [ "Privacy-sensitive OpenClaw command opt-in guidance", $"Commands: {commandList}", "Leave these commands blocked unless you explicitly want the connected gateway to use this device's camera, microphone, or screen recording surfaces.", - "If you opt in, add only the exact commands you need to gateway.nodes.allowCommands, then re-approve or re-pair the node so the gateway refreshes its command snapshot.", + $"If you opt in, add only the exact commands you need to {allowKey}, then re-approve or re-pair the node so the gateway refreshes its command snapshot.", "Do not use wildcards for privacy-sensitive commands." ]); } @@ -1394,9 +1426,12 @@ public static List BuildTopologyWarnings( return warnings; } - public static List BuildNodeWarnings(NodeCapabilityHealthInfo node) + public static List BuildNodeWarnings( + NodeCapabilityHealthInfo node, + string? gatewayVersion = null) { var warnings = new List(); + var nodeCommandAllowKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion); var isPendingApproval = node.ApprovalState is GatewayNodeApprovalState.PendingApproval or GatewayNodeApprovalState.PendingReapproval; @@ -1482,9 +1517,11 @@ GatewayNodeApprovalState.PendingApproval or Severity = GatewayDiagnosticSeverity.Warning, Category = "allowlist", Title = "Safe node commands are filtered by gateway policy", - Detail = $"{missing} {(node.MissingSafeAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. After changing allowCommands, re-approve or re-pair the device if the gateway keeps an older command snapshot.", - RepairAction = "Copy safe allowlist repair command", - CopyText = BuildAllowCommandsRepairCommand(CommandCenterCommandGroups.SafeCompanionCommands) + Detail = $"{missing} {(node.MissingSafeAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. After changing {nodeCommandAllowKey}, re-approve or re-pair the device if the gateway keeps an older command snapshot.", + RepairAction = "Copy safe allowlist merge guidance", + CopyText = BuildAllowCommandsMergeGuidance( + CommandCenterCommandGroups.SafeCompanionCommands, + gatewayVersion) }); } @@ -1495,9 +1532,11 @@ GatewayNodeApprovalState.PendingApproval or Severity = GatewayDiagnosticSeverity.Info, Category = "allowlist", Title = "Privacy-sensitive commands require explicit opt-in", - Detail = string.Join(", ", node.PrivacySensitiveApprovedCommands) + " should only be available when explicitly allowed by gateway.nodes.allowCommands.", + Detail = string.Join(", ", node.PrivacySensitiveApprovedCommands) + $" should only be available when explicitly allowed by {nodeCommandAllowKey}.", RepairAction = "Copy opt-in guidance", - CopyText = BuildDangerousCommandOptInGuidance(node.PrivacySensitiveApprovedCommands) + CopyText = BuildDangerousCommandOptInGuidance( + node.PrivacySensitiveApprovedCommands, + gatewayVersion) }); } @@ -1511,7 +1550,9 @@ GatewayNodeApprovalState.PendingApproval or Title = "Privacy-sensitive commands are currently blocked", Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.", RepairAction = "Copy opt-in guidance", - CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands) + CopyText = BuildDangerousCommandOptInGuidance( + node.MissingDangerousAllowlistCommands, + gatewayVersion) }); } @@ -1524,8 +1565,10 @@ GatewayNodeApprovalState.PendingApproval or Category = "allowlist", Title = "Browser proxy command is filtered by gateway policy", Detail = $"{blocked} {(node.MissingBrowserAllowlistCommands.Count == 1 ? "is" : "are")} approved for the node but denied by current gateway permissions. Add the exact browser command and re-approve or re-pair the node if the gateway keeps an older command snapshot.", - RepairAction = "Copy browser proxy allowlist repair command", - CopyText = BuildAllowCommandsRepairCommand(node.MissingBrowserAllowlistCommands) + RepairAction = "Copy browser proxy allowlist merge guidance", + CopyText = BuildAllowCommandsMergeGuidance( + node.MissingBrowserAllowlistCommands, + gatewayVersion) }); } diff --git a/src/OpenClaw.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..b2d083e94 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/frozen +packages, or `gateway.nodes.allowCommands` for exact legacy versions +`2026.6.11` and `2026.7.2-beta.3`. ``` canvas.a2ui.push diff --git a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs index ec42a96e8..faf33ef9d 100644 --- a/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs +++ b/tests/OpenClaw.E2ETests/Setup/GatewayE2EPackageSpec.cs @@ -16,6 +16,9 @@ internal static string Resolve() => Resolve( Environment.GetEnvironmentVariable(VersionEnvVar), OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion()); + internal static string ResolveNodeCommandAllowConfigKey() + => OpenClaw.Shared.GatewayNodeCommandPolicyConfig.ResolveAllowKey(Resolve()); + internal static string? ResolveExpectedSha256() => ResolveExpectedSha256( Environment.GetEnvironmentVariable(SourceEnvVar), Environment.GetEnvironmentVariable(Sha256EnvVar)); 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/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 9e4ec5060..0864011b7 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -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/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index cf1da039f..d4df4b929 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -1734,6 +1734,48 @@ public void ConfigureGateway_WritesConfiguredReloadMode() Assert.DoesNotContain("openclaw config set gateway.reload.mode off", 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_ExtraConfigOverridesReloadMode() { @@ -1792,11 +1834,33 @@ var value when value.Contains("curl -s") => Ok("200"), [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") - ? Ok("LISTEN 0 4096 127.0.0.1:18789 users:((\"python\",pid=42,fd=3))") + ? Fail("ss unavailable") : Fail($"Unexpected command: {command}")); var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; @@ -1804,10 +1868,136 @@ public async Task StartGateway_RestartRejectsUnrelatedPortOwner() var result = await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None); Assert.False(result.IsSuccess); - Assert.Contains("already in use by another process", result.Message, StringComparison.Ordinal); + 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() { @@ -1876,6 +2066,7 @@ 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}"), @@ -1908,6 +2099,7 @@ public async Task SetupWizard_RestoreAbsentReloadModeIsIdempotentAfterUnsetSucce { 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}"), @@ -1944,6 +2136,8 @@ public async Task SetupWizard_WritesRecoveryMarkerBeforeSuspendingReload() }; return Ok(); } + if (command.Contains("ss -tlnp")) + return Ok(); if (command.Contains("openclaw gateway restart")) return Ok(); if (command.Contains("curl -s")) @@ -2021,6 +2215,8 @@ public async Task SetupWizard_BeginSuspensionReconcilesPreviousRecoveryFirst() 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")) @@ -2100,6 +2296,8 @@ public async Task SetupWizard_SuccessfulRetryClearsInMemoryRecoveryState() 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")) @@ -2249,6 +2447,8 @@ public async Task SetupWizard_BeginReloadSuspensionRestoresWhenSuspensionFails() 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")) @@ -2506,6 +2706,7 @@ private static FakeCommandRunner CreateReloadRestorationRunner() => (_, 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}"), diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index 3fb021718..53f54a6e7 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -1432,8 +1432,14 @@ public void NodeCapabilityHealthInfo_WarnsSpecificallyForBlockedBrowserProxy() 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,78 @@ public void NodeCapabilityHealthInfo_TreatsDisabledCommandsAsSettingsChoice() } [Fact] - public void BuildAllowCommandsRepairCommand_IsStableAndDeduplicated() + public void BuildAllowCommandsMergeGuidance_PreservesExistingArray() { - var command = CommandCenterDiagnostics.BuildAllowCommandsRepairCommand( + var guidance = CommandCenterDiagnostics.BuildAllowCommandsMergeGuidance( ["screen.snapshot", "canvas.present", "screen.snapshot"]); - Assert.Equal("openclaw config set gateway.nodes.allowCommands '[\"canvas.present\",\"screen.snapshot\"]'", command); + 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("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] 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/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); From 46a222dd8d0bc3a1e98f10db78472be0503c09c1 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:16:45 +0100 Subject: [PATCH 10/14] fix(setup): honor gateway reload mode schemas --- docs/SETUP_ENGINE_REDESIGN.md | 2 +- src/OpenClaw.SetupEngine/SetupContext.cs | 2 +- src/OpenClaw.SetupEngine/SetupSteps.cs | 32 +++++-- src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 5 +- src/OpenClaw.SetupEngine/default-config.json | 5 +- .../SetupStepsTests.cs | 86 +++++++++++++------ 6 files changed, 93 insertions(+), 39 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index d13b7758b..742b98466 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -142,7 +142,7 @@ rerun setup with a supported new name. "Version": null, "ExpectedPackageSha256": null, "HealthTimeoutSeconds": 90, - "ReloadMode": "hot", + "ReloadMode": "hybrid", "AuthMode": "token", "ExtraConfig": null }, diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index 9a771cad6..4f3c01dea 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -156,7 +156,7 @@ public sealed class GatewayConfig 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/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index eff20044e..b10b272c2 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -1451,6 +1451,22 @@ public override Task ExecuteAsync(SetupContext ctx, CancellationToke new SetupWizardRunner(ctx).ReconcilePendingReloadRecoveryAsync(); } +internal static class GatewayReloadModeConfig +{ + internal static string Resolve(string? gatewayVersion, string configuredMode) + { + var version = gatewayVersion?.Trim(); + var usesLegacySchema = + string.Equals(version, GatewayLkgVersion.LkgVersion, StringComparison.OrdinalIgnoreCase) || + string.Equals(version, "2026.7.2-beta.3", StringComparison.OrdinalIgnoreCase); + + if (usesLegacySchema) + return configuredMode; + + return configuredMode is "hot" or "restart" ? "hybrid" : configuredMode; + } +} + public sealed class ConfigureGatewayStep : SetupStep { internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl"; @@ -1595,14 +1611,18 @@ openclaw config set gateway.auth.allowTailscale {trustTailscaleAuth} if (!IsSafeExtraConfigKey(key)) throw new ArgumentException($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.", nameof(gw)); - var escapedValue = WslShellQuoting.QuotePosixSingleQuote(value); + var compatibleValue = string.Equals(key, "gateway.reload.mode", StringComparison.Ordinal) + ? GatewayReloadModeConfig.Resolve(gw.Version, value) + : value; + var escapedValue = WslShellQuoting.QuotePosixSingleQuote(compatibleValue); configCommands += $"\n openclaw config set {key} {escapedValue}"; } } if (gw.ExtraConfig?.ContainsKey("gateway.reload.mode") != true) { - configCommands += $"\n openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(gw.ReloadMode)}"; + var reloadMode = GatewayReloadModeConfig.Resolve(gw.Version, gw.ReloadMode); + configCommands += $"\n openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(reloadMode)}"; } return configCommands; @@ -1636,9 +1656,11 @@ private static int CountConfigSetCommands(string configCommands) gw.Bind == "loopback" && !tailscaleEnabled ? $"http://127.0.0.1:{port}" : null; internal static string GetEffectiveReloadMode(GatewayConfig gw) => - gw.ExtraConfig?.TryGetValue("gateway.reload.mode", out var overrideMode) == true - ? overrideMode - : gw.ReloadMode; + GatewayReloadModeConfig.Resolve( + gw.Version, + gw.ExtraConfig?.TryGetValue("gateway.reload.mode", out var overrideMode) == true + ? overrideMode + : gw.ReloadMode); internal static bool IsSafeExtraConfigKey(string value) => System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9._-]+$"); diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 12b28307e..183924150 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -495,8 +495,9 @@ internal async Task RestoreReloadModeAsync(GatewayReloadRecoveryStat } var hadExplicitReloadMode = recovery is null || recovery.ReloadMode is not null; - var reloadMode = recovery?.ReloadMode - ?? ConfigureGatewayStep.GetEffectiveReloadMode(_ctx.Config.Gateway); + var reloadMode = recovery?.ReloadMode is { } recoveredReloadMode + ? GatewayReloadModeConfig.Resolve(_ctx.Config.Gateway.Version, recoveredReloadMode) + : ConfigureGatewayStep.GetEffectiveReloadMode(_ctx.Config.Gateway); var restoreCommand = hadExplicitReloadMode ? $"openclaw config set gateway.reload.mode {WslShellQuoting.QuotePosixSingleQuote(reloadMode)}" : "openclaw config unset gateway.reload.mode"; diff --git a/src/OpenClaw.SetupEngine/default-config.json b/src/OpenClaw.SetupEngine/default-config.json index 5c9194180..b7349ff79 100644 --- a/src/OpenClaw.SetupEngine/default-config.json +++ b/src/OpenClaw.SetupEngine/default-config.json @@ -89,8 +89,9 @@ "ExpectedPackageSha256": null, // Seconds to wait for gateway health endpoint before failing "HealthTimeoutSeconds": 90, - // Config reload strategy: "hot" (no restart), "restart" - "ReloadMode": "hot", + // Config reload strategy. "hybrid" and "off" work on current and legacy gateways. + // Legacy 2026.6.11 and 2026.7.2-beta.3 also accept "hot" and "restart". + "ReloadMode": "hybrid", // Auth mode: "token" (shared secret) "AuthMode": "token", // Additional gateway config key/value pairs (applied via `openclaw config set`) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index d4df4b929..fe7162263 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -864,17 +864,22 @@ public async Task RecoverGatewayReload_RestoresPendingModeBeforeApplyingCurrentC _ => Ok(), (_, command, _) => command switch { - var value when value.Contains("config set gateway.reload.mode 'restart'") => Ok(), + var value when value.Contains("GATEWAY_CONFIGURED") => Ok("GATEWAY_CONFIGURED"), + var value when value.Contains("config set gateway.reload.mode 'hybrid'") => 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"), - var value when value.Contains("GATEWAY_CONFIGURED") => Ok("GATEWAY_CONFIGURED"), _ => Fail($"Unexpected command: {command}"), }); var ctx = CreateContext( new SetupConfig { CleanBeforeRun = false, - Gateway = new GatewayConfig { ReloadMode = "hot" }, + Gateway = new GatewayConfig + { + Version = "https://127.0.0.1/openclaw-composed.tgz", + ReloadMode = "hot", + }, }, commands); ctx.DistroName = "test-distro"; @@ -885,9 +890,9 @@ var value when value.Contains("GATEWAY_CONFIGURED") => Ok("GATEWAY_CONFIGURED"), rollbackOnFailureOverride: false).RunAsync(ctx); Assert.Equal(PipelineOutcome.Success, result.Outcome); - Assert.Contains("config set gateway.reload.mode 'restart'", commands.WslCalls[0].Command); + Assert.Contains("config set gateway.reload.mode 'hybrid'", 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.Contains("config set gateway.reload.mode 'hybrid'", configureCall.Command); Assert.True(commands.WslCalls.IndexOf(configureCall) > 0); Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); } @@ -1722,16 +1727,39 @@ public void ConfigureGateway_RejectsUnsafeExtraConfigKeys(string key) Assert.False(ConfigureGatewayStep.IsSafeExtraConfigKey(key)); } - [Fact] - public void ConfigureGateway_WritesConfiguredReloadMode() + [Theory] + [InlineData("hot")] + [InlineData("restart")] + public void ConfigureGateway_MapsLegacyReloadModesToHybridForCurrentSchema(string reloadMode) { var commands = ConfigureGatewayStep.BuildConfigCommands( - new GatewayConfig { ReloadMode = "hot" }, + new GatewayConfig + { + Version = "https://127.0.0.1/openclaw-composed.tgz", + ReloadMode = reloadMode, + }, 18789, "'[]'"); - Assert.Contains("openclaw config set gateway.reload.mode 'hot'", commands); - Assert.DoesNotContain("openclaw config set gateway.reload.mode off", commands); + Assert.Contains("openclaw config set gateway.reload.mode 'hybrid'", commands); + Assert.DoesNotContain($"gateway.reload.mode '{reloadMode}'", commands); + } + + [Theory] + [InlineData("2026.6.11", "hot")] + [InlineData("2026.6.11", "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] @@ -1782,6 +1810,7 @@ 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", @@ -1792,7 +1821,7 @@ public void ConfigureGateway_ExtraConfigOverridesReloadMode() 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); + Assert.Contains("openclaw config set gateway.reload.mode 'hybrid'", commands); } [Fact] @@ -1800,6 +1829,7 @@ public void ConfigureGateway_EffectiveReloadModeUsesExtraConfigOverride() { var config = new GatewayConfig { + Version = "https://127.0.0.1/openclaw-composed.tgz", ReloadMode = "hot", ExtraConfig = new Dictionary { @@ -1807,7 +1837,7 @@ public void ConfigureGateway_EffectiveReloadModeUsesExtraConfigOverride() }, }; - Assert.Equal("restart", ConfigureGatewayStep.GetEffectiveReloadMode(config)); + Assert.Equal("hybrid", ConfigureGatewayStep.GetEffectiveReloadMode(config)); } [Fact] @@ -2005,7 +2035,7 @@ public async Task SetupWizard_RestoreReloadModeRestartsAndVerifiesGateway() _ => Ok(), (_, command, _) => command switch { - var value when value.Contains("config set gateway.reload.mode 'hot'") => Ok(), + var value when value.Contains("config set gateway.reload.mode 'hybrid'") => 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"), @@ -2019,7 +2049,7 @@ var value when value.Contains("curl -s") => Ok("200"), 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("config set gateway.reload.mode 'hybrid'")); Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); } @@ -2086,7 +2116,7 @@ var value when value.Contains("curl -s") => Ok("200"), 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 'hot'")); + Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hybrid'")); Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); } @@ -2197,7 +2227,7 @@ public async Task SetupWizard_ReconcilesRecoveryBeforeSkippingWizard() 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.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hybrid'")); Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("gateway.reload.mode off")); Assert.False(File.Exists(GatewayReloadRecoveryStore.GetPath(ctx))); } @@ -2209,7 +2239,7 @@ public async Task SetupWizard_BeginSuspensionReconcilesPreviousRecoveryFirst() _ => Ok(), (_, command, _) => { - if (command.Contains("config set gateway.reload.mode 'restart'")) + if (command.Contains("config set gateway.reload.mode 'hybrid'")) return Ok(); if (command.Contains("config get gateway.reload.mode --json")) return Ok("\"hot\""); @@ -2233,7 +2263,7 @@ public async Task SetupWizard_BeginSuspensionReconcilesPreviousRecoveryFirst() 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); + Assert.Contains("config set gateway.reload.mode 'hybrid'", 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); @@ -2246,7 +2276,7 @@ public async Task SetupWizard_FailedRecoveryLeavesMarkerForRetry() { var commands = new FakeCommandRunner( _ => Ok(), - (_, command, _) => command.Contains("config set gateway.reload.mode 'hot'") + (_, command, _) => command.Contains("config set gateway.reload.mode 'hybrid'") ? Fail("restore failed") : Fail($"Unexpected command: {command}")); var ctx = CreateContext(commands: commands); @@ -2266,7 +2296,7 @@ public async Task SetupWizard_FailedRecoveryRestartLeavesMarkerForRetry() _ => Ok(), (_, command, _) => { - if (command.Contains("config set gateway.reload.mode 'hot'")) + if (command.Contains("config set gateway.reload.mode 'hybrid'")) return Ok(); if (command.Contains("openclaw gateway restart")) return Fail("restart failed"); @@ -2294,7 +2324,7 @@ public async Task SetupWizard_SuccessfulRetryClearsInMemoryRecoveryState() 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'")) + if (command.Contains("config set gateway.reload.mode 'hybrid'")) return allowRestore ? Ok() : Fail("restore failed"); if (command.Contains("ss -tlnp")) return Ok(); @@ -2445,7 +2475,7 @@ public async Task SetupWizard_BeginReloadSuspensionRestoresWhenSuspensionFails() 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'")) + if (command.Contains("config set gateway.reload.mode 'hybrid'")) return Ok(); if (command.Contains("ss -tlnp")) return Ok(); @@ -2461,7 +2491,7 @@ public async Task SetupWizard_BeginReloadSuspensionRestoresWhenSuspensionFails() 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("config set gateway.reload.mode 'hybrid'")); Assert.Contains(commands.WslCalls, call => call.Command.Contains("openclaw gateway restart")); } @@ -2522,7 +2552,7 @@ public async Task WaitForGatewayHealth_RejectsUnrelatedPortOwnerBeforePolling() var result = await new WaitForGatewayHealthStep().ExecuteAsync(ctx, CancellationToken.None); Assert.False(result.IsSuccess); - Assert.Contains("already in use by another process", result.Message); + Assert.Contains("already in use by another or unattributable process", result.Message); Assert.DoesNotContain(commands.WslCalls, call => call.Command.Contains("curl -s")); } @@ -2657,7 +2687,7 @@ public async Task SetupWizard_OrchestratorReturnsRestorationFailure() { var commands = new FakeCommandRunner( _ => Ok(), - (_, command, _) => command.Contains("config set gateway.reload.mode 'hot'") + (_, command, _) => command.Contains("config set gateway.reload.mode 'hybrid'") ? Fail("restore failed") : Fail($"Unexpected command: {command}")); var ctx = CreateContext(commands: commands); @@ -2680,7 +2710,7 @@ public async Task SetupWizard_OrchestratorPrioritizesRestorationFailureOverCance { var commands = new FakeCommandRunner( _ => Ok(), - (_, command, _) => command.Contains("config set gateway.reload.mode 'hot'") + (_, command, _) => command.Contains("config set gateway.reload.mode 'hybrid'") ? Fail("restore failed") : Fail($"Unexpected command: {command}")); var ctx = CreateContext(commands: commands); @@ -2705,7 +2735,7 @@ private static FakeCommandRunner CreateReloadRestorationRunner() => _ => Ok(), (_, command, _) => command switch { - var value when value.Contains("config set gateway.reload.mode 'hot'") => Ok(), + var value when value.Contains("config set gateway.reload.mode 'hybrid'") => 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"), @@ -2714,7 +2744,7 @@ var value when value.Contains("curl -s") => Ok("200"), private static void AssertReloadRestorationCompleted(FakeCommandRunner commands) { - Assert.Contains(commands.WslCalls, call => call.Command.Contains("config set gateway.reload.mode 'hot'")); + 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")); } From 7e5ee5858897e99d34a91d3ec40804aec70b79a2 Mon Sep 17 00:00:00 2001 From: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:24:27 +0100 Subject: [PATCH 11/14] feat: update Companion-owned Gateway safely in place --- docs/ARCHITECTURE.md | 2 + docs/CONNECTION_ARCHITECTURE.md | 18 + .../GatewayConnectionManager.cs | 20 +- .../GatewayRollbackPointManager.cs | 1314 ++++++++++++ .../GatewayVersionAlignmentCoordinator.cs | 999 +++++++++ src/OpenClaw.Connection/WslCommandRunner.cs | 130 ++ src/OpenClaw.Shared/SettingsData.cs | 4 + src/OpenClaw.Tray.WinUI/App.xaml.cs | 351 ++++ .../Pages/SettingsPage.xaml | 51 + .../Pages/SettingsPage.xaml.cs | 171 ++ .../Presentation/ISettingsStore.cs | 5 + .../Presentation/SettingsPageViewModel.cs | 43 + .../Presentation/SettingsStore.cs | 4 + .../Services/SettingsManager.cs | 18 +- .../GatewayConnectionManagerTests.cs | 15 +- ...GatewayVersionAlignmentCoordinatorTests.cs | 1822 +++++++++++++++++ .../NodePairAutoApproveTests.cs | 10 +- .../PairingFlowTests.cs | 10 +- .../StaleEventGuardTests.cs | 10 +- .../SetupStepsTests.cs | 50 +- tests/OpenClaw.Shared.Tests/ModelsTests.cs | 21 +- .../AppRefactorContractTests.cs | 117 ++ ...nectionManagerWindowsNodeConnectorTests.cs | 9 +- .../SettingsPageViewModelTests.cs | 22 + .../Presentation/SettingsStoreTests.cs | 4 + .../SettingsRoundTripTests.cs | 81 +- 26 files changed, 5264 insertions(+), 37 deletions(-) create mode 100644 src/OpenClaw.Connection/GatewayRollbackPointManager.cs create mode 100644 src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs create mode 100644 tests/OpenClaw.Connection.Tests/GatewayVersionAlignmentCoordinatorTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11c15352a..6fa55eda9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -117,6 +117,8 @@ leading and trailing pipe. Columns, in order: | wsl-posix-quoting | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | ad hoc ShellEscape with divergent wrap semantics | WslShellQuoting | - | WSL command lines use POSIX single-quote quoting via WslShellQuoting not cmd/PowerShell quoting | WslShellQuotingTests.QuotePosixSingleQuote_WrapsAndEscapesEmbeddedQuote | behavioral | when no code builds WSL command lines outside WslShellQuoting | | setup-shellescape-closed | closed | src/OpenClaw.SetupEngine/SetupSteps.cs | private ShellEscape helpers with divergent wrap semantics | WslShellQuoting | - | SetupSteps builds WSL command lines only via WslShellQuoting; no local ShellEscape helper | SetupStepsShellEscapeClosureTests.SetupSteps_DoesNotReintroduce_PrivateShellEscape | source-shape | when SetupSteps.cs no longer builds any WSL command strings | | wsl-distro-install-path | authoritative | OpenClaw.SetupEngine/SetupSteps.cs | inline Path.Combine wsl distro install-path derivation | DistroInstallPathPolicy | - | new installs use the strict supported name grammar; teardown accepts only unambiguous single-segment names whose canonical path is an immediate child of LocalDataDir\wsl with no aliases, case or Unicode collisions, or reparse points at the root or child | SetupStepsTests.DistroInstallPathPolicy_ResolvesImmediateChild | behavioral | - | +| gateway-version-alignment | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | local WSL version probe, pinned update transaction, and recovery result policy | OpenClaw.Connection/GatewayVersionAlignmentCoordinator | App composes the service and presents confirmation/results only | normal update never unregisters/imports; exact package version, full rollback point, and Gateway/Node/pairing health gate completion | GatewayVersionAlignmentCoordinatorTests.UpdateAsync_CreatesOfflineVerifiedPointThenUpdatesAndCleansOnlyAfterHealth | behavioral | - | +| gateway-rollback-points | authoritative | none | offline VHD export, manifest receipts, retention, verification, and explicit emergency restore | OpenClaw.Connection/GatewayRollbackPointManager | SettingsPage owns selection and confirmation UI only | immutable verified VHD is retained outside the live distro; unregister/import-in-place is restore-only; direct ext4.vhdx replacement is forbidden | GatewayVersionAlignmentCoordinatorTests.RestoreAsync_RecreatesOnlyRegistrationThenProvesVersionIdentityAndSynchronization | behavioral | - | | app-window-manager | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | window creation/show/hide/shutdown | IWindowManager | composition/delegation only | startup/shutdown ordering deterministic; disposed once | none | review-only | extracted in Phase 3 | | app-tray-controller | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | tray icon/menu/action routing | ITrayController | composition/delegation only | tray actions route unchanged | none | review-only | extracted in Phase 3 | | app-activation-router | planned | src/OpenClaw.Tray.WinUI/App.xaml.cs | deep-link/toast/single-instance activation | IActivationRouter | composition/delegation only | activation routes land on the same UI/actions; current-user pipe security preserved | none | review-only | extracted in Phase 3 | diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index facd11278..f86737703 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -105,6 +105,24 @@ Settings changes are classified by `SettingsChangeClassifier.Classify()` which c | `OperatorReconnectRequired` | Reconnect operator (SSH tunnel changed) | | `FullReconnectRequired` | Full tear down and reconnect (gateway URL changed) | +## Companion-owned Gateway update and rollback + +`GatewayVersionAlignmentCoordinator` owns exact installed-version comparison and the update transaction for the fixed Companion-owned WSL distro. A normal update: + +1. proves the active gateway is the setup-managed WSL gateway; +2. proves current Gateway, Companion, Windows Node, and pairing health; +3. asks `GatewayRollbackPointManager` to terminate only that distro and export a complete offline VHD; +4. verifies the VHDX signature, byte size, SHA-256, and private manifest receipt, and attests the distro machine identity plus exact OpenClaw version (including build metadata) immediately before stop and after export; +5. runs `openclaw update --tag --yes --json` in the existing distro; +6. verifies the exact installed version and resynchronizes Gateway, Companion, Windows Node, and pairing; +7. re-verifies the rollback point, marks the update healthy, and only then applies retention cleanup. + +Normal update never unregisters, imports, recreates, or directly replaces the distro's `ext4.vhdx`. A failed or ambiguous update preserves the verified rollback point and requires an explicit restore decision. Its target version is stored in the durable receipt, so retry reuses the same point without requiring the disrupted pre-update runtime to pass the new-update health gate again. Pending-update discovery is scoped to the fixed Companion-owned distro rather than the mutable Gateway record ID; a receipt belonging to an earlier record blocks for explicit recovery instead of starting a second transaction. A later Companion requiring a different target likewise detects the pending transaction and blocks. For a fresh transaction, `UpdateInProgress` is flushed before the final live attestation; immediately after that receipt write and before every updater invocation, and again before post-update healthy marking or cleanup, canonical registration BasePath, machine identity, WSL registration configuration, effective default user, and the expected exact normalized package version must still match the receipt and observed transaction state. Exact equality is ordinal and includes prerelease and build metadata. Ordered numeric build identifiers such as `companion.3` are compared without discarding metadata; differing metadata that cannot be safely ordered blocks instead of guessing. Concurrent drift, including a newer observed package, preserves the receipt and never triggers a downgrade. If the package is already aligned after a transient health failure, retry resumes synchronization, rollback verification, exact live attestation, healthy marking, and cleanup instead of silently abandoning the transaction. + +Emergency restore is a separate confirmed operation. It copies the immutable retained VHD through a private `.partial` staging file, flushes and verifies it before promotion, and safely recreates stale or invalid regular staging files from the immutable point before any destructive WSL lifecycle call. Before unregister, it verifies the same-name WSL registry entry maps to the canonical app-owned `BasePath`, the directory contains only its regular `ext4.vhdx`, and no owned path boundary is a reparse point; it repeats host-side validation after termination and revalidates registration absence/path safety before import. It may then unregister the current Companion-owned distro, move the verified staged copy into the canonical app-owned install directory, and use documented `wsl --import-in-place` under the same distro name. The retained rollback VHD never becomes the mutable live disk. Manifest receipts independently capture the supported WSL registration version/default UID/flags and the effective default username/UID selected inside the distro on both sides of export; `/etc/wsl.conf` may make those UID values differ. Receipt writes use write-through and disk flush before `UnregisterPending` and `ImportPending` lifecycle transitions. Across the entire Companion-owned distro, regardless of a later Gateway record-id change, a receipt in `RestoreStaged`, `UnregisterPending`, `DistroUnregistered`, `ImportPending`, or `Imported` blocks both the app-level package probe and every fresh update. Multiple unresolved restore receipts fail as ambiguous. Only `RestoreStaged` may be durably cancelled as `RestoreCancelled`; Settings exposes that exact-point-confirmed non-destructive exit. After the unregister boundary, recovery must resume the same point, and `Imported` remains blocking until full synchronization plus a final exact live attestation records `RestoreHealthy`. Once a receipt reaches the unregister boundary, later probe/preflight failures preserve that monotonic phase rather than degrading it to a generic failure that could repeat lifecycle operations. After import, the supported WSL configuration API restores the recorded registration UID/flags and readback must match before the internal machine identity and independently recorded effective default user are accepted. Durable phases support retry after unregister or failed import; a same-name distro with a different internal machine identity, mismatched registration/default user, or any registration/install-path collision fails closed. + +Retention keeps at least the newest verified known-good point. Settings allow one previous version (default), two, or indefinite retention. Optional age retention is additive: points inside the age window are kept in addition to the count floor. Points are cleanup-eligible only after successful post-update or post-restore health. + ## Connection state machine `ConnectionStateMachine` (internal) drives state transitions for both operator and node roles: diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 3e7c4c57a..8fb93e945 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -57,6 +57,7 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private readonly IClock _clock; private readonly Func? _shouldStartNodeConnection; private readonly Func _reconnectDelay; + private readonly TimeSpan _sharedTokenValidationTimeout; private readonly SemaphoreSlim _transitionSemaphore = new(1, 1); private readonly SemaphoreSlim _nodeStartSemaphore = new(1, 1); private readonly object _nodeOperationLock = new(); @@ -117,7 +118,8 @@ public GatewayConnectionManager( ConnectionDiagnostics? diagnostics = null, ISshTunnelManager? tunnelManager = null, Func? shouldStartNodeConnection = null, - Func? reconnectDelay = null) + Func? reconnectDelay = null, + TimeSpan? sharedTokenValidationTimeout = null) { _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); @@ -130,6 +132,7 @@ public GatewayConnectionManager( _clock = clock ?? SystemClock.Instance; _shouldStartNodeConnection = shouldStartNodeConnection; _reconnectDelay = reconnectDelay ?? Task.Delay; + _sharedTokenValidationTimeout = sharedTokenValidationTimeout ?? TimeSpan.FromSeconds(15); _diagnostics = diagnostics ?? new ConnectionDiagnostics(clock: clock); _diagnostics.EventRecorded += (_, e) => DiagnosticEvent?.Invoke(this, e); @@ -832,11 +835,20 @@ private async Task ValidateSharedTokenBeforeReplacementAsync( try { - await client.ConnectAsync(); - var completed = await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(15))); - if (completed != completion.Task) + var connectTask = client.ConnectAsync(); + var timeoutTask = Task.Delay(_sharedTokenValidationTimeout); + var completed = await Task.WhenAny(connectTask, completion.Task, timeoutTask); + if (completed == timeoutTask) return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, "Timed out validating shared gateway token"); + if (completed == connectTask) + { + await connectTask; + completed = await Task.WhenAny(completion.Task, timeoutTask); + if (completed == timeoutTask) + return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, "Timed out validating shared gateway token"); + } + return await completion.Task; } catch (Exception ex) diff --git a/src/OpenClaw.Connection/GatewayRollbackPointManager.cs b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs new file mode 100644 index 000000000..2180f65d4 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayRollbackPointManager.cs @@ -0,0 +1,1314 @@ +using OpenClaw.Shared.Mcp; +using System.Security.Cryptography; +using System.Security; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace OpenClaw.Connection; + +public enum GatewayRollbackPointPhase +{ + Creating, + DistroStopped, + Verified, + UpdateInProgress, + PostUpdateHealthy, + RestoreStaged, + UnregisterPending, + DistroUnregistered, + ImportPending, + Imported, + RestoreCancelled, + RestoreHealthy, + Failed +} + +public enum GatewayRollbackPointVerificationStatus +{ + Pending, + Verified, + Failed +} + +public sealed record GatewayRollbackPointManifest +{ + public int SchemaVersion { get; init; } = 3; + public required string Id { get; init; } + public required string DistroName { get; init; } + public required string GatewayId { get; init; } + public required string OpenClawVersion { get; init; } + public required string TargetOpenClawVersion { get; init; } + public required string InternalIdentitySha256 { get; init; } + public required uint RegistrationVersion { get; init; } + public required uint RegistrationDefaultUid { get; init; } + public required uint RegistrationFlags { get; init; } + public required string DefaultUserName { get; init; } + public required uint DefaultUserUid { get; init; } + public required DateTimeOffset CreatedAtUtc { get; init; } + public required DateTimeOffset UpdatedAtUtc { get; init; } + public required string VhdSha256 { get; init; } + public required long VhdSizeBytes { get; init; } + public required GatewayRollbackPointVerificationStatus VerificationStatus { get; init; } + public required GatewayRollbackPointPhase Phase { get; init; } + public required bool WasKnownGood { get; init; } + public required bool RestoreEligible { get; init; } + public string? NodeCommandAllowSnapshotJson { get; init; } + public string? LastFailureCode { get; init; } +} + +public sealed record GatewayRollbackPointInfo( + string Id, + string DistroName, + string OpenClawVersion, + DateTimeOffset CreatedAtUtc, + GatewayRollbackPointVerificationStatus VerificationStatus, + GatewayRollbackPointPhase Phase, + long ApproximateSizeBytes, + bool RestoreEligible, + string? FailureCode); + +public sealed record GatewayRollbackRetentionPolicy(int RetainPreviousVersions, TimeSpan? RetainYoungerThan) +{ + public static GatewayRollbackRetentionPolicy Default { get; } = new(1, null); + + public bool RetainIndefinitely => RetainPreviousVersions == -1; + + public void Validate() + { + if (RetainPreviousVersions is not (1 or 2 or -1)) + throw new ArgumentOutOfRangeException(nameof(RetainPreviousVersions), "Retention must be 1, 2, or -1 for indefinitely."); + if (RetainYoungerThan is { } age && age <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(RetainYoungerThan), "Age retention must be positive when enabled."); + } +} + +public enum GatewayRollbackOperationState +{ + Created, + Restored, + ConfirmationRequired, + NotFound, + VerificationFailed, + OwnershipMismatch, + SameNameCollision, + InstallPathCollision, + TerminationFailed, + UnregisterFailed, + ImportPending, + Cancelled, + ResumeRequired, + AmbiguousRecovery, + Failed +} + +public sealed record GatewayRollbackOperationResult( + GatewayRollbackOperationState State, + GatewayRollbackPointManifest? Point = null, + string? FailureCode = null, + int? ExitCode = null) +{ + public bool Success => State is GatewayRollbackOperationState.Created + or GatewayRollbackOperationState.Restored + or GatewayRollbackOperationState.Cancelled; +} + +/// +/// Owns immutable, offline VHD rollback points for one Companion-owned WSL distro. +/// Normal update never unregisters or imports. Those lifecycle operations are +/// reachable only through the explicit restore method and a matching point id. +/// +public sealed partial class GatewayRollbackPointManager +{ + private const string ManifestFileName = "manifest.json"; + private const string RollbackVhdFileName = "rollback.vhdx"; + private const string LiveVhdFileName = "ext4.vhdx"; + private const string VhdxSignature = "vhdxfile"; + private readonly IWslCommandRunner _commandRunner; + private readonly string _ownedDistroName; + private readonly string _localDataRoot; + private readonly string _pointsRoot; + private readonly string _stagingRoot; + private readonly Func _utcNow; + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + public GatewayRollbackPointManager( + IWslCommandRunner commandRunner, + string localDataRoot, + string ownedDistroName, + Func? utcNow = null) + { + ArgumentNullException.ThrowIfNull(commandRunner); + ArgumentException.ThrowIfNullOrWhiteSpace(localDataRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(ownedDistroName); + if (!DistroNameRegex().IsMatch(ownedDistroName)) + throw new ArgumentException("Owned distro name is not a safe WSL/path name.", nameof(ownedDistroName)); + + _commandRunner = commandRunner; + _ownedDistroName = ownedDistroName; + _localDataRoot = NormalizePath(localDataRoot); + _pointsRoot = NormalizePath(Path.Combine(_localDataRoot, "gateway-rollback-points", ownedDistroName)); + _stagingRoot = NormalizePath(Path.Combine(_localDataRoot, "gateway-rollback-staging", ownedDistroName)); + _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow); + } + + public string OwnedDistroName => _ownedDistroName; + + public bool HasUnreadableReceipt() + { + if (!Directory.Exists(_pointsRoot)) + return false; + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return true; + + try + { + foreach (var pointDirectory in Directory.EnumerateDirectories(_pointsRoot, "*", SearchOption.TopDirectoryOnly)) + { + var pointId = Path.GetFileName(pointDirectory); + if (!PointIdRegex().IsMatch(pointId)) + continue; + if (!HasSafePathBoundary(_pointsRoot, pointDirectory)) + return true; + + var manifestPath = Path.Combine(pointDirectory, ManifestFileName); + if (!File.Exists(manifestPath) || + IsReparsePoint(manifestPath) || + !TryReadManifest(manifestPath, out var manifest) || + manifest is null || + !string.Equals(manifest.Id, pointId, StringComparison.Ordinal) || + !IsManifestOwned(manifest)) + { + return true; + } + } + + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + return true; + } + } + + public IReadOnlyList List() + { + if (!Directory.Exists(_pointsRoot) || + !HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return []; + + var points = new List(); + foreach (var pointDirectory in Directory.EnumerateDirectories(_pointsRoot, "*", SearchOption.TopDirectoryOnly)) + { + if (!PointIdRegex().IsMatch(Path.GetFileName(pointDirectory)) || + !HasSafePathBoundary(_pointsRoot, pointDirectory)) + { + continue; + } + var manifestPath = Path.Combine(pointDirectory, ManifestFileName); + if (!File.Exists(manifestPath) || IsReparsePoint(manifestPath)) + continue; + if (!TryReadManifest(manifestPath, out var manifest) || manifest is null) + continue; + points.Add(ToInfo(manifest)); + } + + return points.OrderByDescending(point => point.CreatedAtUtc).ThenByDescending(point => point.Id, StringComparer.Ordinal).ToArray(); + } + + public async Task CreateVerifiedAsync( + string distroName, + string gatewayId, + string openClawVersion, + string targetOpenClawVersion, + CancellationToken cancellationToken = default) + { + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (!ExactVersionRegex().IsMatch(openClawVersion) || !ExactVersionRegex().IsMatch(targetOpenClawVersion)) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "version_attestation_invalid"); + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "rollback_receipt_unreadable"); + + var liveRegistration = await ValidateOwnedLiveRegistrationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!liveRegistration.Valid) + return new(liveRegistration.State, FailureCode: liveRegistration.FailureCode); + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || registration.Configuration is null || registration.Configuration.Version != 2) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "registration_configuration_probe_failed"); + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "default_user_probe_failed"); + var identity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (identity is null) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "identity_probe_failed"); + var versionBeforeStop = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(versionBeforeStop, openClawVersion, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.VerificationFailed, FailureCode: "version_attestation_changed"); + + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + EnsurePrivateDirectory(_pointsRoot); + if (!HasSafePathBoundary(_localDataRoot, _pointsRoot)) + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_points_reparse_boundary"); + var now = _utcNow(); + var pointId = $"{now:yyyyMMddTHHmmssfffZ}-{Guid.NewGuid():N}"; + var pointDirectory = GetPointDirectory(pointId); + EnsurePrivateDirectory(pointDirectory); + var vhdPath = Path.Combine(pointDirectory, RollbackVhdFileName); + var partialPath = vhdPath + ".partial"; + if (!HasSafePathBoundary(_pointsRoot, pointDirectory) || + !HasSafePathBoundary(pointDirectory, vhdPath) || + !HasSafePathBoundary(pointDirectory, partialPath)) + { + return new(GatewayRollbackOperationState.InstallPathCollision, FailureCode: "rollback_point_reparse_boundary"); + } + var manifest = new GatewayRollbackPointManifest + { + Id = pointId, + DistroName = distroName, + GatewayId = gatewayId, + OpenClawVersion = openClawVersion, + TargetOpenClawVersion = targetOpenClawVersion, + InternalIdentitySha256 = identity, + RegistrationVersion = registration.Configuration.Version, + RegistrationDefaultUid = registration.Configuration.DefaultUid, + RegistrationFlags = registration.Configuration.Flags, + DefaultUserName = defaultUser.Name, + DefaultUserUid = defaultUser.Uid, + CreatedAtUtc = now, + UpdatedAtUtc = now, + VhdSha256 = string.Empty, + VhdSizeBytes = 0, + VerificationStatus = GatewayRollbackPointVerificationStatus.Pending, + Phase = GatewayRollbackPointPhase.Creating, + WasKnownGood = true, + RestoreEligible = false + }; + WriteManifest(manifest); + var verifiedManifestCommitted = false; + + try + { + var terminate = await _commandRunner.TerminateDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!terminate.Success) + return Fail(manifest, GatewayRollbackOperationState.TerminationFailed, "terminate_failed", terminate.ExitCode); + + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.DistroStopped); + var export = await _commandRunner.RunAsync( + ["--export", distroName, partialPath, "--vhd"], + cancellationToken).ConfigureAwait(false); + if (!export.Success) + return Fail(manifest, GatewayRollbackOperationState.Failed, "vhd_export_failed", export.ExitCode); + + if (!File.Exists(partialPath)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "vhd_export_missing"); + + File.Move(partialPath, vhdPath, overwrite: false); + var verification = await VerifyVhdAsync(vhdPath, expectedSha256: null, expectedSize: null, cancellationToken) + .ConfigureAwait(false); + if (!verification.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, verification.FailureCode ?? "vhd_verify_failed"); + + // The retained VHD is opaque to the host. Attest the package version and + // distro identity immediately on both sides of the stop/export boundary; + // any concurrent mutation makes the point ineligible before update. + var versionAfterExport = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + var identityAfterExport = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + var registrationAfterExport = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + var defaultUserAfterExport = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(versionAfterExport, openClawVersion, StringComparison.Ordinal) || + !string.Equals(identityAfterExport, identity, StringComparison.Ordinal) || + !registrationAfterExport.Success || + registrationAfterExport.Configuration != registration.Configuration || + defaultUserAfterExport != defaultUser) + { + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "exported_state_attestation_changed"); + } + + manifest = manifest with + { + UpdatedAtUtc = _utcNow(), + VhdSha256 = verification.Sha256!, + VhdSizeBytes = verification.SizeBytes, + VerificationStatus = GatewayRollbackPointVerificationStatus.Verified, + Phase = GatewayRollbackPointPhase.Verified, + RestoreEligible = true, + LastFailureCode = null + }; + WriteManifest(manifest); + verifiedManifestCommitted = true; + return new(GatewayRollbackOperationState.Created, manifest); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or CryptographicException) + { + return Fail(manifest, GatewayRollbackOperationState.Failed, $"{ex.GetType().Name.ToLowerInvariant()}"); + } + finally + { + TryDeleteGeneratedFile(partialPath); + if (!verifiedManifestCommitted) + TryDeleteGeneratedFile(vhdPath); + } + } + + public IReadOnlyList FindPendingUpdates(string? targetOpenClawVersion = null) => + LoadOwnedManifests() + .Where(point => + point.Phase == GatewayRollbackPointPhase.UpdateInProgress && + (targetOpenClawVersion is null || + string.Equals(point.TargetOpenClawVersion, targetOpenClawVersion, StringComparison.Ordinal))) + .OrderByDescending(point => point.CreatedAtUtc) + .ThenByDescending(point => point.Id, StringComparer.Ordinal) + .ToArray(); + + public GatewayRollbackPointManifest? FindPendingUpdate(string gatewayId, string? targetOpenClawVersion = null) => + FindPendingUpdates(targetOpenClawVersion).FirstOrDefault(); + + public IReadOnlyList FindUnresolvedRestores() => + LoadOwnedManifests() + .Where(point => + IsUnresolvedRestorePhase(point.Phase)) + .OrderBy(point => point.CreatedAtUtc) + .ThenBy(point => point.Id, StringComparer.Ordinal) + .ToArray(); + + public async Task VerifyAsync(string pointId, CancellationToken cancellationToken = default) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + return false; + if (manifest.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !manifest.WasKnownGood || !manifest.RestoreEligible) + return false; + + var result = await VerifyVhdAsync( + GetRollbackVhdPath(pointId), manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!result.Verified) + { + WriteManifest(manifest with + { + UpdatedAtUtc = _utcNow(), + VerificationStatus = GatewayRollbackPointVerificationStatus.Failed, + RestoreEligible = false, + LastFailureCode = result.FailureCode ?? "point_verify_failed" + }); + } + return result.Verified; + } + + public async Task AttestLiveDistroAsync( + string pointId, + string distroName, + string expectedExactVersion, + CancellationToken cancellationToken = default) + { + var normalizedExpectedVersion = expectedExactVersion?.Trim(); + if (!IsOwnedDistro(distroName) || + normalizedExpectedVersion is null || + !ExactVersionRegex().IsMatch(normalizedExpectedVersion) || + !TryLoadPoint(pointId, out var manifest) || + manifest is null || + !IsManifestOwned(manifest)) + { + return false; + } + + var registrations = await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false); + var matching = registrations + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matching.Length != 1) + return false; + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + if (!string.Equals(registeredBasePath, GetInstallDirectory(), StringComparison.OrdinalIgnoreCase)) + return false; + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || + registration.Configuration is null || + registration.Configuration.Version != manifest.RegistrationVersion || + registration.Configuration.DefaultUid != manifest.RegistrationDefaultUid || + registration.Configuration.Flags != manifest.RegistrationFlags) + { + return false; + } + + var identity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(identity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return false; + + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null || + defaultUser.Name != manifest.DefaultUserName || + defaultUser.Uid != manifest.DefaultUserUid) + { + return false; + } + + var exactVersion = await ProbeExactVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + return string.Equals(exactVersion, normalizedExpectedVersion, StringComparison.Ordinal); + } + + public void MarkUpdateInProgress(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.UpdateInProgress); + + public void MarkPostUpdateHealthy(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.PostUpdateHealthy); + + public void MarkRestoreHealthy(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.RestoreHealthy); + + public void MarkImported(string pointId) => UpdatePhase(pointId, GatewayRollbackPointPhase.Imported); + + public GatewayRollbackPointManifest RecordNodeCommandAllowSnapshot(string pointId, string normalizedArrayJson) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + throw new InvalidOperationException("Rollback point is missing or is not owned by this Companion distro."); + if (manifest.Phase != GatewayRollbackPointPhase.Verified || + !IsCompleteCommandArrayJson(normalizedArrayJson)) + { + throw new InvalidOperationException("The node command policy snapshot is invalid for this rollback point."); + } + + var updated = manifest with + { + NodeCommandAllowSnapshotJson = normalizedArrayJson, + UpdatedAtUtc = _utcNow(), + LastFailureCode = null + }; + WriteManifest(updated); + return updated; + } + + public GatewayRollbackOperationResult CancelRestore( + string distroName, + string gatewayId, + string pointId) + { + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "rollback_receipt_unreadable"); + + var unresolved = FindUnresolvedRestores(); + if (unresolved.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "multiple_unresolved_restores"); + if (unresolved.Count == 1 && !string.Equals(unresolved[0].Id, pointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ResumeRequired, unresolved[0], "restore_resume_required"); + if (!TryLoadPoint(pointId, out var manifest) || manifest is null) + return new(GatewayRollbackOperationState.NotFound, FailureCode: "point_not_found"); + if (!IsManifestOwned(manifest) || + (!IsRecoveryReceiptPhase(manifest.Phase) && + !string.Equals(manifest.GatewayId, gatewayId, StringComparison.Ordinal))) + { + return new(GatewayRollbackOperationState.OwnershipMismatch, manifest, "point_ownership_mismatch"); + } + if (manifest.Phase != GatewayRollbackPointPhase.RestoreStaged) + return new(GatewayRollbackOperationState.ResumeRequired, manifest, "restore_resume_required"); + + var stagePath = GetStageVhdPath(pointId); + if (!DeleteGeneratedFile(stagePath, _stagingRoot)) + return new(GatewayRollbackOperationState.VerificationFailed, manifest, "restore_stage_cancel_cleanup_failed"); + + var cancelled = UpdateManifest(manifest, GatewayRollbackPointPhase.RestoreCancelled); + return new(GatewayRollbackOperationState.Cancelled, cancelled); + } + + public async Task RestoreExplicitAsync( + string distroName, + string gatewayId, + string pointId, + string confirmedPointId, + CancellationToken cancellationToken = default) + { + if (!string.Equals(pointId, confirmedPointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ConfirmationRequired, FailureCode: "confirmation_required"); + if (!IsOwnedDistro(distroName) || string.IsNullOrWhiteSpace(gatewayId)) + return new(GatewayRollbackOperationState.OwnershipMismatch, FailureCode: "ownership_mismatch"); + if (HasUnreadableReceipt()) + return new(GatewayRollbackOperationState.AmbiguousRecovery, FailureCode: "rollback_receipt_unreadable"); + if (!TryLoadPoint(pointId, out var manifest) || manifest is null) + return new(GatewayRollbackOperationState.NotFound, FailureCode: "point_not_found"); + if (!IsManifestOwned(manifest) || + (!IsRecoveryReceiptPhase(manifest.Phase) && + !string.Equals(manifest.GatewayId, gatewayId, StringComparison.Ordinal))) + return new(GatewayRollbackOperationState.OwnershipMismatch, manifest, "point_ownership_mismatch"); + + var pendingUpdates = FindPendingUpdates(); + if (pendingUpdates.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, manifest, "multiple_pending_updates"); + if (pendingUpdates.Count == 1 && + !string.Equals(pendingUpdates[0].Id, pointId, StringComparison.Ordinal)) + { + return new(GatewayRollbackOperationState.ResumeRequired, pendingUpdates[0], "update_recovery_resume_required"); + } + + var unresolved = FindUnresolvedRestores(); + if (unresolved.Count > 1) + return new(GatewayRollbackOperationState.AmbiguousRecovery, manifest, "multiple_unresolved_restores"); + if (unresolved.Count == 1 && !string.Equals(unresolved[0].Id, pointId, StringComparison.Ordinal)) + return new(GatewayRollbackOperationState.ResumeRequired, unresolved[0], "restore_resume_required"); + + if (!await VerifyAsync(pointId, cancellationToken).ConfigureAwait(false)) + { + if (TryLoadPoint(pointId, out var invalidated) && invalidated is not null) + manifest = invalidated; + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "point_verify_failed"); + } + + var stagePath = GetStageVhdPath(pointId); + var installDirectory = GetInstallDirectory(); + var liveVhdPath = Path.Combine(installDirectory, LiveVhdFileName); + + try + { + var distroList = await ListDistroNamesAsync(cancellationToken).ConfigureAwait(false); + if (!distroList.Success) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "distro_list_failed", distroList.ExitCode); + var sameNameCount = distroList.Names.Count(name => string.Equals(name, distroName, StringComparison.OrdinalIgnoreCase)); + if (sameNameCount > 1) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "duplicate_same_name_registration"); + var sameNameExists = sameNameCount == 1; + var initialPreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists, cancellationToken).ConfigureAwait(false); + if (!initialPreflight.Valid) + return Fail(manifest, initialPreflight.State, initialPreflight.FailureCode); + if (sameNameExists && manifest.Phase is GatewayRollbackPointPhase.ImportPending or GatewayRollbackPointPhase.Imported) + { + var existingIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(existingIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "same_name_identity_collision"); + return await FinalizeImportedRegistrationAsync(distroName, manifest, cancellationToken).ConfigureAwait(false); + } + if (!sameNameExists && manifest.Phase == GatewayRollbackPointPhase.Imported) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "restored_registration_missing"); + if (sameNameExists && manifest.Phase == GatewayRollbackPointPhase.DistroUnregistered) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "registration_reappeared_after_unregister"); + + if (PathExists(stagePath) && !HasSafePathBoundary(_stagingRoot, stagePath)) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "restore_stage_reparse_boundary"); + + if (File.Exists(stagePath)) + { + var staged = await VerifyVhdAsync(stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken) + .ConfigureAwait(false); + if (!staged.Verified) + { + if (!DeleteGeneratedFile(stagePath, _stagingRoot)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_invalid_delete_failed"); + var recreated = await CreateVerifiedStageAsync(stagePath, manifest, cancellationToken).ConfigureAwait(false); + if (!recreated) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_recreate_failed"); + } + } + else if (!sameNameExists && File.Exists(liveVhdPath)) + { + var movedStage = await VerifyVhdAsync( + liveVhdPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!movedStage.Verified) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "recovery_import_vhd_mismatch"); + } + else + { + if (!await CreateVerifiedStageAsync(stagePath, manifest, cancellationToken).ConfigureAwait(false)) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_recreate_failed"); + } + + if (!IsDestructiveRecoveryPhase(manifest.Phase)) + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.RestoreStaged); + + if (sameNameExists) + { + var existingIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(existingIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "same_name_identity_collision"); + + var terminate = await _commandRunner.TerminateDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!terminate.Success) + return Fail(manifest, GatewayRollbackOperationState.TerminationFailed, "restore_terminate_failed", terminate.ExitCode); + + var destructivePreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists: true, cancellationToken).ConfigureAwait(false); + if (!destructivePreflight.Valid) + return Fail(manifest, destructivePreflight.State, destructivePreflight.FailureCode); + + if (!HasSafePathBoundary(_stagingRoot, stagePath)) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, "restore_stage_reparse_boundary"); + var destructiveStage = await VerifyVhdAsync( + stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!destructiveStage.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_stage_changed_before_unregister"); + + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.UnregisterPending); + var unregister = await _commandRunner.UnregisterDistroAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!unregister.Success) + return Fail(manifest, GatewayRollbackOperationState.UnregisterFailed, "restore_unregister_failed", unregister.ExitCode); + } + + if (manifest.Phase is not GatewayRollbackPointPhase.DistroUnregistered + and not GatewayRollbackPointPhase.ImportPending) + { + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.DistroUnregistered); + } + var importPath = await PrepareCanonicalImportVhdAsync( + stagePath, liveVhdPath, manifest, cancellationToken).ConfigureAwait(false); + if (!importPath.Prepared) + return Fail(manifest, GatewayRollbackOperationState.InstallPathCollision, importPath.FailureCode ?? "install_path_collision"); + + var importPreflight = await ValidateRestorePreflightAsync( + distroName, installDirectory, liveVhdPath, sameNameExists: false, cancellationToken).ConfigureAwait(false); + if (!importPreflight.Valid) + return Fail(manifest, importPreflight.State, importPreflight.FailureCode); + + if (manifest.Phase == GatewayRollbackPointPhase.DistroUnregistered) + manifest = UpdateManifest(manifest, GatewayRollbackPointPhase.ImportPending); + var promotedVhd = await VerifyVhdAsync( + liveVhdPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!promotedVhd.Verified) + return Fail(manifest, GatewayRollbackOperationState.VerificationFailed, "restore_import_vhd_mismatch", preservePhase: true); + var import = await _commandRunner.RunAsync( + ["--import-in-place", distroName, liveVhdPath], cancellationToken).ConfigureAwait(false); + if (!import.Success) + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restore_import_failed", import.ExitCode, preservePhase: true); + + return await FinalizeImportedRegistrationAsync(distroName, manifest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or CryptographicException or SecurityException) + { + return Fail(manifest, GatewayRollbackOperationState.Failed, ex.GetType().Name.ToLowerInvariant()); + } + } + + public async Task CleanupAsync( + GatewayRollbackRetentionPolicy policy, + CancellationToken cancellationToken = default) + { + policy.Validate(); + var ownedManifests = LoadOwnedManifests(); + foreach (var unverified in ownedManifests.Where(point => + point.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !point.RestoreEligible)) + { + cancellationToken.ThrowIfCancellationRequested(); + var pointDirectory = GetPointDirectory(unverified.Id); + var rollbackVhdPath = GetRollbackVhdPath(unverified.Id); + DeleteGeneratedFile(rollbackVhdPath, pointDirectory); + DeleteGeneratedFile(rollbackVhdPath + ".partial", pointDirectory); + } + + var manifests = ownedManifests + .Where(point => point.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified && point.RestoreEligible) + .OrderByDescending(point => point.CreatedAtUtc) + .ThenByDescending(point => point.Id, StringComparer.Ordinal) + .ToArray(); + if (manifests.Length == 0 || policy.RetainIndefinitely) + return 0; + + var keep = new HashSet(StringComparer.Ordinal) { manifests[0].Id }; + foreach (var point in manifests.Take(policy.RetainPreviousVersions)) + keep.Add(point.Id); + if (policy.RetainYoungerThan is { } age) + { + var cutoff = _utcNow() - age; + foreach (var point in manifests.Where(point => point.CreatedAtUtc >= cutoff)) + keep.Add(point.Id); + } + + var removed = 0; + foreach (var point in manifests) + { + cancellationToken.ThrowIfCancellationRequested(); + if (keep.Contains(point.Id) || point.Phase is not (GatewayRollbackPointPhase.PostUpdateHealthy or GatewayRollbackPointPhase.RestoreHealthy)) + continue; + if (DeletePointFilesOnly(point.Id)) + removed++; + await Task.Yield(); + } + return removed; + } + + private async Task<(bool Prepared, string? FailureCode)> PrepareCanonicalImportVhdAsync( + string stagePath, + string liveVhdPath, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + var installDirectory = GetInstallDirectory(); + if (!HasSafePathBoundary(_localDataRoot, installDirectory) || + !HasSafePathBoundary(_localDataRoot, liveVhdPath) || + !HasSafePathBoundary(_localDataRoot, stagePath)) + { + return (false, "restore_path_reparse_boundary"); + } + if (Directory.Exists(installDirectory)) + { + var entries = Directory.GetFileSystemEntries(installDirectory); + if (entries.Length > 0) + { + if (entries.Length == 1 && + string.Equals(NormalizePath(entries[0]), NormalizePath(liveVhdPath), StringComparison.OrdinalIgnoreCase) && + File.Exists(liveVhdPath) && + (await VerifyVhdAsync( + liveVhdPath, + manifest.VhdSha256, + manifest.VhdSizeBytes, + cancellationToken).ConfigureAwait(false)).Verified) + { + return (true, null); + } + return (false, "install_path_not_empty"); + } + } + else + { + Directory.CreateDirectory(installDirectory); + if (!HasSafePathBoundary(_localDataRoot, installDirectory)) + return (false, "restore_path_reparse_boundary"); + } + + if (!File.Exists(stagePath)) + return (false, "restore_stage_missing"); + File.Move(stagePath, liveVhdPath, overwrite: false); + return (true, null); + } + + private async Task CreateVerifiedStageAsync( + string stagePath, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + EnsurePrivateDirectory(_stagingRoot); + if (!HasSafePathBoundary(_stagingRoot, stagePath)) + return false; + + var partialPath = stagePath + ".partial"; + if (File.Exists(partialPath) && !DeleteGeneratedFile(partialPath, _stagingRoot)) + return false; + + try + { + await CopyAndFlushAsync(GetRollbackVhdPath(manifest.Id), partialPath, cancellationToken).ConfigureAwait(false); + var partial = await VerifyVhdAsync( + partialPath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + if (!partial.Verified) + return false; + + File.Move(partialPath, stagePath, overwrite: false); + var staged = await VerifyVhdAsync( + stagePath, manifest.VhdSha256, manifest.VhdSizeBytes, cancellationToken).ConfigureAwait(false); + return staged.Verified; + } + finally + { + DeleteGeneratedFile(partialPath, _stagingRoot); + } + } + + private async Task<(bool Valid, GatewayRollbackOperationState State, string FailureCode)> ValidateRestorePreflightAsync( + string distroName, + string installDirectory, + string liveVhdPath, + bool sameNameExists, + CancellationToken cancellationToken) + { + if (!HasSafePathBoundary(_localDataRoot, installDirectory) || + !HasSafePathBoundary(_localDataRoot, liveVhdPath) || + !HasSafePathBoundary(_localDataRoot, _stagingRoot) || + !HasSafePathBoundary(_localDataRoot, _pointsRoot)) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "restore_path_reparse_boundary"); + } + + var registrations = await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false); + var matching = registrations + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (!sameNameExists) + { + return matching.Length == 0 + ? (true, GatewayRollbackOperationState.Restored, string.Empty) + : (false, GatewayRollbackOperationState.SameNameCollision, "registration_list_disagrees"); + } + + if (matching.Length != 1) + return (false, GatewayRollbackOperationState.SameNameCollision, "registration_missing_or_duplicate"); + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_invalid"); + } + + if (!string.Equals(registeredBasePath, installDirectory, StringComparison.OrdinalIgnoreCase)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_mismatch"); + if (!Directory.Exists(installDirectory) || !File.Exists(liveVhdPath)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registered_install_path_missing"); + + var entries = Directory.GetFileSystemEntries(installDirectory).Select(NormalizePath).ToArray(); + if (entries.Length != 1 || !string.Equals(entries[0], NormalizePath(liveVhdPath), StringComparison.OrdinalIgnoreCase)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "install_path_not_exclusive"); + + return (true, GatewayRollbackOperationState.Restored, string.Empty); + } + + private async Task<(bool Valid, GatewayRollbackOperationState State, string FailureCode)> ValidateOwnedLiveRegistrationAsync( + string distroName, + CancellationToken cancellationToken) + { + var installDirectory = GetInstallDirectory(); + if (!HasSafePathBoundary(_localDataRoot, installDirectory)) + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_reparse_boundary"); + + var matching = (await _commandRunner.ListRegistrationsAsync(cancellationToken).ConfigureAwait(false)) + .Where(registration => string.Equals(registration.Name, distroName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (matching.Length != 1) + return (false, GatewayRollbackOperationState.OwnershipMismatch, "registration_missing_or_duplicate"); + + string registeredBasePath; + try { registeredBasePath = NormalizePath(matching[0].BasePath); } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_invalid"); + } + + return string.Equals(registeredBasePath, installDirectory, StringComparison.OrdinalIgnoreCase) + ? (true, GatewayRollbackOperationState.Created, string.Empty) + : (false, GatewayRollbackOperationState.InstallPathCollision, "registration_base_path_mismatch"); + } + + private GatewayRollbackOperationResult Fail( + GatewayRollbackPointManifest manifest, + GatewayRollbackOperationState state, + string failureCode, + int? exitCode = null, + bool preservePhase = false) + { + var keepRecoveryPhase = preservePhase || IsRecoveryReceiptPhase(manifest.Phase); + var failed = manifest with + { + UpdatedAtUtc = _utcNow(), + VerificationStatus = manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified + ? manifest.VerificationStatus + : GatewayRollbackPointVerificationStatus.Failed, + Phase = keepRecoveryPhase ? manifest.Phase : GatewayRollbackPointPhase.Failed, + RestoreEligible = manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified && manifest.WasKnownGood, + LastFailureCode = failureCode + }; + WriteManifest(failed); + return new(state, failed, failureCode, exitCode); + } + + private static bool IsDestructiveRecoveryPhase(GatewayRollbackPointPhase phase) => + phase is GatewayRollbackPointPhase.UnregisterPending + or GatewayRollbackPointPhase.DistroUnregistered + or GatewayRollbackPointPhase.ImportPending + or GatewayRollbackPointPhase.Imported; + + private static bool IsUnresolvedRestorePhase(GatewayRollbackPointPhase phase) => + phase is GatewayRollbackPointPhase.RestoreStaged + or GatewayRollbackPointPhase.UnregisterPending + or GatewayRollbackPointPhase.DistroUnregistered + or GatewayRollbackPointPhase.ImportPending + or GatewayRollbackPointPhase.Imported; + + private static bool IsRecoveryReceiptPhase(GatewayRollbackPointPhase phase) => + phase == GatewayRollbackPointPhase.UpdateInProgress || IsUnresolvedRestorePhase(phase); + + private GatewayRollbackPointManifest UpdateManifest( + GatewayRollbackPointManifest manifest, + GatewayRollbackPointPhase phase) + { + var updated = manifest with { Phase = phase, UpdatedAtUtc = _utcNow(), LastFailureCode = null }; + WriteManifest(updated); + return updated; + } + + private void UpdatePhase(string pointId, GatewayRollbackPointPhase phase) + { + if (!TryLoadPoint(pointId, out var manifest) || manifest is null || !IsManifestOwned(manifest)) + throw new InvalidOperationException("Rollback point is missing or is not owned by this Companion distro."); + UpdateManifest(manifest, phase); + } + + private async Task ProbeInternalIdentityHashAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, ["sh", "-lc", "cat /etc/machine-id"], cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var identity = result.StandardOutput.Trim(); + if (!MachineIdRegex().IsMatch(identity)) + return null; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); + } + + private async Task ProbeDefaultUserAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, + ["sh", "-lc", "printf '%s\\n%s\\n' \"$(id -un)\" \"$(id -u)\""], + cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var lines = result.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length != 2 || + !DefaultUserNameRegex().IsMatch(lines[0]) || + !uint.TryParse(lines[1], out var uid)) + { + return null; + } + return new(lines[0], uid); + } + + private async Task FinalizeImportedRegistrationAsync( + string distroName, + GatewayRollbackPointManifest manifest, + CancellationToken cancellationToken) + { + var configure = await _commandRunner.ConfigureDistroRegistrationAsync( + distroName, + manifest.RegistrationDefaultUid, + manifest.RegistrationFlags, + cancellationToken).ConfigureAwait(false); + if (!configure.Success) + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restore_registration_configure_failed", configure.ExitCode, preservePhase: true); + + var registration = await _commandRunner.GetDistroConfigurationAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!registration.Success || + registration.Configuration is null || + registration.Configuration.Version != manifest.RegistrationVersion || + registration.Configuration.DefaultUid != manifest.RegistrationDefaultUid || + registration.Configuration.Flags != manifest.RegistrationFlags) + { + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restored_registration_mismatch", preservePhase: true); + } + + var importedIdentity = await ProbeInternalIdentityHashAsync(distroName, cancellationToken).ConfigureAwait(false); + if (!string.Equals(importedIdentity, manifest.InternalIdentitySha256, StringComparison.Ordinal)) + return Fail(manifest, GatewayRollbackOperationState.SameNameCollision, "restored_identity_mismatch", preservePhase: true); + + var defaultUser = await ProbeDefaultUserAsync(distroName, cancellationToken).ConfigureAwait(false); + if (defaultUser is null || + defaultUser.Name != manifest.DefaultUserName || + defaultUser.Uid != manifest.DefaultUserUid) + { + return Fail(manifest, GatewayRollbackOperationState.ImportPending, "restored_default_user_mismatch", preservePhase: true); + } + + var imported = UpdateManifest(manifest, GatewayRollbackPointPhase.Imported); + return new(GatewayRollbackOperationState.Restored, imported); + } + + private async Task ProbeExactVersionAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, GatewayVersionAlignmentCommandBuilder.BuildProbe(), cancellationToken).ConfigureAwait(false); + if (!result.Success) + return null; + var match = VersionOutputRegex().Match(result.StandardOutput ?? string.Empty); + return match.Success ? match.Groups["version"].Value : null; + } + + private async Task<(bool Success, IReadOnlyList Names, int ExitCode)> ListDistroNamesAsync( + CancellationToken cancellationToken) + { + var result = await _commandRunner.RunAsync(["--list", "--quiet"], cancellationToken).ConfigureAwait(false); + if (!result.Success) + return (false, Array.Empty(), result.ExitCode); + var names = result.StandardOutput + .Replace("\0", string.Empty, StringComparison.Ordinal) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(name => name.Length > 0) + .ToArray(); + return (true, names, result.ExitCode); + } + + private async Task VerifyVhdAsync( + string path, + string? expectedSha256, + long? expectedSize, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return new(false, null, 0, "vhd_missing"); + + var header = new byte[8]; + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var size = stream.Length; + if (size < header.Length || (expectedSize.HasValue && size != expectedSize.Value)) + return new(false, null, size, "vhd_size_mismatch"); + + var read = await stream.ReadAsync(header, cancellationToken).ConfigureAwait(false); + if (read != header.Length || !string.Equals(Encoding.ASCII.GetString(header), VhdxSignature, StringComparison.Ordinal)) + return new(false, null, size, "vhdx_signature_invalid"); + + stream.Position = 0; + var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)) + .ToLowerInvariant(); + if (stream.Length != size) + return new(false, hash, stream.Length, "vhd_size_changed"); + if (expectedSha256 is not null && !string.Equals(hash, expectedSha256, StringComparison.Ordinal)) + return new(false, hash, size, "vhd_hash_mismatch"); + return new(true, hash, size, null); + } + + private static async Task CopyAndFlushAsync(string source, string destination, CancellationToken cancellationToken) + { + await using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read, 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1024 * 1024, FileOptions.Asynchronous | FileOptions.WriteThrough); + await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + private void WriteManifest(GatewayRollbackPointManifest manifest) + { + var directory = GetPointDirectory(manifest.Id); + EnsurePrivateDirectory(directory); + var path = Path.Combine(directory, ManifestFileName); + var temp = path + ".tmp"; + DeleteGeneratedFile(temp, directory); + var bytes = new UTF8Encoding(false).GetBytes(JsonSerializer.Serialize(manifest, _jsonOptions)); + using (var stream = new FileStream( + temp, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(path)) + File.Replace(temp, path, destinationBackupFileName: null, ignoreMetadataErrors: true); + else + File.Move(temp, path, overwrite: false); + + using var committed = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + committed.Flush(flushToDisk: true); + } + + private bool TryReadManifest(string path, out GatewayRollbackPointManifest? manifest) + { + manifest = null; + try + { + manifest = JsonSerializer.Deserialize(File.ReadAllText(path), _jsonOptions); + return manifest is not null && PointIdRegex().IsMatch(manifest.Id); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + return false; + } + } + + private bool TryLoadPoint(string pointId, out GatewayRollbackPointManifest? manifest) + { + manifest = null; + if (string.IsNullOrWhiteSpace(pointId) || !PointIdRegex().IsMatch(pointId)) + return false; + return TryReadManifest(Path.Combine(GetPointDirectory(pointId), ManifestFileName), out manifest); + } + + private GatewayRollbackPointManifest[] LoadOwnedManifests() => + List().Select(info => TryLoadPoint(info.Id, out var manifest) ? manifest : null) + .Where(manifest => manifest is not null && IsManifestOwned(manifest)) + .Cast() + .ToArray(); + + private bool DeletePointFilesOnly(string pointId) + { + var directory = GetPointDirectory(pointId); + if (!Directory.Exists(directory) || + !HasSafePathBoundary(_pointsRoot, directory)) + return false; + var allowed = new HashSet(StringComparer.OrdinalIgnoreCase) + { + NormalizePath(Path.Combine(directory, ManifestFileName)), + NormalizePath(Path.Combine(directory, RollbackVhdFileName)) + }; + var entries = Directory.GetFileSystemEntries(directory).Select(NormalizePath).ToArray(); + if (entries.Any(entry => !allowed.Contains(entry) || IsReparsePoint(entry))) + return false; + foreach (var entry in entries) + File.Delete(entry); + Directory.Delete(directory, recursive: false); + return true; + } + + private bool IsManifestOwned(GatewayRollbackPointManifest manifest) => + manifest.SchemaVersion == 3 && + IsOwnedDistro(manifest.DistroName) && + PointIdRegex().IsMatch(manifest.Id) && + ExactVersionRegex().IsMatch(manifest.OpenClawVersion) && + ExactVersionRegex().IsMatch(manifest.TargetOpenClawVersion) && + Sha256Regex().IsMatch(manifest.InternalIdentitySha256) && + manifest.RegistrationVersion == 2 && + DefaultUserNameRegex().IsMatch(manifest.DefaultUserName) && + (manifest.NodeCommandAllowSnapshotJson is null || + IsCompleteCommandArrayJson(manifest.NodeCommandAllowSnapshotJson)) && + (string.IsNullOrEmpty(manifest.VhdSha256) || Sha256Regex().IsMatch(manifest.VhdSha256)); + + private static bool IsCompleteCommandArrayJson(string value) + { + try + { + using var document = JsonDocument.Parse(value); + return document.RootElement.ValueKind == JsonValueKind.Array && + document.RootElement.EnumerateArray().All(item => + item.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(item.GetString())); + } + catch (JsonException) + { + return false; + } + } + + private bool IsOwnedDistro(string? distroName) => + string.Equals(distroName?.Trim(), _ownedDistroName, StringComparison.Ordinal); + + private string GetPointDirectory(string pointId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pointId); + if (!PointIdRegex().IsMatch(pointId)) + throw new ArgumentException("Invalid rollback point id.", nameof(pointId)); + var path = NormalizePath(Path.Combine(_pointsRoot, pointId)); + if (!string.Equals(Path.GetDirectoryName(path), _pointsRoot, StringComparison.OrdinalIgnoreCase)) + throw new IOException("Rollback point escaped its owned root."); + return path; + } + + private string GetRollbackVhdPath(string pointId) => Path.Combine(GetPointDirectory(pointId), RollbackVhdFileName); + private string GetStageVhdPath(string pointId) => NormalizePath(Path.Combine(_stagingRoot, $"{pointId}.vhdx")); + private string GetInstallDirectory() => NormalizePath(Path.Combine(_localDataRoot, "wsl", _ownedDistroName)); + + private static GatewayRollbackPointInfo ToInfo(GatewayRollbackPointManifest manifest) => new( + manifest.Id, + manifest.DistroName, + manifest.OpenClawVersion, + manifest.CreatedAtUtc, + manifest.VerificationStatus, + manifest.Phase, + manifest.VhdSizeBytes, + manifest.RestoreEligible && manifest.VerificationStatus == GatewayRollbackPointVerificationStatus.Verified, + manifest.LastFailureCode); + + private static void EnsurePrivateDirectory(string path) + { + Directory.CreateDirectory(path); + McpAuthToken.TryRestrictDataDirectoryAcl(path); + } + + private static void TryDeleteGeneratedFile(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + private static bool DeleteGeneratedFile(string path, string ownedRoot) + { + if (!File.Exists(path)) + return true; + if (!HasSafePathBoundary(ownedRoot, path) || IsReparsePoint(path)) + return false; + try + { + File.Delete(path); + return !File.Exists(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static bool HasSafePathBoundary(string root, string target) + { + var normalizedRoot = NormalizePath(root); + var normalizedTarget = NormalizePath(target); + if (!string.Equals(normalizedTarget, normalizedRoot, StringComparison.OrdinalIgnoreCase) && + !normalizedTarget.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var current = normalizedRoot; + if (PathExists(current) && IsReparsePoint(current)) + return false; + + var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget); + foreach (var segment in relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if (PathExists(current) && IsReparsePoint(current)) + return false; + } + return true; + } + + private static bool PathExists(string path) => File.Exists(path) || Directory.Exists(path); + + private static bool IsReparsePoint(string path) => + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + + private static string NormalizePath(string path) + { + var fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + fullPath = @"\\" + fullPath[8..]; + else if (fullPath.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + fullPath = fullPath[4..]; + return Path.TrimEndingDirectorySeparator(fullPath); + } + + private sealed record VhdVerification(bool Verified, string? Sha256, long SizeBytes, string? FailureCode); + private sealed record DefaultUserIdentity(string Name, uint Uid); + + [GeneratedRegex(@"\A[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?\z", RegexOptions.CultureInvariant)] + private static partial Regex DistroNameRegex(); + + [GeneratedRegex(@"\A\d{8}T\d{9}Z-[0-9a-f]{32}\z", RegexOptions.CultureInvariant)] + private static partial Regex PointIdRegex(); + + [GeneratedRegex(@"\A[0-9a-fA-F]{32}\z", RegexOptions.CultureInvariant)] + private static partial Regex MachineIdRegex(); + + [GeneratedRegex(@"\A[0-9a-f]{64}\z", RegexOptions.CultureInvariant)] + private static partial Regex Sha256Regex(); + + [GeneratedRegex(@"\A[a-z_][a-z0-9_-]{0,31}\z", RegexOptions.CultureInvariant)] + private static partial Regex DefaultUserNameRegex(); + + [GeneratedRegex(@"\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?\z", RegexOptions.CultureInvariant)] + private static partial Regex ExactVersionRegex(); + + [GeneratedRegex( + @"\A\s*(?:OpenClaw\s+)?(?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)(?:\s+\([^\r\n()]+\))?\s*\z", + RegexOptions.CultureInvariant)] + private static partial Regex VersionOutputRegex(); +} diff --git a/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs b/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs new file mode 100644 index 000000000..7f8623835 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayVersionAlignmentCoordinator.cs @@ -0,0 +1,999 @@ +using System.Numerics; +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +public enum GatewayVersionAlignmentState +{ + Ineligible, + Busy, + ProbeFailed, + Aligned, + Mismatch, + NewerThanRequired, + VersionOrderUnknown, + PreUpdateHealthFailed, + RollbackPointFailed, + UpdateFailed, + VerificationFailed, + SynchronizationFailed, + RecoveryAvailable, + RestoreConfirmationRequired, + RestoreFailed, + RestoreVerificationFailed, + RestoreCancelled, + Restored, + Updated +} + +public sealed record GatewayVersionAlignmentResult( + GatewayVersionAlignmentState State, + string RequiredVersion, + string? InstalledVersion = null, + string? PreviousVersion = null, + string? RollbackPointId = null, + int? ExitCode = null, + string? FailureSummary = null) +{ + public bool IsAligned => State is GatewayVersionAlignmentState.Aligned or GatewayVersionAlignmentState.Updated; +} + +/// +/// Aligns OpenClaw inside an existing Companion-owned WSL distro. Normal update +/// exports an offline rollback point and runs only the native pinned updater in +/// the existing distro. WSL unregister/import is isolated to RestoreAsync and +/// requires an explicit rollback-point confirmation. +/// +public sealed partial class GatewayVersionAlignmentCoordinator +{ + private readonly IWslCommandRunner _commandRunner; + private readonly GatewayRollbackPointManager _rollbackPoints; + private readonly string _requiredVersion; + private readonly Func _synchronizeAsync; + private readonly Func _retentionPolicy; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public GatewayVersionAlignmentCoordinator( + IWslCommandRunner commandRunner, + string requiredVersion, + GatewayRollbackPointManager rollbackPoints, + Func? synchronizeAsync = null, + Func? retentionPolicy = null) + { + ArgumentNullException.ThrowIfNull(commandRunner); + ArgumentNullException.ThrowIfNull(rollbackPoints); + + var normalizedVersion = requiredVersion?.Trim(); + if (normalizedVersion is null || !ExactVersionRegex().IsMatch(normalizedVersion)) + { + throw new ArgumentException( + "Required gateway version must be a strict exact semantic version without a leading 'v'.", + nameof(requiredVersion)); + } + + _commandRunner = commandRunner; + _rollbackPoints = rollbackPoints; + _requiredVersion = normalizedVersion; + _synchronizeAsync = synchronizeAsync ?? ((_, _) => Task.CompletedTask); + _retentionPolicy = retentionPolicy ?? (() => GatewayRollbackRetentionPolicy.Default); + } + + public string RequiredVersion => _requiredVersion; + + public IReadOnlyList ListRollbackPoints() => _rollbackPoints.List(); + + public bool HasVerifiedPendingUpdate(string gatewayId) => + !string.IsNullOrWhiteSpace(gatewayId) && + _rollbackPoints.FindPendingUpdates().Count > 0; + + public async Task ProbeAsync( + GatewayHostAccessPlan accessPlan, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not a proven Companion-owned WSL gateway."); + } + var restoreGate = GetUnresolvedRestoreGate(); + if (restoreGate is not null) + return restoreGate; + var pendingUpdateGate = GetPendingUpdateProbeGate(accessPlan.GatewayId!); + if (pendingUpdateGate is not null) + return pendingUpdateGate; + return await ProbeCoreAsync(distroName, cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task UpdateAsync( + GatewayHostAccessPlan accessPlan, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var gatewayId = accessPlan.GatewayId!; + var restoreGate = GetUnresolvedRestoreGate(); + if (restoreGate is not null) + return restoreGate; + + var pendingUpdates = _rollbackPoints.FindPendingUpdates(); + if (pendingUpdates.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple verified pending Gateway update receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe or update was attempted."); + } + + var before = await ProbeCoreAsync(distroName, cancellationToken).ConfigureAwait(false); + var pending = pendingUpdates.SingleOrDefault(); + if (pending is not null && + !string.Equals(pending.GatewayId, gatewayId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + "A verified pending update belongs to an earlier Gateway record for this Companion-owned distro. Explicit recovery is required before another update."); + } + if (pending is not null && + !string.Equals(pending.TargetOpenClawVersion, _requiredVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + $"A verified pending update targets OpenClaw {pending.TargetOpenClawVersion}; explicit recovery is required before aligning to {_requiredVersion}."); + } + if (before.State == GatewayVersionAlignmentState.Aligned && pending is not null) + { + if (!await _rollbackPoints.VerifyAsync(pending.Id, cancellationToken).ConfigureAwait(false) || + !await _rollbackPoints.AttestLiveDistroAsync( + pending.Id, distroName, _requiredVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + pending.OpenClawVersion, + pending.Id, + before.ExitCode, + "The aligned Companion-owned distro no longer matches its pending rollback receipt, so finalization was blocked."); + } + return await FinalizePostUpdateAsync( + distroName, gatewayId, before.InstalledVersion!, pending.OpenClawVersion, pending.Id, + pending.NodeCommandAllowSnapshotJson, before.ExitCode, cancellationToken) + .ConfigureAwait(false); + } + if (before.State != GatewayVersionAlignmentState.Mismatch || before.InstalledVersion is null) + return before; + + var previousVersion = pending?.OpenClawVersion ?? before.InstalledVersion; + + GatewayRollbackPointManifest rollbackPoint; + if (pending is not null) + { + if (!await _rollbackPoints.VerifyAsync(pending.Id, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pending.Id, + failureSummary: "The pending update's integral rollback point no longer verifies, so retry was blocked."); + } + if (!await _rollbackPoints.AttestLiveDistroAsync( + pending.Id, distroName, pending.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pending.Id, + failureSummary: "The live Companion-owned distro no longer matches the pending rollback receipt, so retry was blocked."); + } + rollbackPoint = pending; + } + else + { + try + { + await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.PreUpdateHealthFailed, + previousVersion, + previousVersion, + failureSummary: $"Pre-update Gateway, Companion, Node, or pairing health failed: {ex.GetType().Name}."); + } + + var policySnapshot = await CaptureNodeCommandPolicyAsync( + distroName, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + if (policySnapshot.Failure is not null) + { + return Result( + GatewayVersionAlignmentState.PreUpdateHealthFailed, + previousVersion, + previousVersion, + failureSummary: policySnapshot.Failure); + } + + GatewayRollbackOperationResult rollback; + try + { + rollback = await _rollbackPoints.CreateVerifiedAsync( + distroName, gatewayId, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await TryRestoreExistingRuntimeAvailabilityAsync( + distroName, gatewayId, CancellationToken.None).ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + await TryRestoreExistingRuntimeAvailabilityAsync( + distroName, gatewayId, CancellationToken.None).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RollbackPointFailed, + previousVersion, + previousVersion, + failureSummary: $"Rollback point creation stopped before update: {ex.GetType().Name}."); + } + if (!rollback.Success || rollback.Point is null || + !await _rollbackPoints.VerifyAsync(rollback.Point.Id, cancellationToken).ConfigureAwait(false)) + { + await TryRestoreExistingRuntimeAvailabilityAsync(distroName, gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RollbackPointFailed, + previousVersion, + previousVersion, + rollback.Point?.Id, + rollback.ExitCode, + "A verified integral rollback point could not be created, so no update was attempted."); + } + rollbackPoint = rollback.Point; + if (policySnapshot.NormalizedArrayJson is not null) + { + rollbackPoint = _rollbackPoints.RecordNodeCommandAllowSnapshot( + rollbackPoint.Id, policySnapshot.NormalizedArrayJson); + } + } + + var pointId = rollbackPoint.Id; + if (pending is null) + _rollbackPoints.MarkUpdateInProgress(pointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, before.InstalledVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pointId, + failureSummary: "The live Companion-owned distro changed before package mutation, so the update was blocked."); + } + if (rollbackPoint.NodeCommandAllowSnapshotJson is { } expectedPolicy) + { + var currentPolicy = await CaptureNodeCommandPolicyAsync( + distroName, previousVersion, _requiredVersion, cancellationToken).ConfigureAwait(false); + if (currentPolicy.Failure is not null || + !string.Equals(currentPolicy.NormalizedArrayJson, expectedPolicy, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + before.InstalledVersion, + previousVersion, + pointId, + failureSummary: "The complete Gateway node command allowlist changed before package mutation. The update receipt was preserved and no updater command was invoked."); + } + } + var update = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildUpdate(_requiredVersion), + cancellationToken).ConfigureAwait(false); + if (!update.Success || !IsStructuredUpdateSuccess(update.StandardOutput, _requiredVersion)) + { + var current = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + current.Version, + previousVersion, + pointId, + update.ExitCode, + update.Success + ? "The updater did not return a trusted exact-version result. The verified rollback point is available for explicit recovery." + : $"The update failed with exit code {update.ExitCode}. The verified rollback point is available for explicit recovery."); + } + + var after = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (after.Failure is not null || !string.Equals(after.Version, _requiredVersion, StringComparison.Ordinal)) + { + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + after.Version, + previousVersion, + pointId, + after.ExitCode, + "The installed version could not be verified exactly after update. The verified rollback point is available for explicit recovery."); + } + + return await FinalizePostUpdateAsync( + distroName, gatewayId, after.Version!, previousVersion, pointId, + rollbackPoint.NodeCommandAllowSnapshotJson, after.ExitCode, cancellationToken).ConfigureAwait(false); + } + finally + { + _operationGate.Release(); + } + } + + public async Task RestoreAsync( + GatewayHostAccessPlan accessPlan, + string rollbackPointId, + string confirmedRollbackPointId, + CancellationToken cancellationToken = default) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result(GatewayVersionAlignmentState.Ineligible, failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var point = _rollbackPoints.List().SingleOrDefault(item => string.Equals(item.Id, rollbackPointId, StringComparison.Ordinal)); + if (point is null) + return Result(GatewayVersionAlignmentState.RestoreFailed, failureSummary: "The selected rollback point no longer exists."); + + var restore = await _rollbackPoints.RestoreExplicitAsync( + distroName, + accessPlan.GatewayId!, + rollbackPointId, + confirmedRollbackPointId, + cancellationToken).ConfigureAwait(false); + if (restore.State == GatewayRollbackOperationState.ConfirmationRequired) + return Result(GatewayVersionAlignmentState.RestoreConfirmationRequired, rollbackPointId: rollbackPointId); + if (!restore.Success || restore.Point is null) + { + var requiredPointId = restore.Point?.Id ?? rollbackPointId; + return Result( + GatewayVersionAlignmentState.RestoreFailed, + previousVersion: point.OpenClawVersion, + rollbackPointId: requiredPointId, + exitCode: restore.ExitCode, + failureSummary: restore.State switch + { + GatewayRollbackOperationState.ImportPending => + "The old registration was removed but import did not complete. The verified rollback point and durable recovery receipt were preserved for retry.", + GatewayRollbackOperationState.ResumeRequired => + $"Recovery must resume exact rollback point {requiredPointId}; the selected point was not mutated.", + GatewayRollbackOperationState.AmbiguousRecovery => + "Multiple mandatory recovery receipts exist. Restore is ambiguous and no lifecycle mutation was attempted.", + _ => $"Emergency restore stopped safely: {restore.FailureCode ?? restore.State.ToString()}." + }); + } + + var restored = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (restored.Failure is not null || !string.Equals(restored.Version, restore.Point.OpenClawVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The restored distro registration exists, but its exact OpenClaw version could not be verified."); + } + + try + { + await _synchronizeAsync(accessPlan.GatewayId!, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + failureSummary: $"The full state was restored, but Gateway, Companion, Node, or pairing health failed: {ex.GetType().Name}."); + } + + if (!await VerifyRestoredNodeCommandPolicyAsync( + distroName, restore.Point, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The restored distro version is exact, but its complete Gateway node command policy does not match the rollback receipt."); + } + + if (!await _rollbackPoints.VerifyAsync(rollbackPointId, cancellationToken).ConfigureAwait(false) || + !await _rollbackPoints.AttestLiveDistroAsync( + rollbackPointId, distroName, restore.Point.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "Restore health passed, but the live distro no longer matches the exact rollback receipt. Restore finalization was blocked."); + } + + _rollbackPoints.MarkRestoreHealthy(rollbackPointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + rollbackPointId, distroName, restore.Point.OpenClawVersion, cancellationToken).ConfigureAwait(false)) + { + _rollbackPoints.MarkImported(rollbackPointId); + return Result( + GatewayVersionAlignmentState.RestoreVerificationFailed, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode, + "The live distro changed immediately before retention cleanup. The imported recovery receipt was preserved and cleanup was blocked."); + } + await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false); + return Result( + GatewayVersionAlignmentState.Restored, + restored.Version, + restore.Point.OpenClawVersion, + rollbackPointId, + restored.ExitCode); + } + finally + { + _operationGate.Release(); + } + } + + public GatewayVersionAlignmentResult CancelRestore( + GatewayHostAccessPlan accessPlan, + string rollbackPointId, + string confirmedRollbackPointId) + { + if (!_operationGate.Wait(0)) + return Result(GatewayVersionAlignmentState.Busy); + + try + { + if (!string.Equals(rollbackPointId, confirmedRollbackPointId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RestoreConfirmationRequired, + rollbackPointId: rollbackPointId); + } + if (!TryGetEligibleDistro(accessPlan, out var distroName) || + !string.Equals(distroName, _rollbackPoints.OwnedDistroName, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.Ineligible, + failureSummary: "Gateway is not the expected Companion-owned WSL distro."); + } + + var cancelled = _rollbackPoints.CancelRestore( + distroName, accessPlan.GatewayId!, rollbackPointId); + return cancelled.State == GatewayRollbackOperationState.Cancelled + ? Result( + GatewayVersionAlignmentState.RestoreCancelled, + previousVersion: cancelled.Point?.OpenClawVersion, + rollbackPointId: rollbackPointId) + : Result( + GatewayVersionAlignmentState.RestoreFailed, + previousVersion: cancelled.Point?.OpenClawVersion, + rollbackPointId: rollbackPointId, + failureSummary: $"Staged restore cancellation stopped safely: {cancelled.FailureCode ?? cancelled.State.ToString()}."); + } + finally + { + _operationGate.Release(); + } + } + + private async Task FinalizePostUpdateAsync( + string distroName, + string gatewayId, + string installedVersion, + string previousVersion, + string pointId, + string? nodeCommandAllowSnapshotJson, + int? exitCode, + CancellationToken cancellationToken) + { + var policyMigration = await ApplyNodeCommandPolicyMigrationAsync( + distroName, + previousVersion, + installedVersion, + nodeCommandAllowSnapshotJson, + cancellationToken).ConfigureAwait(false); + if (policyMigration is not null) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + installedVersion, + previousVersion, + pointId, + exitCode, + policyMigration); + } + + try + { + await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + installedVersion, + previousVersion, + pointId, + exitCode, + $"Post-update synchronization failed: {ex.GetType().Name}. The verified rollback point is available for explicit recovery."); + } + + if (!await _rollbackPoints.VerifyAsync(pointId, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "Post-update health passed, but the rollback point no longer verifies. Retention cleanup was not run."); + } + + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false)) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "Post-update health passed, but the live distro no longer matches the exact expected version and rollback receipt. Finalization and cleanup were blocked."); + } + + _rollbackPoints.MarkPostUpdateHealthy(pointId); + if (!await _rollbackPoints.AttestLiveDistroAsync( + pointId, distroName, installedVersion, cancellationToken).ConfigureAwait(false)) + { + _rollbackPoints.MarkUpdateInProgress(pointId); + return Result( + GatewayVersionAlignmentState.VerificationFailed, + installedVersion, + previousVersion, + pointId, + exitCode, + "The live distro changed immediately before retention cleanup. The update receipt was preserved and cleanup was blocked."); + } + await _rollbackPoints.CleanupAsync(_retentionPolicy(), cancellationToken).ConfigureAwait(false); + return Result(GatewayVersionAlignmentState.Updated, installedVersion, previousVersion, pointId, exitCode); + } + + private async Task CaptureNodeCommandPolicyAsync( + string distroName, + string sourceVersion, + string targetVersion, + CancellationToken cancellationToken) + { + if (string.Equals( + GatewayNodeCommandPolicyConfig.ResolveAllowKey(sourceVersion), + GatewayNodeCommandPolicyConfig.ResolveAllowKey(targetVersion), + StringComparison.Ordinal)) + { + return new(null, null); + } + + var result = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(sourceVersion), + cancellationToken).ConfigureAwait(false); + if (!result.Success || !TryNormalizeCompleteCommandArray(result.StandardOutput, out var normalized)) + { + return new( + null, + "The complete Gateway node command allowlist could not be captured before update, so no package mutation was attempted."); + } + + return new(normalized, null); + } + + private async Task ApplyNodeCommandPolicyMigrationAsync( + string distroName, + string sourceVersion, + string targetVersion, + string? normalizedArrayJson, + CancellationToken cancellationToken) + { + var sourceKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(sourceVersion); + var targetKey = GatewayNodeCommandPolicyConfig.ResolveAllowKey(targetVersion); + if (string.Equals(sourceKey, targetKey, StringComparison.Ordinal)) + return null; + if (!TryNormalizeCompleteCommandArray(normalizedArrayJson, out var expected)) + return "The update receipt does not contain a valid complete Gateway node command allowlist, so policy migration and finalization were blocked."; + + var set = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildSetNodeCommandAllow(targetVersion, expected), + cancellationToken).ConfigureAwait(false); + if (!set.Success) + return "The complete Gateway node command policy could not be written to the target-version path. The update receipt was preserved for retry or rollback."; + + var verify = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(targetVersion), + cancellationToken).ConfigureAwait(false); + if (!verify.Success || + !TryNormalizeCompleteCommandArray(verify.StandardOutput, out var observed) || + !string.Equals(observed, expected, StringComparison.Ordinal)) + { + return "The migrated Gateway node command policy did not preserve the complete array exactly. The source policy remained in place, the update receipt was preserved, and finalization was blocked."; + } + + var unset = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildUnsetNodeCommandAllow(sourceVersion), + cancellationToken).ConfigureAwait(false); + return unset.Success + ? null + : "The target Gateway node command policy is verified, but the legacy path could not be removed. Both policy copies remain available and the update receipt was preserved for retry."; + } + + private async Task VerifyRestoredNodeCommandPolicyAsync( + string distroName, + GatewayRollbackPointManifest point, + CancellationToken cancellationToken) + { + if (point.NodeCommandAllowSnapshotJson is null) + return true; + + var result = await _commandRunner.RunInDistroAsync( + distroName, + GatewayVersionAlignmentCommandBuilder.BuildGetNodeCommandAllow(point.OpenClawVersion), + cancellationToken).ConfigureAwait(false); + return result.Success && + TryNormalizeCompleteCommandArray(point.NodeCommandAllowSnapshotJson, out var expected) && + TryNormalizeCompleteCommandArray(result.StandardOutput, out var observed) && + string.Equals(observed, expected, StringComparison.Ordinal); + } + + private static bool TryNormalizeCompleteCommandArray(string? value, out string normalized) + { + normalized = string.Empty; + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + using var document = JsonDocument.Parse(value); + if (document.RootElement.ValueKind != JsonValueKind.Array) + return false; + var commands = document.RootElement.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : null) + .ToArray(); + if (commands.Any(string.IsNullOrWhiteSpace)) + return false; + normalized = JsonSerializer.Serialize(commands); + return true; + } + catch (JsonException) + { + return false; + } + } + + private async Task TryRestoreExistingRuntimeAvailabilityAsync( + string distroName, + string gatewayId, + CancellationToken cancellationToken) + { + await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + await TrySynchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); + } + + private async Task TrySynchronizeAsync(string gatewayId, CancellationToken cancellationToken) + { + try { await _synchronizeAsync(gatewayId, cancellationToken).ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) { } + } + + private GatewayVersionAlignmentResult? GetUnresolvedRestoreGate() + { + if (_rollbackPoints.HasUnreadableReceipt()) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "A Gateway rollback receipt directory cannot be read or validated. Recovery is ambiguous and no package probe or lifecycle mutation was attempted."); + } + + var unresolvedRestores = _rollbackPoints.FindUnresolvedRestores(); + if (unresolvedRestores.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple unresolved Gateway restore receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe or update was attempted."); + } + if (unresolvedRestores.Count == 0) + return null; + + var unresolved = unresolvedRestores[0]; + var action = unresolved.Phase == GatewayRollbackPointPhase.RestoreStaged + ? "Resume or durably cancel this pre-destructive restore before updating." + : "Resume this exact restore point before updating."; + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: unresolved.OpenClawVersion, + rollbackPointId: unresolved.Id, + failureSummary: $"An unresolved Gateway restore is in phase {unresolved.Phase}. {action}"); + } + + private GatewayVersionAlignmentResult? GetPendingUpdateProbeGate(string gatewayId) + { + var pendingUpdates = _rollbackPoints.FindPendingUpdates(); + if (pendingUpdates.Count > 1) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + failureSummary: "Multiple pending Gateway update receipts exist for the Companion-owned distro. Recovery is ambiguous and no package probe was attempted."); + } + if (pendingUpdates.Count == 0) + return null; + + var pending = pendingUpdates[0]; + if (pending.VerificationStatus != GatewayRollbackPointVerificationStatus.Verified || + !pending.RestoreEligible) + { + return Result( + GatewayVersionAlignmentState.VerificationFailed, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: "The mandatory pending Gateway update receipt no longer has an eligible verified rollback point. Recovery must be resolved before probing ordinary alignment."); + } + if (!string.Equals(pending.GatewayId, gatewayId, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: "A mandatory pending update belongs to an earlier Gateway record for this Companion-owned distro. Resume or explicitly restore that exact point before ordinary alignment."); + } + if (!string.Equals(pending.TargetOpenClawVersion, _requiredVersion, StringComparison.Ordinal)) + { + return Result( + GatewayVersionAlignmentState.RecoveryAvailable, + previousVersion: pending.OpenClawVersion, + rollbackPointId: pending.Id, + failureSummary: $"A mandatory pending update targets OpenClaw {pending.TargetOpenClawVersion}. Resolve exact point {pending.Id} before aligning to {_requiredVersion}."); + } + return null; + } + + private async Task ProbeCoreAsync(string distroName, CancellationToken cancellationToken) + { + var probe = await ProbeInstalledVersionAsync(distroName, cancellationToken).ConfigureAwait(false); + if (probe.Failure is not null) + return Result(GatewayVersionAlignmentState.ProbeFailed, exitCode: probe.ExitCode, failureSummary: probe.Failure); + + if (string.Equals(probe.Version, _requiredVersion, StringComparison.Ordinal)) + return Result(GatewayVersionAlignmentState.Aligned, probe.Version, exitCode: probe.ExitCode); + + var comparison = CompareSemanticVersions(probe.Version!, _requiredVersion); + return comparison switch + { + > 0 => Result(GatewayVersionAlignmentState.NewerThanRequired, probe.Version, exitCode: probe.ExitCode), + < 0 => Result(GatewayVersionAlignmentState.Mismatch, probe.Version, exitCode: probe.ExitCode), + _ => Result( + GatewayVersionAlignmentState.VersionOrderUnknown, + probe.Version, + exitCode: probe.ExitCode, + failureSummary: "Installed and required OpenClaw versions differ, but their build metadata cannot be safely ordered. No update was attempted.") + }; + } + + private async Task ProbeInstalledVersionAsync(string distroName, CancellationToken cancellationToken) + { + var result = await _commandRunner.RunInDistroAsync( + distroName, GatewayVersionAlignmentCommandBuilder.BuildProbe(), cancellationToken).ConfigureAwait(false); + if (!result.Success) + return new(null, result.ExitCode, $"OpenClaw version probe failed with exit code {result.ExitCode}."); + + var match = InstalledVersionRegex().Match(result.StandardOutput ?? string.Empty); + return match.Success + ? new(match.Groups["version"].Value, result.ExitCode, null) + : new(null, result.ExitCode, "OpenClaw version probe returned an unrecognized version."); + } + + private GatewayVersionAlignmentResult Result( + GatewayVersionAlignmentState state, + string? installedVersion = null, + string? previousVersion = null, + string? rollbackPointId = null, + int? exitCode = null, + string? failureSummary = null) => + new(state, _requiredVersion, installedVersion, previousVersion, rollbackPointId, exitCode, failureSummary); + + private static bool TryGetEligibleDistro(GatewayHostAccessPlan? accessPlan, out string distroName) + { + distroName = accessPlan?.DistroName?.Trim() ?? string.Empty; + return accessPlan is not null && + !string.IsNullOrWhiteSpace(accessPlan.GatewayId) && + accessPlan.TerminalTarget == GatewayTerminalTarget.Wsl && + accessPlan.CanControlWslGateway && + distroName.Length > 0; + } + + private static bool IsStructuredUpdateSuccess(string output, string requiredVersion) + { + try + { + using var document = JsonDocument.Parse(output); + var root = document.RootElement; + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.String && + string.Equals(status.GetString(), "ok", StringComparison.Ordinal) && + root.TryGetProperty("after", out var after) && after.ValueKind == JsonValueKind.Object && + after.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.String && + string.Equals(version.GetString(), requiredVersion, StringComparison.Ordinal); + } + catch (JsonException) + { + return false; + } + } + + private static int? CompareSemanticVersions(string left, string right) + { + var leftParts = ParseSemanticVersion(left); + var rightParts = ParseSemanticVersion(right); + var core = leftParts.Core.Zip(rightParts.Core, (a, b) => a.CompareTo(b)).FirstOrDefault(value => value != 0); + if (core != 0) + return core; + if (leftParts.PreRelease.Count == 0 && rightParts.PreRelease.Count > 0) + return 1; + if (leftParts.PreRelease.Count > 0 && rightParts.PreRelease.Count == 0) + return -1; + + for (var i = 0; i < Math.Min(leftParts.PreRelease.Count, rightParts.PreRelease.Count); i++) + { + var leftNumeric = IsDecimalIdentifier(leftParts.PreRelease[i]); + var rightNumeric = IsDecimalIdentifier(rightParts.PreRelease[i]); + var comparison = leftNumeric && rightNumeric + ? BigInteger.Parse(leftParts.PreRelease[i]).CompareTo(BigInteger.Parse(rightParts.PreRelease[i])) + : leftNumeric ? -1 + : rightNumeric ? 1 + : string.Compare(leftParts.PreRelease[i], rightParts.PreRelease[i], StringComparison.Ordinal); + if (comparison != 0) + return comparison; + } + var preReleaseCount = leftParts.PreRelease.Count.CompareTo(rightParts.PreRelease.Count); + if (preReleaseCount != 0) + return preReleaseCount; + + if (leftParts.BuildMetadata.SequenceEqual(rightParts.BuildMetadata, StringComparer.Ordinal)) + return 0; + if (leftParts.BuildMetadata.Count == 0 || + rightParts.BuildMetadata.Count == 0 || + leftParts.BuildMetadata.Count != rightParts.BuildMetadata.Count) + { + return null; + } + + int? orderedComparison = null; + for (var i = 0; i < leftParts.BuildMetadata.Count; i++) + { + if (string.Equals(leftParts.BuildMetadata[i], rightParts.BuildMetadata[i], StringComparison.Ordinal)) + continue; + if (!IsDecimalIdentifier(leftParts.BuildMetadata[i]) || + !IsDecimalIdentifier(rightParts.BuildMetadata[i])) + { + return null; + } + var comparison = BigInteger.Parse(leftParts.BuildMetadata[i]) + .CompareTo(BigInteger.Parse(rightParts.BuildMetadata[i])); + if (comparison != 0) + orderedComparison ??= comparison; + } + return orderedComparison; + } + + private static bool IsDecimalIdentifier(string value) => + value.Length > 0 && value.All(character => character is >= '0' and <= '9'); + + private static SemanticVersionParts ParseSemanticVersion(string version) + { + var versionAndBuild = version.Split('+', 2); + var coreAndPreRelease = versionAndBuild[0].Split('-', 2); + return new( + coreAndPreRelease[0].Split('.').Select(BigInteger.Parse).ToArray(), + coreAndPreRelease.Length == 2 ? coreAndPreRelease[1].Split('.').ToArray() : [], + versionAndBuild.Length == 2 ? versionAndBuild[1].Split('.').ToArray() : []); + } + + private sealed record InstalledVersionProbe(string? Version, int ExitCode, string? Failure); + private sealed record NodeCommandPolicySnapshot(string? NormalizedArrayJson, string? Failure); + private sealed record SemanticVersionParts( + IReadOnlyList Core, + IReadOnlyList PreRelease, + IReadOnlyList BuildMetadata); + + private const string ExactVersionPattern = + @"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + + @"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + + @"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"; + + [GeneratedRegex(@"\A" + ExactVersionPattern + @"\z", RegexOptions.CultureInvariant)] + private static partial Regex ExactVersionRegex(); + + [GeneratedRegex( + @"\A\s*(?:OpenClaw\s+)?(?" + ExactVersionPattern + @")(?:\s+\([^\r\n()]+\))?\s*\z", + RegexOptions.CultureInvariant)] + private static partial Regex InstalledVersionRegex(); +} + +internal static class GatewayVersionAlignmentCommandBuilder +{ + public static IReadOnlyList BuildProbe() => + ["bash", "-lc", $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw --version"]; + + public static IReadOnlyList BuildUpdate(string requiredVersion) => + [ + "bash", + "-lc", + $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw update --tag {requiredVersion} --yes --json" + ]; + + public static IReadOnlyList BuildGetNodeCommandAllow(string gatewayVersion) => + BuildConfigCommand($"get {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)} --json"); + + public static IReadOnlyList BuildUnsetNodeCommandAllow(string gatewayVersion) => + BuildConfigCommand($"unset {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)}"); + + public static IReadOnlyList BuildSetNodeCommandAllow(string gatewayVersion, string completeArrayJson) => + BuildConfigCommand( + $"set {GatewayNodeCommandPolicyConfig.ResolveAllowKey(gatewayVersion)} {QuotePosix(completeArrayJson)}"); + + private static IReadOnlyList BuildConfigCommand(string operation) => + [ + "bash", + "-lc", + $"{WslGatewayControlCommandBuilder.OpenClawWslPathPrefix} && openclaw config {operation}" + ]; + + private static string QuotePosix(string value) => $"'{value.Replace("'", "'\\''", StringComparison.Ordinal)}'"; +} diff --git a/src/OpenClaw.Connection/WslCommandRunner.cs b/src/OpenClaw.Connection/WslCommandRunner.cs index 8dfd3ef50..607fc7f42 100644 --- a/src/OpenClaw.Connection/WslCommandRunner.cs +++ b/src/OpenClaw.Connection/WslCommandRunner.cs @@ -1,6 +1,8 @@ using OpenClaw.Shared; +using Microsoft.Win32; using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; namespace OpenClaw.Connection; @@ -11,6 +13,12 @@ public sealed record WslCommandResult(int ExitCode, string StandardOutput, strin } public sealed record WslDistroInfo(string Name, string State, int Version); +public sealed record WslDistroRegistration(string Name, string BasePath); +public sealed record WslDistroConfiguration(uint Version, uint DefaultUid, uint Flags); +public sealed record WslDistroConfigurationResult( + bool Success, + WslDistroConfiguration? Configuration, + int HResult = 0); public interface IWslCommandRunner { @@ -21,6 +29,21 @@ Task RunAsync( Task> ListDistrosAsync(CancellationToken cancellationToken = default); + Task> ListRegistrationsAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + Task GetDistroConfigurationAsync( + string name, + CancellationToken cancellationToken = default) => + Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + + Task ConfigureDistroRegistrationAsync( + string name, + uint defaultUid, + uint flags, + CancellationToken cancellationToken = default) => + Task.FromResult(new WslCommandResult(-1, string.Empty, "WSL registration configuration is unavailable.")); + Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default); Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default); @@ -37,6 +60,7 @@ Task RunInDistroAsync( /// public sealed class WslExeCommandRunner : IWslCommandRunner { + private const string LxssRegistryPath = @"Software\Microsoft\Windows\CurrentVersion\Lxss"; private readonly IOpenClawLogger _logger; private readonly TimeSpan _defaultTimeout; @@ -54,6 +78,97 @@ public async Task> ListDistrosAsync(CancellationTok return result.Success ? ParseDistroList(result.StandardOutput) : []; } + public Task> ListRegistrationsAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var registrations = new List(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult>(registrations); + using var root = Registry.CurrentUser.OpenSubKey(LxssRegistryPath, writable: false); + if (root is null) + return Task.FromResult>(registrations); + + foreach (var subKeyName in root.GetSubKeyNames()) + { + cancellationToken.ThrowIfCancellationRequested(); + using var distro = root.OpenSubKey(subKeyName, writable: false); + if (distro?.GetValue("DistributionName") is not string name || + distro.GetValue("BasePath") is not string basePath || + string.IsNullOrWhiteSpace(name) || + string.IsNullOrWhiteSpace(basePath)) + { + continue; + } + + registrations.Add(new(name.Trim(), Environment.ExpandEnvironmentVariables(basePath.Trim()))); + } + + return Task.FromResult>(registrations); + } + + public Task GetDistroConfigurationAsync( + string name, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + + IntPtr environmentVariables = IntPtr.Zero; + uint environmentVariableCount = 0; + try + { + var hresult = WslGetDistributionConfiguration( + name, + out var version, + out var defaultUid, + out var flags, + out environmentVariables, + out environmentVariableCount); + return Task.FromResult(hresult >= 0 + ? new WslDistroConfigurationResult(true, new(version, defaultUid, flags), hresult) + : new WslDistroConfigurationResult(false, null, hresult)); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return Task.FromResult(new WslDistroConfigurationResult(false, null, -1)); + } + finally + { + for (uint index = 0; environmentVariables != IntPtr.Zero && index < environmentVariableCount; index++) + { + var value = Marshal.ReadIntPtr(environmentVariables, checked((int)(index * IntPtr.Size))); + if (value != IntPtr.Zero) + Marshal.FreeCoTaskMem(value); + } + if (environmentVariables != IntPtr.Zero) + Marshal.FreeCoTaskMem(environmentVariables); + } + } + + public Task ConfigureDistroRegistrationAsync( + string name, + uint defaultUid, + uint flags, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!OperatingSystem.IsWindows()) + return Task.FromResult(new WslCommandResult(-1, string.Empty, "WSL registration configuration requires Windows.")); + + try + { + var hresult = WslConfigureDistribution(name, defaultUid, flags); + return Task.FromResult(hresult >= 0 + ? new WslCommandResult(0, string.Empty, string.Empty) + : new WslCommandResult(hresult, string.Empty, "WslConfigureDistribution failed.")); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return Task.FromResult(new WslCommandResult(-1, string.Empty, "WslConfigureDistribution is unavailable.")); + } + } + public Task RunAsync( IReadOnlyList arguments, CancellationToken cancellationToken = default, @@ -174,4 +289,19 @@ private async Task RunProcessAsync( ? new WslCommandResult(-1, stdout, "wsl.exe timed out") : new WslCommandResult(process.ExitCode, stdout, stderr); } + + [DllImport("api-ms-win-wsl-api-l1-1-0.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int WslGetDistributionConfiguration( + string distributionName, + out uint distributionVersion, + out uint defaultUid, + out uint wslDistributionFlags, + out IntPtr defaultEnvironmentVariables, + out uint defaultEnvironmentVariableCount); + + [DllImport("api-ms-win-wsl-api-l1-1-0.dll", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int WslConfigureDistribution( + string distributionName, + uint defaultUid, + uint wslDistributionFlags); } diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index cc5d4a997..796e128b4 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -118,6 +118,10 @@ public record class SettingsData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? McpOnlyMode { get; set; } public string? PreferredGatewayId { get; set; } + /// Rollback points retained by count: 1 (default), 2, or -1 for indefinitely. + public int GatewayRollbackRetentionCount { get; set; } = 1; + /// Optional additional age window in days. Zero disables age retention. + public int GatewayRollbackRetentionAgeDays { get; set; } = 0; public bool HasSeenActivityStreamTip { get; set; } = false; public string? SkippedUpdateTag { get; set; } public bool NotifyChatResponses { get; set; } = true; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 2f7f1707b..9f2eac2d7 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -196,6 +196,10 @@ public IntPtr GetHubWindowHandle() private AppState? _appState; internal AppState? AppState => _appState; private UpdateCoordinator? _updateCoordinator; + private GatewayVersionAlignmentCoordinator? _gatewayVersionAlignmentCoordinator; + private string? _gatewayVersionAlignmentPromptKey; + private int _gatewayVersionAlignmentInFlight; + private int _gatewayVersionAlignmentFollowUpQueued; private GatewayService? _gatewayService; private PairingApprovalCoordinator? _pairingApprovalCoordinator; private OpenClawTray.Dialogs.PairingApprovalDialog? _pairingApprovalDialog; @@ -785,6 +789,17 @@ _dispatcherQueue is null tunnelManager: _sshTunnelService); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; _connectionManager.StateChanged += OnManagerStateChanged; + var gatewayAlignmentRunner = new WslExeCommandRunner(appLogger, defaultTimeout: TimeSpan.FromMinutes(35)); + var gatewayRollbackPoints = new GatewayRollbackPointManager( + gatewayAlignmentRunner, + AppIdentity.ResolveSetupLocalDataDirectory(), + AppIdentity.SetupDistroName); + _gatewayVersionAlignmentCoordinator = new GatewayVersionAlignmentCoordinator( + gatewayAlignmentRunner, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + gatewayRollbackPoints, + SynchronizeCompanionGatewayAsync, + ResolveGatewayRollbackRetentionPolicy); // First-run check (also supports forced onboarding for testing). // Wrapped in try/catch so a wizard construction failure cannot tear @@ -826,6 +841,7 @@ _dispatcherQueue is null var wslKeepAlive = new WslGatewayKeepAliveService(() => _settings, () => _gatewayRegistry); _ = Task.Run(wslKeepAlive.TryEnsureAsync); InitializeGatewayClient(); + _ = CheckCompanionGatewayVersionAsync(_gatewayRegistry.GetActive()?.Id); // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) if (_settings != null && @@ -2061,6 +2077,7 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _lastManagerConnectedSideEffectsKey = connectedSideEffectsKey; _ = RunHealthCheckAsync(); _ = TryConnectLocalNodeServiceAsync(); + _ = CheckCompanionGatewayVersionAsync(snap.GatewayId); } } else @@ -2069,6 +2086,340 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna } } + private async Task CheckCompanionGatewayVersionAsync(string? connectedGatewayId) + { + if (Interlocked.CompareExchange(ref _gatewayVersionAlignmentInFlight, 1, 0) != 0) + { + Interlocked.Exchange(ref _gatewayVersionAlignmentFollowUpQueued, 1); + return; + } + + try + { + var activeRecord = _gatewayRegistry?.GetActive(); + if (activeRecord == null || + !string.Equals(activeRecord.Id, connectedGatewayId, StringComparison.Ordinal) || + _gatewayVersionAlignmentCoordinator == null) + { + return; + } + + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + var probe = await _gatewayVersionAlignmentCoordinator.ProbeAsync(accessPlan); + if (probe.State is GatewayVersionAlignmentState.RecoveryAvailable + or GatewayVersionAlignmentState.VerificationFailed + or GatewayVersionAlignmentState.VersionOrderUnknown) + { + var blockedKey = $"blocked|{activeRecord.Id}|{probe.State}|{probe.RollbackPointId}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (!string.Equals(_gatewayVersionAlignmentPromptKey, blockedKey, StringComparison.Ordinal)) + { + _gatewayVersionAlignmentPromptKey = blockedKey; + ReportGatewayAlignmentResult(probe); + } + return; + } + if (probe.State == GatewayVersionAlignmentState.NewerThanRequired && + probe.InstalledVersion != null) + { + var newerKey = $"newer|{activeRecord.Id}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (!string.Equals(_gatewayVersionAlignmentPromptKey, newerKey, StringComparison.Ordinal)) + { + _gatewayVersionAlignmentPromptKey = newerKey; + ShowGatewayAlignmentToast( + "Local Gateway is newer than Companion", + $"OpenClaw {probe.InstalledVersion} was not downgraded to {probe.RequiredVersion}. Your existing WSL installation was left unchanged."); + } + return; + } + + if (probe.State == GatewayVersionAlignmentState.Aligned && + _gatewayVersionAlignmentCoordinator.HasVerifiedPendingUpdate(activeRecord.Id)) + { + var resumed = await _gatewayVersionAlignmentCoordinator.UpdateAsync(accessPlan); + ReportGatewayAlignmentResult(resumed); + if (!resumed.IsAligned) + _gatewayVersionAlignmentPromptKey = null; + return; + } + + if (probe.State != GatewayVersionAlignmentState.Mismatch || probe.InstalledVersion == null) + return; + + var promptKey = $"{activeRecord.Id}|{probe.InstalledVersion}|{probe.RequiredVersion}"; + if (string.Equals(_gatewayVersionAlignmentPromptKey, promptKey, StringComparison.Ordinal)) + return; + + var accepted = await ConfirmCompanionGatewayUpdateAsync(probe); + if (!accepted) + { + _gatewayVersionAlignmentPromptKey = null; + return; + } + + var confirmedRecord = _gatewayRegistry?.GetActive(); + var confirmedPlan = GatewayHostAccessClassifier.Classify(confirmedRecord); + if (confirmedRecord == null || + !string.Equals(confirmedRecord.Id, activeRecord.Id, StringComparison.Ordinal) || + confirmedPlan.TerminalTarget != GatewayTerminalTarget.Wsl || + !confirmedPlan.CanControlWslGateway || + !string.Equals(confirmedPlan.DistroName, accessPlan.DistroName, StringComparison.Ordinal)) + { + Logger.Warn("Local Gateway update canceled because the active Companion-owned Gateway changed while confirmation was open."); + ShowGatewayAlignmentToast( + "Local Gateway update canceled", + "The active Gateway changed before the update started. No package change was made."); + return; + } + + _gatewayVersionAlignmentPromptKey = promptKey; + + ShowGatewayAlignmentToast( + "Updating local OpenClaw Gateway", + "Creating a verified offline rollback point, updating the existing WSL installation, and reconnecting Companion."); + + var result = await _gatewayVersionAlignmentCoordinator.UpdateAsync(confirmedPlan); + ReportGatewayAlignmentResult(result); + if (!result.IsAligned) + _gatewayVersionAlignmentPromptKey = null; + } + catch (Exception ex) + { + _gatewayVersionAlignmentPromptKey = null; + Logger.Warn($"Companion-owned Gateway version alignment failed: {ex.GetType().Name}: {ex.Message}"); + ShowGatewayAlignmentToast( + "Local Gateway update failed", + "OpenClaw could not complete the in-place update. The existing distro was not recreated."); + } + finally + { + Interlocked.Exchange(ref _gatewayVersionAlignmentInFlight, 0); + if (Interlocked.Exchange(ref _gatewayVersionAlignmentFollowUpQueued, 0) != 0) + { + _ = CheckCompanionGatewayVersionAsync(_gatewayRegistry?.GetActive()?.Id); + } + } + } + + private async Task ConfirmCompanionGatewayUpdateAsync(GatewayVersionAlignmentResult probe) + { + if (_dispatcherQueue == null) + { + Logger.Warn("Cannot prompt for local Gateway update without the UI dispatcher."); + return false; + } + + return await RunOnUiThreadAsync(async () => + { + ShowHub("settings"); + var hubWindow = _hubWindow + ?? throw new InvalidOperationException("The Hub window could not be created."); + await hubWindow.WaitForCurrentContentReadyAsync(); + var root = hubWindow.Content as FrameworkElement; + if (root?.XamlRoot == null) + throw new InvalidOperationException("The Hub window has no active XAML root."); + + var dialog = new ContentDialog + { + Title = "Update local OpenClaw Gateway", + Content = + $"This Companion requires OpenClaw {probe.RequiredVersion}, but its existing WSL Gateway " + + $"is {probe.InstalledVersion}. Update this same installation now?\n\n" + + "Companion will stop this distro briefly, export and verify a complete offline VHD rollback point, " + + "then update OpenClaw in place and reconnect the Gateway and Windows Node. The distro is never " + + "recreated during update. Emergency restore remains a separate explicit action.", + PrimaryButtonText = "Update now", + CloseButtonText = "Later", + DefaultButton = ContentDialogButton.Primary, + XamlRoot = root.XamlRoot + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + }); + } + + private Task RunOnUiThreadAsync(Func> action) + { + if (_dispatcherQueue?.HasThreadAccess == true) + return action(); + + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (_dispatcherQueue?.TryEnqueue(async () => + { + try { completion.SetResult(await action()); } + catch (Exception ex) { completion.SetException(ex); } + }) != true) + { + completion.SetException(new InvalidOperationException("The UI dispatcher is unavailable.")); + } + + return completion.Task; + } + + private async Task SynchronizeCompanionGatewayAsync( + string expectedGatewayId, + CancellationToken cancellationToken) + { + var connectionManager = _connectionManager + ?? throw new InvalidOperationException("Gateway connection manager is unavailable."); + var activeRecord = _gatewayRegistry?.GetActive(); + if (activeRecord == null || + !string.Equals(activeRecord.Id, expectedGatewayId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The active Gateway changed during version alignment."); + } + + var operatorReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void ObserveState(object? _, GatewayConnectionSnapshot snapshot) + { + if (!string.Equals(snapshot.GatewayId, expectedGatewayId, StringComparison.Ordinal)) + return; + + switch (snapshot.OperatorState) + { + case RoleConnectionState.Connected: + operatorReady.TrySetResult(true); + break; + case RoleConnectionState.Error: + case RoleConnectionState.PairingRejected: + case RoleConnectionState.PairingRequired: + case RoleConnectionState.RateLimited: + operatorReady.TrySetException(new InvalidOperationException( + snapshot.OperatorError ?? $"Gateway operator connection reached {snapshot.OperatorState}.")); + break; + } + } + + connectionManager.StateChanged += ObserveState; + try + { + await connectionManager.ReconnectAsync(); + ObserveState(connectionManager, connectionManager.CurrentSnapshot); + await operatorReady.Task.WaitAsync(TimeSpan.FromSeconds(45), cancellationToken); + + if (_settings?.EnableNodeMode == true) + await connectionManager.EnsureNodeConnectedAsync(cancellationToken); + + var synchronized = connectionManager.CurrentSnapshot; + if (!string.Equals(synchronized.GatewayId, expectedGatewayId, StringComparison.Ordinal) || + synchronized.OperatorState != RoleConnectionState.Connected || + (_settings?.EnableNodeMode == true && + (synchronized.NodeState != RoleConnectionState.Connected || + synchronized.NodePairingStatus != PairingStatus.Paired))) + { + throw new InvalidOperationException("Companion connection state changed before synchronization completed."); + } + } + finally + { + connectionManager.StateChanged -= ObserveState; + } + } + + private void ReportGatewayAlignmentResult(GatewayVersionAlignmentResult result) + { + switch (result.State) + { + case GatewayVersionAlignmentState.Updated: + ShowGatewayAlignmentToast( + "Local OpenClaw Gateway updated", + $"Companion, Windows Node, and Gateway are synchronized on {result.RequiredVersion}."); + break; + case GatewayVersionAlignmentState.Restored: + ShowGatewayAlignmentToast( + "Local Gateway rollback restored", + $"OpenClaw {result.InstalledVersion} and its complete retained state were restored and resynchronized."); + break; + case GatewayVersionAlignmentState.RestoreCancelled: + ShowGatewayAlignmentToast( + "Staged Gateway restore cancelled", + "The non-destructive staged restore was cancelled. No WSL registration or installed Gateway state was changed."); + break; + case GatewayVersionAlignmentState.RollbackPointFailed: + ShowGatewayAlignmentToast( + "Local Gateway update not started", + "The required verified offline rollback point could not be created, so Companion made no package change."); + break; + case GatewayVersionAlignmentState.RecoveryAvailable: + ShowGatewayAlignmentToast( + "Local Gateway needs attention", + "The update did not complete healthy. A verified rollback point is available in Settings for explicit emergency restore."); + break; + default: + ShowGatewayAlignmentToast( + "Local Gateway update failed", + result.FailureSummary ?? "The existing WSL installation was left in place."); + break; + } + } + + private GatewayRollbackRetentionPolicy ResolveGatewayRollbackRetentionPolicy() + { + var count = _settings?.GatewayRollbackRetentionCount ?? 1; + var ageDays = _settings?.GatewayRollbackRetentionAgeDays ?? 0; + return new GatewayRollbackRetentionPolicy( + count, + ageDays > 0 ? TimeSpan.FromDays(ageDays) : null); + } + + internal IReadOnlyList GetGatewayRollbackPoints() => + _gatewayVersionAlignmentCoordinator?.ListRollbackPoints() ?? []; + + internal async Task RestoreGatewayRollbackPointAsync( + string rollbackPointId, + CancellationToken cancellationToken = default) + { + var coordinator = _gatewayVersionAlignmentCoordinator; + var activeRecord = _gatewayRegistry?.GetActive(); + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + if (coordinator == null || activeRecord == null) + { + return new GatewayVersionAlignmentResult( + GatewayVersionAlignmentState.Ineligible, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + FailureSummary: "No active Companion-owned Gateway is available."); + } + + var result = await coordinator.RestoreAsync( + accessPlan, + rollbackPointId, + rollbackPointId, + cancellationToken); + ReportGatewayAlignmentResult(result); + return result; + } + + internal GatewayVersionAlignmentResult CancelGatewayRollbackPointRestore( + string rollbackPointId, + string confirmedRollbackPointId) + { + var coordinator = _gatewayVersionAlignmentCoordinator; + var activeRecord = _gatewayRegistry?.GetActive(); + var accessPlan = GatewayHostAccessClassifier.Classify(activeRecord); + if (coordinator == null || activeRecord == null) + { + return new GatewayVersionAlignmentResult( + GatewayVersionAlignmentState.Ineligible, + OpenClaw.SetupEngine.GatewayLkgVersion.ResolveLkgVersion(), + FailureSummary: "No active Companion-owned Gateway is available."); + } + + var result = coordinator.CancelRestore( + accessPlan, rollbackPointId, confirmedRollbackPointId); + ReportGatewayAlignmentResult(result); + return result; + } + + private void ShowGatewayAlignmentToast(string title, string detail) + { + try + { + _toastService?.ShowToast(new ToastContentBuilder().AddText(title).AddText(detail)); + } + catch (Exception ex) + { + Logger.Debug($"Gateway alignment toast suppressed: {ex.Message}"); + } + } + private NodeService? EnsureNodeService(SettingsManager settings) { if (_nodeService != null) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index b31b4bece..438a52247 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -626,6 +626,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +