diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383387975..60d819a9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,8 +80,12 @@ jobs: run: | dotnet build tests/OpenClaw.Shared.Tests -c Debug --no-restore dotnet build tests/OpenClaw.Tray.Tests -c Debug -r win-x64 --no-restore + dotnet build tests/OpenClaw.Connection.Tests -c Debug --no-restore + dotnet build tests/OpenClaw.WinNode.Cli.Tests -c Debug --no-restore dotnet build tests/OpenClaw.Tray.IntegrationTests -c Debug -r win-x64 --no-restore dotnet build tests/OpenClaw.Tray.UITests -c Debug -r win-x64 --no-restore + dotnet build tests/OpenClawTray.FunctionalUI.Tests -c Debug -r win-x64 --no-restore + dotnet build tests/OpenClaw.SetupEngine.Tests -c Debug -r win-x64 - name: Run Shared Tests env: @@ -110,6 +114,30 @@ jobs: --results-directory TestResults\Tray --logger trx;LogFileName=OpenClaw.Tray.Tests.trx" + - name: Run Connection Tests + run: > + dotnet-coverage collect + --output TestResults\Connection\coverage.cobertura.xml + --output-format cobertura + "dotnet test tests/OpenClaw.Connection.Tests + --no-build + -c Debug + --verbosity normal + --results-directory TestResults\Connection + --logger trx;LogFileName=OpenClaw.Connection.Tests.trx" + + - name: Run WinNode CLI Tests + run: > + dotnet-coverage collect + --output TestResults\WinNodeCli\coverage.cobertura.xml + --output-format cobertura + "dotnet test tests/OpenClaw.WinNode.Cli.Tests + --no-build + -c Debug + --verbosity normal + --results-directory TestResults\WinNodeCli + --logger trx;LogFileName=OpenClaw.WinNode.Cli.Tests.trx" + # Tray integration tests gate on OPENCLAW_RUN_INTEGRATION; set it so the # MCP-server / capability tests actually run. dotnet-coverage with no # filter captures coverage for both the test host AND the spawned tray @@ -130,19 +158,45 @@ jobs: --logger trx;LogFileName=OpenClaw.Tray.IntegrationTests.trx" # UI tests need a real visual tree AND a system-registered WindowsAppRuntime - # framework MSIX — the test fixture calls Bootstrap.Initialize(1.8, stable), + # framework MSIX — the test fixture calls Bootstrap.Initialize(2.0, stable), # which looks up the framework package by identity. The hosted windows-2025 # runner image doesn't preinstall it, so we install it explicitly here. - # Version pinned to match Microsoft.WindowsAppSDK 1.8.260101001 in the csprojs. - - name: Install WindowsAppRuntime 1.8 + # Version pinned to match Microsoft.WindowsAppSDK 2.0.1 in the csprojs. + - name: Install WindowsAppRuntime 2.0.1 shell: pwsh run: | - $url = "https://aka.ms/windowsappsdk/1.8/1.8.260101001/windowsappruntimeinstall-x64.exe" + $url = "https://aka.ms/windowsappsdk/2.0/2.0.1/windowsappruntimeinstall-x64.exe" $exe = "$env:RUNNER_TEMP\WindowsAppRuntimeInstall.exe" Invoke-WebRequest -Uri $url -OutFile $exe & $exe --quiet if ($LASTEXITCODE -ne 0) { throw "WindowsAppRuntimeInstall failed with exit code $LASTEXITCODE" } + - name: Run Functional UI Tests + run: > + dotnet-coverage collect + --output TestResults\FunctionalUI\coverage.cobertura.xml + --output-format cobertura + "dotnet test tests/OpenClawTray.FunctionalUI.Tests + --no-build + -c Debug + -r win-x64 + --verbosity normal + --results-directory TestResults\FunctionalUI + --logger trx;LogFileName=OpenClawTray.FunctionalUI.Tests.trx" + + - name: Run SetupEngine Tests + run: > + dotnet-coverage collect + --output TestResults\SetupEngine\coverage.cobertura.xml + --output-format cobertura + "dotnet test tests/OpenClaw.SetupEngine.Tests + --no-build + -c Debug + -r win-x64 + --verbosity normal + --results-directory TestResults\SetupEngine + --logger trx;LogFileName=OpenClaw.SetupEngine.Tests.trx" + - name: Run Tray UI Tests run: > dotnet-coverage collect @@ -168,8 +222,83 @@ jobs: semVer: ${{ steps.gitversion.outputs.semVer }} majorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }} + e2etests: + needs: repo-hygiene + if: ${{ !cancelled() }} + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Fail if repo hygiene failed + if: ${{ needs.repo-hygiene.result != 'success' }} + shell: pwsh + run: | + Write-Error "repo-hygiene failed; see the repo-hygiene job output." + exit 1 + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore + + - name: Build Shared Library + run: dotnet build src/OpenClaw.Shared -c Debug --no-restore + + - name: Build SetupEngine + run: dotnet build src/OpenClaw.SetupEngine -c Debug --no-restore + + - name: Build Tray App (WinUI) + run: dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 + + - name: Build E2E Tests + run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64 + + - name: Run E2E Tests + env: + OPENCLAW_RUN_E2E: 1 + shell: pwsh + run: | + dotnet test tests/OpenClaw.E2ETests ` + --no-build ` + -c Debug ` + -r win-x64 ` + --verbosity normal ` + --results-directory TestResults/E2E ` + --logger "trx;LogFileName=OpenClaw.E2ETests.trx" ` + --logger "console;verbosity=detailed" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + [xml]$trx = Get-Content TestResults\E2E\OpenClaw.E2ETests.trx + $executed = [int]$trx.TestRun.ResultSummary.Counters.executed + if ($executed -lt 1) { + Write-Error "E2E test run executed zero tests. Check OPENCLAW_RUN_E2E gating before merging." + exit 1 + } + + - name: Upload E2E Test Results & Logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-test-results + path: | + TestResults/E2E/ + if-no-files-found: warn + build: - needs: test + needs: [test, e2etests] runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} strategy: matrix: @@ -199,6 +328,12 @@ jobs: - name: Publish WinUI Tray App run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r ${{ matrix.rid }} --self-contained --no-restore -p:Version=${{ needs.test.outputs.semVer }} -o publish + - name: Publish SetupEngine.UI + run: | + dotnet publish src/OpenClaw.SetupEngine.UI -c Release -r ${{ matrix.rid }} --self-contained -p:Version=${{ needs.test.outputs.semVer }} -o publish-setup + mkdir publish\SetupEngine + copy publish-setup\* publish\SetupEngine\ -Recurse + - name: Disable NuGet source mapping for signing if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' shell: pwsh @@ -236,7 +371,7 @@ jobs: path: publish/ build-msix: - needs: test + needs: [test, e2etests] runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} continue-on-error: true strategy: @@ -328,7 +463,7 @@ jobs: path: ${{ steps.find-msix.outputs.msix_path }} build-extension: - needs: test + needs: [test, e2etests] runs-on: windows-latest strategy: matrix: @@ -362,8 +497,8 @@ jobs: path: src/OpenClaw.CommandPalette/bin/${{ matrix.platform }}/Debug/ release: - needs: [repo-hygiene, test, build, build-msix, build-extension] - if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' && needs.build-extension.result == 'success' && !cancelled() + needs: [repo-hygiene, test, e2etests, build, build-msix, build-extension] + if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && needs.build-extension.result == 'success' && !cancelled() runs-on: windows-latest permissions: contents: write diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 0ae42f6f5..18b4efab0 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -21,6 +21,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Install gh-aw extension - uses: github/gh-aw-actions/setup-cli@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup-cli@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: version: v0.72.1 diff --git a/.github/workflows/localization-audit.lock.yml b/.github/workflows/localization-audit.lock.yml index 43a8dd2a2..ec0098629 100644 --- a/.github/workflows/localization-audit.lock.yml +++ b/.github/workflows/localization-audit.lock.yml @@ -37,7 +37,7 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 +# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.25.41 @@ -85,7 +85,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -340,7 +340,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -964,7 +964,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1101,7 +1101,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1312,7 +1312,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} diff --git a/.github/workflows/msix-update-feed-pages.yml b/.github/workflows/msix-update-feed-pages.yml deleted file mode 100644 index 85703c56d..000000000 --- a/.github/workflows/msix-update-feed-pages.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Publish MSIX update feed - -on: - push: - branches: [ master ] - paths: - - docs/msix-feed/** - - .github/workflows/msix-update-feed-pages.yml - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v6 - - - name: Prepare MSIX update feed - run: | - mkdir -p _site - cp docs/msix-feed/openclaw-x64.appinstaller _site/openclaw-x64.appinstaller - touch _site/.nojekyll - - - uses: actions/configure-pages@v5 - - - uses: actions/upload-pages-artifact@v4 - with: - path: _site - - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/repo-assist.lock.yml b/.github/workflows/repo-assist.lock.yml index ea90453fa..502ed16fc 100644 --- a/.github/workflows/repo-assist.lock.yml +++ b/.github/workflows/repo-assist.lock.yml @@ -50,7 +50,7 @@ # - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 # - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 -# - github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 +# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 # # Container images used: # - ghcr.io/github/gh-aw-firewall/agent:0.25.41 @@ -133,7 +133,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -461,7 +461,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1366,7 +1366,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1538,7 +1538,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1722,7 +1722,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1775,7 +1775,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} @@ -1875,7 +1875,7 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e70126015..a92e2316d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -436,6 +436,12 @@ File-based logging with automatic rotation: - Rotation: When log exceeds 5MB, old log → `openclaw-tray.log.old` - Thread-safe: Uses lock for concurrent writes +**Easy-button setup diagnostics:** +- Human summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` +- Machine-readable latest trace: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl` +- Per-run traces: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl` +- Contents are redacted and cover setup phases, WSL commands, pairing, gateway checks, repair, and remove lifecycle steps. + **Log Levels:** - `INFO` - Normal operation (connections, events) - `WARN` - Recoverable issues (reconnects, timeouts) @@ -462,7 +468,8 @@ Sensitive data (authentication tokens) are never logged. Two test projects cover the shared library and tray helpers: ```bash -# Run all tests +# Run local-dev tests. E2E is intentionally excluded from the solution and +# runs in CI before merge; run it locally only when explicitly needed. dotnet test # Run with detailed output diff --git a/README.md b/README.md index f51fae84f..bb92e8be4 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,8 @@ openclaw-windows-node/ Settings are stored in: - Settings: `%APPDATA%\OpenClawTray\settings.json` - Logs: `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` +- Easy-button setup summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` +- Easy-button setup JSONL: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl` Default gateway: `ws://localhost:18789` diff --git a/build.ps1 b/build.ps1 index 8d422ddcf..3a17978b7 100644 --- a/build.ps1 +++ b/build.ps1 @@ -23,7 +23,7 @@ #> param( - [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli")] + [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli", "SetupEngine")] [string]$Project = "All", [ValidateSet("Debug", "Release")] @@ -204,9 +204,10 @@ $projects = @{ "Tray" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true } "WinUI" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true } "CommandPalette" = @{ Path = "src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj"; UseRid = $false } + "SetupEngine" = @{ Path = "src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj"; UseRid = $true } } -$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "WinUI") } else { @($Project) } +$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "SetupEngine", "WinUI") } else { @($Project) } # Always build Shared first if building other projects if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Shared") { @@ -221,7 +222,23 @@ foreach ($proj in $toBuild) { } # ============================================================================= -# SUMMARY +# POST-BUILD: Copy SetupEngine.UI into WinUI output so the tray can find it +# ============================================================================= +if (($buildResults.ContainsKey("SetupEngine") -and $buildResults["SetupEngine"]) -and + (($buildResults.ContainsKey("WinUI") -and $buildResults["WinUI"]) -or ($buildResults.ContainsKey("Tray") -and $buildResults["Tray"]))) { + $setupTfm = Get-ProjectTargetFramework $projects["SetupEngine"].Path + $winUITfm = Get-ProjectTargetFramework $projects["WinUI"].Path + if ($setupTfm -and $winUITfm) { + $setupOutDir = "src\OpenClaw.SetupEngine.UI\bin\$Configuration\$setupTfm\$rid" + $winUIOutDir = "src\OpenClaw.Tray.WinUI\bin\$Configuration\$winUITfm\$rid" + $destDir = Join-Path $winUIOutDir "SetupEngine" + if (Test-Path $setupOutDir) { + if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + Copy-Item "$setupOutDir\*" $destDir -Recurse -Force + Write-Info "Copied SetupEngine.UI output → $destDir" + } + } +} # ============================================================================= Write-Header "Build Summary" diff --git a/docs/CODE_REVIEW.md b/docs/CODE_REVIEW.md index 3182ab187..610d3fbaa 100644 --- a/docs/CODE_REVIEW.md +++ b/docs/CODE_REVIEW.md @@ -1,4 +1,12 @@ -# Code Review - OpenClaw Windows Hub +# Historical Code Review - OpenClaw Windows Hub + +> Current audit status (2026-05-21): this review is retained as a point-in-time +> snapshot from early 2026. Several findings have since been addressed or moved +> into dedicated architecture docs, including the connection state machine, +> credential storage, and expanded test coverage. Use +> [`TEST_COVERAGE.md`](./TEST_COVERAGE.md) and +> [`CONNECTION_ARCHITECTURE.md`](./CONNECTION_ARCHITECTURE.md) for current +> status before acting on the historical recommendations below. ## Overview This document provides a comprehensive code review of the OpenClaw Windows Hub repository, focusing on correctness, security, and best practices. @@ -63,6 +71,10 @@ else if (sessions.ValueKind == JsonValueKind.Object) { /* ... */ } **Location**: `OpenClawGatewayClient.ReconnectWithBackoffAsync()` (lines 164-185) +**Current status**: Superseded by the dedicated connection architecture in +`OpenClaw.Connection`, including `ConnectionStateMachine` and +`GatewayConnectionManager`. + **Issue**: Multiple paths can trigger reconnection simultaneously: - Manual reconnect in `CheckHealthAsync()` (line 92) - Auto-reconnect in `ListenForMessagesAsync()` (line 278) @@ -207,10 +219,14 @@ public async Task SendChatMessageAsync(string message) ### ⚠️ Security Recommendations -1. **Token Storage** (Medium Priority) - - Currently stores auth token in `settings.json` as plain text - - **Recommendation**: Use Windows Data Protection API (DPAPI) to encrypt tokens - - Example: `ProtectedData.Protect()` from `System.Security.Cryptography` +1. **Credential Storage** (Medium Priority) + - Historical finding: older settings stored gateway credentials directly in + `settings.json`. + - Current status: gateway credential ownership moved to the gateway registry + and device identity files; SettingsManager uses Windows DPAPI for stored + API keys where applicable. + - **Recommendation**: keep new credential paths documented and covered by + migration/security tests. 2. **WebSocket Message Validation** (Low-Medium Priority) - No explicit size limits on incoming messages @@ -224,10 +240,11 @@ public async Task SendChatMessageAsync(string message) ## Testing Coverage -### ✅ Tests Added (571 tests) +### Tests Added -1. **OpenClaw.Shared.Tests** (478 tests) - Models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting -2. **OpenClaw.Tray.Tests** (93 tests) - Menu display, menu positioning, settings round-trip, deep link parsing +The original review was written when the suite was much smaller. The current +test project inventory and latest required-suite runtime counts live in +[`TEST_COVERAGE.md`](./TEST_COVERAGE.md). ### 📋 Recommended Additional Tests @@ -322,14 +339,16 @@ if (Key.Contains('/') || Key.Contains('\\')) ## Recommendations Summary ### High Priority -1. ✅ Add unit tests - **COMPLETED (88 tests)** -2. Consider encrypting auth token in settings.json (use DPAPI) -3. Add integration tests for WebSocket communication +1. Add unit tests - **completed and expanded; see `TEST_COVERAGE.md`** +2. Review remaining credential-at-rest gaps against the current gateway + registry/device identity model +3. Add integration tests for WebSocket communication where live-gateway coverage + is still required ### Medium Priority 4. Improve error handling consistency 5. Add schema versioning to protocol -6. Implement connection state machine +6. Connection state machine - **completed in `OpenClaw.Connection`** 7. Add message size limits ### Low Priority @@ -342,17 +361,18 @@ if (Key.Contains('/') || Key.Contains('\\')) The OpenClaw Windows Hub codebase demonstrates good software engineering practices with proper async/await usage, event-driven architecture, and resource management. The main areas for improvement are: -1. **Testing**: Now addressed with 88 unit tests covering core functionality +1. **Testing**: Now addressed by the expanded xUnit suite documented in `TEST_COVERAGE.md` 2. **Error Handling**: Could be more consistent -3. **Security**: Token encryption would enhance security +3. **Security**: Continue reviewing credential-at-rest coverage as storage paths evolve 4. **Robustness**: JSON parsing could be more resilient -All critical functionality has been validated through the new unit test suite. The code is production-ready with the caveat that the identified medium-priority issues should be addressed for long-term maintainability. +Critical functionality has broad automated coverage, but this historical review +should not be treated as the live source of truth for current issue status. --- -**Review Date**: 2026-01-29 (updated 2026-03-18) +**Review Date**: 2026-01-29 (historical review; documentation audit 2026-05-21) **Reviewer**: GitHub Copilot Coding Agent -**Test Coverage**: 571 tests, all passing +**Test Coverage**: See [`TEST_COVERAGE.md`](./TEST_COVERAGE.md) for current counts **Overall Grade**: B+ (Good, with room for improvement) diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index e00e26c04..13353f1d6 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -62,6 +62,10 @@ MigrateFromSettings(...) // one-time legacy migration The operator client is received through the `OperatorClientChanged` event. The app subscribes to data events (sessions, nodes, usage, config, pairing, models, agents, etc.) and calls request methods for chat, node invocations, and configuration. +### Chat timeline event routing + +Inbound chat and agent timeline events must include the gateway's canonical `sessionKey`. The tray client must not synthesize a literal `main` key for keyless inbound events, because that can merge unrelated events into the wrong timeline. When a keyless chat or agent event arrives, the tray drops it and raises a one-shot diagnostic so the protocol issue is visible without exposing the dropped message contents. + ## Startup wiring (App.xaml.cs) ``` @@ -212,7 +216,7 @@ The connect handshake uses Ed25519 signatures with v3→v2 fallback: ## Tests -Connection tests live in `tests/OpenClaw.Connection.Tests/` (215 tests): +Connection tests live in `tests/OpenClaw.Connection.Tests/`: - `ConnectionStateMachineTests` — FSM transitions, derived overall state - `CredentialResolverTests` — credential precedence for operator and node diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 7560d9520..9868d2acf 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -13,7 +13,7 @@ The implementation is structured so that **adding a new node capability automati ## Goals 1. **Single source of truth for capabilities.** A new `INodeCapability` registered with `WindowsNodeClient.RegisterCapability(...)` is reachable via every transport the tray supports. Today: gateway WebSocket and local MCP HTTP. Future transports (named pipe, gRPC, whatever) plug in the same way. -2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without auth, without a tunnel. +2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without a gateway token, without pairing, and without a tunnel. 3. **Make MCP clients first-class consumers** of the OpenClaw native node, not afterthoughts. The tooling investment in capabilities (camera consent flows, exec approval policy, canvas WebView2 plumbing) pays off in both directions: agent-via-gateway and agent-via-local-MCP. ## Non-goals (for this iteration) @@ -119,7 +119,7 @@ The tray's most interesting code lives in capabilities — `system.run` (LocalCo Local MCP changes that. Concrete benefits: -- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no token, no SSH tunnel. +- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no gateway token, no pairing, no SSH tunnel. - **Reproducible bug reports.** A repro becomes a `tools/call` body the bug filer can paste verbatim. No "what was the gateway doing at the time." - **Integration tests against a real instance.** A future `tests/integration/` project can spin up the tray in MCP-only mode, fire JSON-RPC, assert results. The same test bodies a developer runs by hand are the same ones CI runs. (Harnessing WinUI itself in CI is harder, but the bridge logic — `McpToolBridge` — is already covered by `McpToolBridgeTests` with no UI involvement.) - **Coverage for the dispatch path itself.** `WindowsNodeClient`'s capability-routing logic (`CanHandle` → `ExecuteAsync`) was previously only exercised against a live gateway. The MCP server hits the same code paths, so any local MCP test is implicit coverage of the gateway dispatch. @@ -168,7 +168,7 @@ With MCP in-process the workflow shortens to: 2. Wire it into `NodeService.RegisterCapabilities()`. 3. Restart the tray. The new tool is *immediately* visible to any local MCP client (`tools/list` re-reads the registry every call), and to manual `curl` tests. -The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint, observe. No gateway, no agent, no auth. +The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint with the local MCP bearer token, observe. No gateway, no agent, no gateway auth. This compounds when you stack it with Claude Code or Cursor on the same machine. A contributor can: @@ -181,7 +181,7 @@ It also reduces the cost of "speculative" capabilities. Today, adding a capabili ## Security model -The server is built on **three** defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly. +The server is built on several defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly. 1. **Loopback bind.** `HttpListener` is registered with the prefix `http://127.0.0.1:8765/`. The Windows kernel binds the listening socket to the loopback interface only — packets from other interfaces are not delivered to it. Firewall configuration is irrelevant. Defends against: another machine on the network. 2. **Defensive `IsLoopback` check.** Each incoming request validates `ctx.Request.RemoteEndPoint.Address`. Belt-and-suspenders for #1. @@ -192,10 +192,11 @@ The server is built on **three** defensive layers, not just one. Loopback alone - the request body exceeds 4 MiB (DoS / OOM cap). Together these three checks force a malicious cross-origin browser fetch into a CORS preflight that we deliberately do not honor (no `Access-Control-Allow-*` is ever emitted), so the actual call is blocked before reaching capability code. -4. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls. -5. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those. +4. **Bearer token.** Every request must include the persistent local MCP bearer token (`Authorization: Bearer `) once the server has created `%APPDATA%\OpenClawTray\mcp-token.txt`. This blocks drive-by local clients that know the port but cannot read the per-user token file. +5. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls. +6. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those. -**Still no authentication.** Any user-context local process with a TCP socket and the port number can drive any capability. This is the same trust boundary as anything that runs as the user — a malicious process on the box could already invoke arbitrary Win32 APIs without going through MCP. We don't try to stop user-context processes from talking to MCP. If that turns out to matter (multi-user shared boxes, low-trust local processes), the right answer is per-call bearer tokens issued by the tray (one-time copy-to-clipboard from the Settings UI), not URL ACLs or HTTPS — both add deployment pain without solving the actual problem. +**Authentication is local bearer-token based.** The token is persistent, generated by the tray, stored in the current user's OpenClawTray data directory, and verified before MCP method dispatch. It is defense-in-depth rather than a hard sandbox boundary: a malicious process already running as the same user may still be able to read user-profile files or invoke native APIs directly. If we need stronger isolation for shared machines or low-trust local processes, the next step is scoped or per-call tokens issued by the tray, not URL ACLs or HTTPS — both add deployment pain without solving the same-user trust problem. ### Verifying the gate @@ -212,7 +213,7 @@ curl -X POST http://127.0.0.1:8765/ -H "Host: evil.com" -H "Content-Type: applic This should be **rejected** with `415`: ```powershell -curl -X POST http://127.0.0.1:8765/ -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}' +curl -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer " -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}' ``` These should **succeed**: @@ -252,19 +253,24 @@ With the tray running and `EnableMcpServer = true`: ```powershell # Server is up -curl http://127.0.0.1:8765/ +curl http://127.0.0.1:8765/ -H "Authorization: Bearer " # List tools curl -s -X POST http://127.0.0.1:8765/ ` + -H "Authorization: Bearer " ` -H "Content-Type: application/json" ` -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' # Take a screenshot of the primary monitor curl -s -X POST http://127.0.0.1:8765/ ` + -H "Authorization: Bearer " ` -H "Content-Type: application/json" ` -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screen.snapshot"}}' ``` +For a simpler local CLI smoke test, run `winnode --list-tools`; it loads the +same token file automatically. + For Claude Code, drop this into `.mcp.json` at the repo root or `~/.claude.json`: ```json @@ -272,7 +278,10 @@ For Claude Code, drop this into `.mcp.json` at the repo root or `~/.claude.json` "mcpServers": { "openclaw-tray": { "type": "http", - "url": "http://127.0.0.1:8765/" + "url": "http://127.0.0.1:8765/", + "headers": { + "Authorization": "Bearer " + } } } } diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index a7df9239c..bfa5b51cf 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -9,7 +9,7 @@ On first launch, the wizard appears only when there is no usable saved gateway c The V2 setup flow walks users through: 1. **Welcome** — Greeting and introduction -2. **Local setup progress** — App-owned `OpenClawGateway` WSL installation +2. **Local setup progress** — Fresh app-owned `OpenClawGateway` WSL installation 3. **Gateway setup** — Gateway-driven provider/model configuration hosted by `GatewayWizardPage` 4. **Permissions** — Windows system permission review 5. **All set** — Feature summary and completion @@ -22,7 +22,7 @@ The setup flow no longer configures remote/manual gateways. The Welcome page's * Displays the OpenClaw lobster icon, app title, and a brief description. If an app-owned local WSL gateway already exists, the primary CTA reads **Install new WSL Gateway** and confirmation warns that the current OpenClaw WSL gateway and distro will be deleted. If only an external gateway exists, the CTA remains **Set up locally** and confirmation explains that the external connection remains available in Connections. ### Local setup progress -Installs and connects a new app-owned `OpenClawGateway` WSL instance. When replacing an app-owned local gateway, the removal step is shown as part of progress and can be retried on failure. +Installs and connects a new app-owned `OpenClawGateway` WSL instance from a clean WSL baseline. Setup does not export from or mutate an existing user Ubuntu distro; if WSL cannot create the named app-owned distro directly, setup fails with an actionable update message. When replacing an app-owned local gateway, the removal step is shown as part of progress and can be retried on failure. ### Wizard Renders server-defined setup steps via RPC (`wizard.start` / `wizard.next`). The gateway controls the flow — steps can be: diff --git a/docs/SETUP.md b/docs/SETUP.md index 2ae877136..c3cf2df26 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -139,6 +139,7 @@ Download and install WebView2 from [Microsoft](https://developer.microsoft.com/m - Make sure the OpenClaw gateway process is running. - Check Windows Firewall — if your gateway runs on a different machine, allow inbound traffic on port 18789. - See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for connection errors. +- For easy-button setup, repair, or remove failures, start with `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt`; Copilot CLI/debugging tools can use `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl`. ### "Not yet paired" message on reconnect @@ -155,6 +156,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for - Make sure you paste the **entire** setup code — it's a single base64url-encoded string. - Check for accidental leading/trailing whitespace. - The code must be from a compatible gateway version. Try entering the gateway URL and token manually instead. +- If the easy-button setup flow generated the code, check `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` for the failing phase and next action. ### Connection test fails @@ -162,6 +164,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for - Check that your token is valid and hasn't expired. - If the gateway is on another machine, ensure Windows Firewall allows traffic on the gateway port. - See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for detailed error messages. +- Easy-button setup diagnostics keep per-run JSONL traces at `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl` and update `easy-setup-latest.txt`/`.jsonl` after each run. ### Wizard shows "offline" @@ -187,4 +190,4 @@ OpenClaw Tray checks for updates automatically and shows a notification when a n Go to **Settings → Apps → Installed apps**, find **OpenClaw Tray**, and click **Uninstall**. Alternatively, use **Add or Remove Programs** in the Control Panel. -Your settings file at `%APPDATA%\OpenClawTray\settings.json` and device key at `%LOCALAPPDATA%\OpenClawTray\device-key-ed25519.json` are not removed automatically — delete them manually if you want a clean uninstall. +Your settings file at `%APPDATA%\OpenClawTray\settings.json` and device identity files under `%APPDATA%\OpenClawTray\` (including per-gateway keys at `%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json`) are not removed automatically — delete them manually if you want a clean uninstall. diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md new file mode 100644 index 000000000..7959beb26 --- /dev/null +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -0,0 +1,392 @@ +# Setup Engine — Architecture & Reference + +## Overview + +The Setup Engine is a **standalone, config-driven system** for provisioning an OpenClaw WSL gateway from scratch. It consists of two projects: + +1. **`OpenClaw.SetupEngine`** — Headless pipeline (console exe). Runs 18 steps sequentially with full JSONL logging, transaction journal, and rollback support. +2. **`OpenClaw.SetupEngine.UI`** — WinUI3 app that wraps the same pipeline with a 5-page fluent wizard UI. + +Both accept a JSON config file to customize behavior. A bundled `default-config.json` ships with each exe and provides secure defaults (loopback bind, WSL isolation, systemd enabled). All defaults can be overridden via config file or environment variables. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OpenClaw.SetupEngine (net10.0, console exe) │ +│ │ +│ SetupPipeline ──→ 18 SetupStep classes ──→ StepResult │ +│ │ │ │ +│ SetupContext CommandRunner (WSL + Process) │ +│ SetupConfig TransactionJournal (JSONL) │ +│ SetupLogger RetryExecutor │ +│ │ +│ refs: OpenClaw.Connection, OpenClaw.Shared │ +└─────────────────────────────────────────────────────────────┘ + ▲ callback: Action + │ +┌─────────────────────────────────────────────────────────────┐ +│ OpenClaw.SetupEngine.UI (net10.0-windows10.0.22621, WinUI3)│ +│ 5 pages, direct code-behind, no MVVM │ +│ Welcome → Capabilities → Progress → Permissions → Complete │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Project Structure + +``` +src/OpenClaw.SetupEngine/ +├── OpenClaw.SetupEngine.csproj # net10.0, console exe +├── Program.cs # CLI entry: --config, --headless, --dry-run, --rollback-on-failure +├── SetupPipeline.cs # Sequential step orchestrator (132 lines) +├── SetupContext.cs # Config model + shared state bag (217 lines) +├── SetupSteps.cs # All setup step implementations +├── TransactionJournal.cs # Append-only JSONL journal (77 lines) +├── SetupLogger.cs # Structured JSONL logger (112 lines) +├── CommandRunner.cs # Concrete WSL/process command runner +├── RetryExecutor.cs # Exponential backoff retry +├── StubNodeCapability.cs # Minimal capability stubs for pairing +└── default-config.json # THE source of truth for all config values + +src/OpenClaw.SetupEngine.UI/ +├── OpenClaw.SetupEngine.UI.csproj # WinAppSDK 2.0.1, self-contained +├── App.xaml / App.xaml.cs # Application entry +├── Program.cs # Main with WinUI activation +├── SetupWindow.xaml / .xaml.cs # 720×820 window, Mica, title bar, navigation +└── Pages/ + ├── WelcomePage.xaml / .cs # Logo, info card, Install button + ContentDialog + ├── CapabilitiesPage.xaml / .cs # 2-column grid with icons + descriptions + ├── ProgressPage.xaml / .cs # Live step rows + streaming log viewer + ├── PermissionsPage.xaml / .cs # 5 permission checks + Open Settings buttons + └── CompletePage.xaml / .cs # Party popper, amber banner, startup toggle +``` + +**Total engine code: ~1,882 lines across 8 files.** UI adds ~10 more files. + +--- + +## Config File (`default-config.json`) + +**Config is required.** Neither the headless exe nor the UI will run without one. The bundled `default-config.json` is auto-loaded from `AppContext.BaseDirectory` if no `--config` is specified. + +```json +{ + "DistroName": "OpenClawGateway", + "GatewayPort": 18789, + "BaseDistro": "Ubuntu-24.04", + "Headless": true, + "AutoApprovePairing": true, + "CleanBeforeRun": true, + "SkipPermissions": false, + "SkipWizard": false, + "WizardAnswers": { + "openclaw-setup": "true", + "security-disclaimer": "true", + "i-understand-this-is-personal-by-default-and-shared-multi-user-use-requires-lock-down-continue": "true", + "setup-mode": "quickstart", + "existing-config-detected": "true", + "config-handling": "keep", + "quickstart": "true", + "model-auth-provider": "skip", + "default-model": "__keep__", + "select-channel-quickstart": "__skip__", + "search-provider": "__skip__", + "configure-skills-now-recommended": "false" + }, + "LogLevel": "trace", + "LogPath": null, + "GatewayUrl": null, + "BootstrapToken": null, + "RollbackOnFailure": false, + + "Wsl": { + "User": "openclaw", + "Systemd": true, + "Interop": false, + "AppendWindowsPath": false, + "Automount": false, + "MountFsTab": false, + "UseWindowsTimezone": true, + "Memory": null, + "Swap": null + }, + + "Gateway": { + "Bind": "loopback", + "InstallUrl": null, + "Version": null, + "HealthTimeoutSeconds": 90, + "ReloadMode": "hot", + "AuthMode": "token", + "ExtraConfig": null + }, + + "Capabilities": { + "System": true, "Canvas": true, "Screen": true, + "Camera": true, "Location": true, "Browser": true, + "Device": true, "Tts": true, "Stt": true + }, + + "Settings": { + "EnableNodeMode": true, + "AutoStart": false, + "NodeSystemRunEnabled": true, + "NodeCanvasEnabled": true, + "NodeScreenEnabled": true, + "NodeCameraEnabled": true, + "NodeLocationEnabled": true, + "NodeBrowserProxyEnabled": true, + "NodeTtsEnabled": true, + "NodeSttEnabled": true + }, + + "Pairing": { + "TimeoutSeconds": 60 + } +} +``` + +### Config Layering (priority, highest wins) + +1. CLI flags (`--headless`, `--log-path`, `--rollback-on-failure`, `--no-rollback-on-failure`) +2. Config file (explicit `--config` or bundled `default-config.json`) +3. Environment variables (`OPENCLAW_SETUP_DISTRO_NAME`, etc.) + +--- + +## Pipeline Steps (18 total) + +Executed sequentially. Each step is a small class (30–120 lines) in `SetupSteps.cs`. + +| # | Step Class | What It Does | +|---|-----------|-------------| +| 1 | `PreflightOsStep` | Validate Windows 64-bit, version ≥ 22H2 | +| 2 | `PreflightWslStep` | Verify WSL is installed and supports direct named clean installs | +| 3 | `CleanupStaleDistroStep` | Unregister leftover app-owned WSL distro and remove its VHD directory if `CleanBeforeRun` | +| 4 | `CleanupStaleGatewayStep` | Stop orphaned gateway service, remove config | +| 5 | `PreflightPortStep` | Check gateway port is available | +| 6 | `CreateWslInstanceStep` | Directly install a fresh app-owned WSL distro; never export a user's Ubuntu distro | +| 7 | `ConfigureWslInstanceStep` | Write wsl.conf, create user, set dirs | +| 8 | `ValidateWslLockdownStep` | Verify WSL isolation settings are applied | +| 9 | `InstallCliStep` | Run install script inside WSL | +| 10 | `ConfigureGatewayStep` | Write gateway config (bind, port, auth) | +| 11 | `InstallGatewayServiceStep` | `openclaw gateway install --force` | +| 12 | `StartGatewayStep` | Start service, poll health endpoint (90s timeout) | +| 13 | `MintBootstrapTokenStep` | Generate bootstrap token via CLI | +| 14 | `PairOperatorStep` | WebSocket operator connection + device approval | +| 15 | `PairNodeStep` | WebSocket node connection + capability registration | +| 16 | `VerifyEndToEndStep` | End-to-end health check (operator → node round trip) | +| 17 | `RunGatewayWizardStep` | Run/configure the gateway wizard unless skipped | +| 18 | `StartKeepaliveStep` | Background WSL keepalive to prevent VM shutdown | + +### Step Base Class + +```csharp +public abstract class SetupStep +{ + public abstract string Id { get; } + public abstract string DisplayName { get; } + public abstract Task ExecuteAsync(SetupContext ctx, CancellationToken ct); + public virtual Task RollbackAsync(SetupContext ctx, CancellationToken ct) => Task.CompletedTask; + public virtual bool CanSkip(SetupContext ctx) => false; + public virtual bool CanRetry => true; + public virtual RetryPolicy Retry => RetryPolicy.Default; +} +``` + +### StepResult + +```csharp +public sealed record StepResult(StepOutcome Outcome, string? Message = null, Exception? Exception = null); +``` + +--- + +## Key Components + +### SetupPipeline + +Sequential orchestrator. For each step: +1. Check `CanSkip` → skip if true +2. Execute with retry (via `RetryExecutor`) +3. On failure + `RollbackOnFailure` → try failed-step cleanup, then rollback completed steps in reverse +4. Journal records every start/complete/rollback + +### SetupContext + +Shared state bag passed to all steps. Contains: +- `Config` — the loaded `SetupConfig` +- `Logger` — structured JSONL logger +- `Journal` — transaction journal +- `Commands` — `CommandRunner` for executing WSL/process commands +- Accumulated runtime state: `DistroName`, `GatewayUrl`, `BootstrapToken`, `GatewayRecordId` + +### CommandRunner + +A single concrete runner executes Windows processes and WSL scripts (`wsl.exe -d -- bash -lc ...`) with timeouts, bounded output collection, and environment injection. + +Every command is logged with exe, sanitized args, timeout, exit code, sanitized stdout/stderr, and elapsed time. + +### TransactionJournal + +Append-only JSONL file (`.journal.jsonl`) recording step transitions. Enables: +- Forensic replay of what happened +- Future `--resume` from last good state +- Rollback decision tracking + +### SetupLogger + +Structured JSONL logger. Records sanitized entries for: +- Step start/complete with timing +- Every shell command and bounded output +- Decisions made (e.g., "chose to clean existing distro") +- State transitions +- Errors with stack traces + +Log path defaults to `%APPDATA%\OpenClawTray\Logs\Setup\setup-.log` + +--- + +## UI Flow + +The WinUI app is a **thin shell** — no business logic, just rendering pipeline state. End-user UI runs default to `RollbackOnFailure=true`; `--no-rollback-on-failure` preserves an explicit debugging opt-out. + +### Page Flow: Welcome → Capabilities → Progress → Permissions → Complete + +**WelcomePage** +- Lobster icon + "OpenClaw Setup" title bar +- Info card explaining what will be installed +- "Install new WSL Gateway" button with ContentDialog confirmation +- "Advanced setup" link → launches tray with `--page connection` + +**CapabilitiesPage** +- 2-column grid showing capabilities from config +- Icons + descriptions for each (System, Canvas, Screen, Camera, etc.) +- "Continue" proceeds to Progress + +**ProgressPage** +- Step rows with spinning ProgressRing → ✓/✗ badges +- Live streaming log viewer (monospace, auto-scroll) +- On success → navigates to Permissions +- On failure → navigates to Complete(success=false) + +**PermissionsPage** +- 5 permission rows: Notifications, Camera, Microphone, Location, Screen Capture +- Live status checks (registry, DeviceAccessInformation, GraphicsCaptureSession) +- "Open Settings" buttons launch `ms-settings://` URIs +- "Refresh status" button, "Continue" proceeds to Complete + +**CompletePage** +- Party popper image +- "All set!" / error heading +- Amber "Node Mode Active" warning banner +- "Launch OpenClaw at startup?" toggle (writes HKCU Run registry) +- "Finish" button launches tray and closes + +### Window Properties +- 720×820 logical pixels (DPI-scaled) +- Mica backdrop +- Custom title bar with lobster icon + +--- + +## CLI Usage + +### Headless (Console Exe) + +``` +OpenClaw.SetupEngine.exe # uses bundled default-config.json +OpenClaw.SetupEngine.exe --config custom.json # explicit config +OpenClaw.SetupEngine.exe --config custom.json --headless # force headless +OpenClaw.SetupEngine.exe --dry-run # validate config, don't execute +OpenClaw.SetupEngine.exe --rollback-on-failure # clean up on failure +OpenClaw.SetupEngine.exe --no-rollback-on-failure # explicit rollback opt-out +OpenClaw.SetupEngine.exe --log-path ./trace.log # override log location +``` + +Exit codes: 0 = success, 1 = failure + +### UI (WinUI Exe) + +``` +OpenClaw.SetupEngine.UI.exe # uses bundled default-config.json +OpenClaw.SetupEngine.UI.exe --config custom.json # explicit config +OpenClaw.SetupEngine.UI.exe --no-rollback-on-failure # debug opt-out from UI cleanup +``` + +Uses the bundled default config when no explicit `--config` is supplied. + +--- + +## Build & Run + +```powershell +# Build headless engine +dotnet build src\OpenClaw.SetupEngine\OpenClaw.SetupEngine.csproj + +# Build UI app (requires Platform specification) +dotnet build src\OpenClaw.SetupEngine.UI\OpenClaw.SetupEngine.UI.csproj -p:Platform=x64 + +# Run UI +Start-Process "src\OpenClaw.SetupEngine.UI\bin\x64\Debug\net10.0-windows10.0.22621.0\win-x64\OpenClaw.SetupEngine.UI.exe" + +# Run headless +& "src\OpenClaw.SetupEngine\bin\Debug\net10.0\OpenClaw.SetupEngine.exe" +``` + +--- + +## Design Principles + +1. **Config is explicit** — secure bundled defaults can be overridden by config file, environment, or flags +2. **Log everything** — every command, decision, and state change in structured JSONL +3. **Steps are small** — each step is a focused class, 30–120 lines +4. **Fail closed on approval** — setup validates approval request IDs and avoids ambiguous node approvals +5. **Clean-start guarantee** — stale state from prior runs is cleaned before proceeding +6. **UI is optional** — engine works identically without UI; UI is a passive observer +7. **Direct code-behind** — no MVVM, no ViewModels, no framework abstractions in UI +8. **Transactional** — journal + rollback on failure, enabled by default for the UI + +--- + +## What We Reuse + +| Component | Source | How | +|-----------|--------|-----| +| WebSocket protocol | `OpenClaw.Shared` | Project reference | +| Gateway registry/credentials | `OpenClaw.Connection` | Project reference | +| Credential resolver | `OpenClaw.Connection` | Direct use | +| Node connector | `OpenClaw.Connection` | Direct use | +| Setup code decoder | `OpenClaw.Connection` | Direct use | +| Bounded WSL drain logic | Reimplemented cleanly | 5s timeout pattern | + +--- + +## Future Work + +| Item | Status | Notes | +|------|--------|-------| +| Interactive gateway wizard in UI | Not started | RPC wizard protocol exists; needs dynamic page renderer | +| Resume from journal (`--resume`) | Designed, not implemented | Journal records state; pipeline can skip completed steps | +| Retry button in Progress UI | Not started | Pipeline supports retry; UI needs "Retry" affordance | +| Tray integration (invoke engine from tray) | Not started | Engine is standalone exe; tray could spawn it | +| Replace `LocalGatewaySetup.cs` | Out of scope | Requires feature-flag switchover in tray | + +--- + +## Design Decisions + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| 1 | Config format | JSON | No extra dependency; commented JSON for readability | +| 2 | Config source | Bundled default config plus overrides | Provides secure defaults while preserving explicit environment-specific overrides | +| 3 | Log viewer | Real-time streaming in Progress page | Essential for debugging; makes iteration fast | +| 4 | Rollback scope | UI default on; headless/config opt-in or explicit opt-out | End-user setup should clean partial installs; debugging can preserve artifacts | +| 5 | UI framework | Direct code-behind, no MVVM | Minimal code; setup UI is write-once, low-churn | +| 6 | Two projects | Engine (console) + UI (WinUI) | Engine testable/automatable independently | +| 7 | Step parallelism | Sequential only | Simplicity; steps have ordering dependencies | +| 8 | Gateway bind | Loopback by default, LAN explicit opt-in | Secure default; LAN mode must be deliberate | diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index debfb9f72..67889f6cf 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,135 +1,99 @@ # Test Coverage Summary -**2349 tests total** (1464 shared + 885 tray) — required suites passing ✅ +**Last audited**: 2026-05-22
+**Framework**: xUnit / .NET 10.0
+**Required validation status**: passing (`.\build.ps1`, Shared tests, Tray tests) -| Metric | Value | -|--------|-------| -| Total Tests | 2349 | -| Result | 2327 passed, 22 skipped | -| Failing | 0 | -| Framework | xUnit / .NET 10.0 | +## Required validation suites -## Test Projects +These are the suites every agent must run after code changes, as documented in +`AGENTS.md`. -### OpenClaw.Shared.Tests — 1464 tests +| Suite | Latest runtime result | +|---|---:| +| `OpenClaw.Shared.Tests` | 1,920 total: 1,891 passed, 29 skipped | +| `OpenClaw.Tray.Tests` | 1,178 total: 1,178 passed, 0 skipped | -#### ModelsTests -- **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting -- **ChannelHealthTests** (~25) — status display for ON/OFF/ERR/LINKED/READY states, case-insensitive matching -- **SessionInfoTests** (~25) — display text, main/sub session prefixes, ShortKey extraction, context summaries -- **SessionInfoContextSummaryTests** (~8) — token window formatting, millions/thousands display -- **SessionInfoRichDisplayTextTests** (~8) — rich display labels, display name fallback -- **SessionInfoAgeTextTests** (~6) — relative time formatting (minutes, hours ago) -- **GatewayUsageInfoTests** (~12) — token counts (999, 15.0K, 2.5M), cost display, empty state -- **GatewayNodeInfoTests** (~10) — display name, node info formatting +Runtime totals come from `dotnet test` on 2026-05-22. They are higher than +method counts because some `[Theory]` tests expand into multiple cases. -#### OpenClawGatewayClientTests (~50) -- Notification classification (health, urgent, calendar, build, email alerts) -- Tool-to-activity mapping (exec, read, write, edit, search, browser, message) -- Path shortening and label truncation -- `ResetUnsupportedMethodFlags` — clearing unsupported flag state +## Test project inventory -#### ExecApprovalPolicyTests (~20) -- Policy rule evaluation, persistence, pattern matching +| Project | Primary scope | Test methods | +|---|---|---:| +| `OpenClaw.Connection.Tests` | Gateway registry, credential resolution, connection manager/state machine, setup codes, pairing, diagnostics | 189 | +| `OpenClaw.Shared.Tests` | Shared models, gateway client, capabilities, MCP, exec approval, A2UI security, URL handling, notification categorization | 1,347 | +| `OpenClaw.Tray.Tests` | Tray state/UI helpers, settings isolation, onboarding, connection page behavior, localization, local gateway setup/uninstall | 786 | +| `OpenClaw.Tray.UITests` | Native WinUI/A2UI control and rendering coverage | 50 | +| `OpenClaw.WinNode.Cli.Tests` | Windows node CLI argument parsing, command behavior, JSON output, uninstall flow | 79 | +| `OpenClawTray.FunctionalUI.Tests` | Functional UI smoke coverage | 8 | +| `OpenClawTray.OnboardingV2.Tests` | Onboarding V2 page flow and state coverage | 9 | +| `OpenClaw.Tray.IntegrationTests` | Integration-test project scaffold; no `[Fact]`/`[Theory]` methods currently | 0 | -#### CapabilityTests (~30) -- **SystemCapabilityTests** — system command handling -- **CanvasCapabilityTests** — canvas command handling -- **ScreenCapabilityTests** — screen command handling -- **CameraCapabilityTests** — camera command handling +The method inventory is a source scan of `[Fact]` and `[Theory]` attributes. Use +`dotnet test` for authoritative runtime totals. -#### NodeCapabilitiesTests (~15) -- Base class parsing, `ExecuteAsync` return values, payload handling +## Coverage highlights -#### DeviceIdentityTests (~15) -- Payload format validation, pairing status events +### OpenClaw.Shared.Tests -#### NotificationCategorizerTests (~30) -- Keyword matching, channel-to-type mapping (health, calendar, stock, build, email, urgent) -- Priority rules, default categorization +- **Model and display formatting** - activity glyphs, app version display, session labels, gateway usage/node display, channel status, and rich text helpers. +- **Gateway and WebSocket behavior** - gateway client parsing, session keys, WebSocket base handling, URL normalization, local gateway classification, and token sanitization. +- **Capabilities and MCP** - app/canvas/screen/camera/system capabilities, MCP auth token reset, MCP HTTP server, MCP tool bridge, MXC availability, MXC policy building, and command runners. +- **Exec approval** - legacy policy coverage plus V2 evaluator, input validation, normalization, prompt adapter, routing, coordinator, store, environment sanitizing, and shell-wrapper parsing. +- **A2UI and web bridge** - A2UI capability security, asset hash pinning, web bridge message handling, and channel payload/status tests. +- **Security and localization-adjacent helpers** - HTTP URL validation/risk evaluation, device identity, identity migration, notification categorization, speech language normalization, and non-fatal action handling. -#### GatewayUrlHelperTests (~25) -- URL normalization (http→ws, https→wss) -- Embedded credential stripping -- Port preservation, path handling +### OpenClaw.Tray.Tests -#### SystemRunTests (~20) -- Command execution, timeout handling, environment variables +- **Tray UI and state** - app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, and chat timeline/markdown handling. +- **Connection and pairing** - connection manager node connector tests, connection page approval/channel metrics/row state, operator and Windows tray node pairing approval, and gateway action transport. +- **Settings and startup** - settings round-trip/isolation, consent and settings save, auto-start defaults, startup setup state, existing config guard policy, and local setup progress stage mapping. +- **Onboarding and local gateway setup** - onboarding completion/chat bootstrapper/existing config guard, wizard flow/selection/error/step parsing, setup code decoding, local gateway setup diagnostics, uninstall, WSL keep-alive, and auto-pair flags. +- **Localization and resources** - localization key parity, capability page localization, fluent icon catalog coverage, and installer assertion tests. -#### ShellQuotingTests (~20) -- Shell metachar detection (`&`, `|`, `;`, `$`, `` ` ``, `*`, `?`, `<`, `>`, etc.) -- Quoting for safe shell invocation +### Additional projects -#### WindowsNodeClientTests (~10) -- URL handling, endpoint construction +- **OpenClaw.Connection.Tests** keeps connection architecture tests separate from tray UI concerns. +- **OpenClaw.Tray.UITests** covers A2UI/native WinUI rendering behavior that is awkward to validate through pure unit tests. +- **OpenClaw.WinNode.Cli.Tests** covers the standalone Windows node CLI contract. +- **OpenClawTray.OnboardingV2.Tests** and **OpenClawTray.FunctionalUI.Tests** cover newer UI surfaces outside the main tray test project. -#### NodeInvokeResponseTests (~5) -- Default values, property setting - -#### ReadmeValidationTests (~5) -- Documentation sync checks - ---- - -### OpenClaw.Tray.Tests — 885 tests - -#### Core Tray Tests - -- **MenuDisplayHelperTests** (~40) — `GetStatusIcon` emoji mapping for Connected/Disconnected/Connecting/Error states, `GetChannelStatusIcon` status icons for running/idle/pending/error/disconnected + case-insensitive variants, `GetNextToggleValue` ON↔OFF toggling, unknown/empty status fallback -- **MenuPositionerTests** (~15) — Screen edge clamping (top-left, bottom-right), taskbar-at-right scenario, menu positioning relative to cursor -- **SettingsRoundTripTests** (~15) — Serialization/deserialization round trips, default values on missing keys, backward compatibility with older settings formats -- **DeepLinkParserTests** (~23) — `ParseDeepLink` protocol validation, null/empty handling, subpath parsing, trailing slash stripping, query parameter extraction, URL-encoded message handling - -#### Onboarding Tests - -- **GatewayWizardStateTests** — Gateway wizard state persistence defaults and disposal -- **GatewayChatHelperTests** (11) — URL scheme conversion, token encoding, localhost checks, session keys -- **LocalGatewayApproverTests** (13) — IsLocalGateway for localhost/remote/edge cases -- **SetupCodeDecoderTests** (14) — Base64url decode, size limits, JSON validation, URL/token extraction -- **GatewayHealthCheckTests** (6) — Health URI building, scheme conversion, port preservation -- **SecurityValidationTests** (16) — Locale whitelist, port range, path traversal, URI scheme validation -- **WizardStepParsingTests** (12) — JSON step parsing, options, completion, sensitive fields -- **GatewayDiscoveryServiceTests** — mDNS host selection and connection URL regression coverage -- **LocalizationValidationTests** — locale key parity, onboarding key presence, duplicate detection, and all-or-none translation consistency - -#### Connection Architecture Tests - -- **GatewayRegistryTests / GatewayRegistryMigrationTests** — active gateway persistence, URL lookup, migration from legacy settings, per-gateway identity copy. -- **CredentialResolverTests / InteractiveGatewayCredentialResolverTests** — device-token-first precedence, shared token fallback, bootstrap pairing state, legacy fallback for user-facing chat. -- **GatewayConnectionManagerTests / ConnectionStateMachineTests** — operator/node lifecycle, transitions, diagnostics, reconnect/disconnect behavior. -- **PairingFlowTests / SetupCodeFlowTests / StaleEventGuardTests** — bootstrap setup codes, pairing state transitions, and stale event suppression. -- **RetryPolicyTests / ConnectionDiagnosticsTests / NodeConnectorTests / SettingsChangeImpactTests** — retry classification, diagnostics, node connector behavior, and settings change impact. - ---- - -## Running Tests +## Running tests ```powershell -# All tests +# Required validation after code changes +$env:OPENCLAW_REPO_ROOT = (Get-Location).Path +.\build.ps1 +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore + +# All local-dev tests in the solution. E2E is intentionally excluded from the +# solution and runs in CI before merge; run it locally only when explicitly needed. dotnet test +# Explicit local E2E run +$env:OPENCLAW_RUN_E2E = "1" +dotnet test .\tests\OpenClaw.E2ETests\OpenClaw.E2ETests.csproj -r win-x64 + # Single project -dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore -dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.Connection.Tests\OpenClaw.Connection.Tests.csproj # Specific test class dotnet test --filter "FullyQualifiedName~MenuDisplayHelperTests" -# Onboarding tests only -dotnet test --filter "FullyQualifiedName~Onboarding" - # Verbose output dotnet test --logger "console;verbosity=detailed" ``` -## Not Covered (Requires Integration Tests) - -- Windows shell tray hover/click behavior -- Full WinUI onboarding wizard flow, including WebView2 navigation -- Real gateway/node pairing against a live gateway +In a fresh worktree, run the project once without `--no-restore` or build it +first so `dotnet test --no-restore` cannot no-op before `bin\` exists. ---- +## Not fully covered by automated tests -**Last Updated**: 2026-05-10 -**Framework**: xUnit / .NET 10.0 -**Status**: ✅ required suites passing (`.\build.ps1`, Shared tests, Tray tests) +- Real shell tray hover/click behavior against Explorer. +- Full live gateway/node pairing against a remote gateway. +- Long-running soak behavior for reconnects, high-frequency activity updates, + and memory usage over multi-day sessions. +- Manual visual acceptance for complex WinUI surfaces where screenshot + comparison would be brittle. diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 674f018d0..de1ad8f03 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -70,6 +70,32 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n 2. **Let GitVersion and CI control the version** - the csproj's `` is just a fallback for local development builds 3. **Test version detection** - after building, check the EXE properties to ensure FileVersion matches expectations 4. **Use semantic versioning** - tags should follow `v{major}.{minor}.{patch}` format (e.g., `v0.4.0`) +5. **Use `OpenClaw.Shared.AppVersionInfo` for any user-visible or wire-exposed version string** - never re-roll + `typeof(...).Assembly.GetName().Version` or hardcode literals like `"v0.1.0"`. `AppVersionInfo` is the single + source of truth driven by ``, used by the About page, Update dialog, support-context dump, + `device.info` capability, MCP `serverVersion` handshake, and the update-check diagnostics. + +## Runtime Version Resolution (AppVersionInfo) + +`src/OpenClaw.Shared/AppVersionInfo.cs` exposes: + +- `AppVersionInfo.Version` → bare string, e.g. `"0.4.7"` +- `AppVersionInfo.DisplayVersion` → `"v"` prefix, e.g. `"v0.4.7"` + +It resolves the version by: + +1. Looking for the `OpenClaw.Tray.WinUI` assembly in the current `AppDomain` (so `dotnet test` and CLI siblings + still report the tray's version rather than the testhost / dotnet host). +2. Falling back to `Assembly.GetEntryAssembly()`, then to the Shared assembly. +3. Reading `AssemblyInformationalVersionAttribute` (preferred) or `AssemblyVersion`. +4. Stripping SourceLink build metadata (`+abc123`) **and** the SemVer pre-release suffix (`-beta.1`) so the + displayed value matches what Updatum compares (Updatum reads the numeric `AssemblyVersion` only). + +For tests that need a deterministic value regardless of host process, set the `internal` test hook: + +```csharp +AppVersionInfo.TestOverride = "9.9.9"; +``` ## References diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index f81baac5a..c0e5cba3e 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -113,6 +113,16 @@ When the node connects, it advertises these capabilities: - If you see "Camera access blocked", enable camera access for desktop apps in Windows Privacy settings - Packaged MSIX builds will show the system consent prompt automatically +### Local sandbox validation +- Sandbox integration tests are intended for local Windows development machines and may skip when the required local sandbox prerequisites are unavailable. +- Build the tray app before running local sandbox validation so the required sandbox helper binaries are present in the app output. + + ```powershell + .\build.ps1 + $env:OPENCLAW_RUN_INTEGRATION='1' + dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc" + ``` + ## Remaining Work (Roadmap) 1. ~~**system.run + exec approvals**~~ ✅ Implemented diff --git a/docs/a2ui/README.md b/docs/a2ui/README.md index b4748b33c..0723390ea 100644 --- a/docs/a2ui/README.md +++ b/docs/a2ui/README.md @@ -4,10 +4,10 @@ This folder is the entry point for everything A2UI in this repo. It captures the v0.8 specification, the standard catalog, and a side-by-side grading of two implementations: -- **Lit reference** in `C:\Users\andersonch\Code\openclaw` (web components, - rendered in a browser via the OpenClaw canvas host). -- **Native WinUI** in this repo (`src/OpenClaw.Tray.WinUI/A2UI/`, - branch `feat/a2ui-native-winui`). +- **Lit reference** from the upstream OpenClaw A2UI renderer + (`vendor/a2ui/renderers/lit/src/0.8`, web components rendered in a + browser via the OpenClaw canvas host). +- **Native WinUI** in this repo (`src/OpenClaw.Tray.WinUI/A2UI/`). The native WinUI design doc that predates this overview lives at [`../A2UI_NATIVE_WINUI.md`](../A2UI_NATIVE_WINUI.md); this folder diff --git a/docs/a2ui/grading.md b/docs/a2ui/grading.md index 05e7c17b2..b3963653b 100644 --- a/docs/a2ui/grading.md +++ b/docs/a2ui/grading.md @@ -3,12 +3,12 @@ This grades two implementations against the v0.8 spec (): -- **Lit reference** at `C:\Users\andersonch\Code\openclaw\vendor\a2ui\renderers\lit\src\0.8` +- **Lit reference** at `vendor\a2ui\renderers\lit\src\0.8` in the + upstream OpenClaw checkout - **Native WinUI** in this repo at `src/OpenClaw.Tray.WinUI/A2UI/` The Lit code looks like the canonical browser renderer the OpenClaw -canvas host ships; the WinUI code is this repo's branch -`feat/a2ui-native-winui`. +canvas host ships; the WinUI code is the native renderer in this repo. Citations use repo-local paths. Lit paths are anchored at the OpenClaw checkout: `openclaw\vendor\a2ui\renderers\lit\src\0.8\`. WinUI paths are diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md index 18b67b024..747128ee1 100644 --- a/docs/gateway-node-integration.md +++ b/docs/gateway-node-integration.md @@ -28,11 +28,14 @@ The gateway has hardcoded defaults per platform (from `PLATFORM_DEFAULTS`): | **macOS** | canvas.*, camera.list, location.get, device.info/status, contacts.search, calendar.events, reminders.list, photos.latest, motion.*, system.run/which/notify, screen.snapshot, browser.proxy | | **iOS** | canvas.*, camera.list, location.get, device.info/status, contacts.*, calendar.*, reminders.*, photos.latest, motion.*, system.notify | | **Android** | canvas.*, camera.list, location.get, notifications.*, device.*, contacts.*, calendar.*, callLog.search, reminders.*, photos.latest, motion.*, system.notify | -| **Windows** | **system.run, system.run.prepare, system.which, system.notify, browser.proxy** | +| **Windows** | camera.list, location.get, device.info, device.status, system.*, browser.proxy, screen.snapshot | | **Linux** | system.run, system.run.prepare, system.which, system.notify, browser.proxy | | **Unknown** | canvas.*, camera.list, location.get, system.notify | -**Windows and Linux get almost nothing by default** — only system commands. No canvas, no camera, no screen, no location. This is because Windows/Linux were originally designed as headless "node host" platforms (exec-only), not full companion apps like macOS/iOS. +Desktop host commands such as `system.run`, `browser.proxy`, and +`screen.snapshot` are filtered unless the node reports canonical desktop +metadata and the command is approved through pairing/live session state or +explicit config. ### 1.2 "Dangerous" Commands (Always Need Explicit Opt-In) @@ -49,46 +52,21 @@ 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`. -### 1.3 How to Enable Commands for Windows +### 1.3 How to Enable Privacy-Sensitive Commands for Windows -Add ALL needed commands to `gateway.nodes.allowCommands` in `~/.openclaw/openclaw.json`: +Normal first-party Windows companion commands should work after pairing when the +node reports canonical `platform: "windows"` and `deviceFamily: "Windows"`. +Only add privacy-sensitive commands when you explicitly want the gateway to +allow camera capture or screen recording: ```json5 { gateway: { nodes: { allowCommands: [ - // Canvas - "canvas.present", - "canvas.hide", - "canvas.navigate", - "canvas.eval", - "canvas.snapshot", - "canvas.a2ui.push", - "canvas.a2ui.pushJSONL", - "canvas.a2ui.reset", - // Camera (all are dangerous or not in Windows defaults) - "camera.list", "camera.snap", "camera.clip", - // Screen - "screen.snapshot", "screen.record", - // Location - "location.get", - // Device metadata/status - "device.info", - "device.status", - // Text-to-speech playback (enable only when agent-driven audio is desired) - "tts.speak", - // System (already in Windows defaults, but listed for completeness) - // "system.run", - // "system.run.prepare", - // "system.which", - // "system.notify", - // Exec approvals - "system.execApprovals.get", - "system.execApprovals.set", ] } } @@ -199,29 +177,20 @@ client: { } ``` -Detection logic (from `node-command-policy.ts`): -1. Normalize `platform` → lowercase -2. Match against prefix rules: `"win"` → windows, `"mac"/"darwin"` → macos, etc. -3. If no match, try `deviceFamily` field -4. If still no match → `"unknown"` (gets conservative defaults) +Detection logic (from `node-command-policy.ts`) now treats desktop command +defaults as a stricter, canonical-platform path: -Our node sends `platform: "windows"` which correctly matches the `windows` prefix rule. +1. Normalize `platform` and `deviceFamily`. +2. Match only canonical platform IDs such as `windows`, `macos`, and `linux`. +3. Require desktop platforms to have a matching desktop family, for example + `platform: "windows"` with `deviceFamily: "Windows"`. +4. If metadata is missing or noncanonical, fall back to `"unknown"` and a + conservative allowlist. -**The problem isn't detection — it's that the `windows` platform intentionally gets a minimal allowlist.** The gateway team designed Windows as a headless exec host, not a full companion app with camera/canvas/screen. - -### 3.1 What "Unknown" Gets (and Why It's Actually Better) - -Ironically, the `unknown` platform gets MORE than Windows: -```typescript -unknown: [ - ...CANVAS_COMMANDS, - ...CAMERA_COMMANDS, // camera.list - ...LOCATION_COMMANDS, // location.get - NODE_SYSTEM_NOTIFY_COMMAND, -] -``` - -If we sent `platform: "windows-desktop"` (which wouldn't match any prefix rule), we'd fall through to `unknown` and actually get canvas/camera/location defaults. But that would be a hack — the right fix is `gateway.nodes.allowCommands`. +Our node should therefore send canonical Windows metadata. SetupEngine also +writes `gateway.nodes.allowCommands` 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. --- @@ -339,22 +308,20 @@ Recommended gateway defaults: | 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` | | Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior | -Until the gateway expands Windows safe defaults, the practical local solution is: +For the first-party Windows companion node, the practical local solution is: 1. Keep declaring the correct command names from the Windows node. -2. Configure `gateway.nodes.allowCommands` for the Windows companion features. +2. Send canonical connect metadata: `platform: "windows"` and + `deviceFamily: "Windows"`. 3. Re-pair after command-list changes because the gateway snapshots commands at approval time. ### 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, for commands that are safe but not yet in the Windows default policy. - -Recommended safe Windows companion allowlist: - -```bash -openclaw config set gateway.nodes.allowCommands '["canvas.present","canvas.hide","canvas.navigate","canvas.eval","canvas.snapshot","canvas.a2ui.push","canvas.a2ui.pushJSONL","canvas.a2ui.reset","camera.list","location.get","screen.snapshot","device.info","device.status","system.execApprovals.get","system.execApprovals.set"]' -openclaw gateway restart -``` +`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.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. @@ -384,66 +351,29 @@ After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyComman - [x] Handle bootstrap token expiry gracefully when setup code payloads include expiry metadata (`expiresAt`, `expires_at`, `expires`, `expiry`, or `exp`) - [x] Add Settings toggles for optional Windows node capability groups (`canvas`, `screen`, `camera`, `location`, `browser.proxy`) -### 5.4 Upstream Contributions / Issues to File +### 5.4 Upstream Alignment -- [x] **Request Windows/macOS parity for safe declared commands** — Windows should allow the same safe companion commands macOS does, while dangerous commands stay explicit opt-in. Draft included below. -- [x] **Document `gateway.nodes.allowCommands`** — local Windows integration docs now describe allowCommands, denyCommands, safe parity commands, privacy-sensitive opt-ins, and re-pair requirements. +- [x] **Use canonical Windows node metadata** — Windows sends + `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`. - [x] **Add `canvas.a2ui.pushJSONL`** — current Mac supports it as a legacy JSONL alias; Windows routes it through the same A2UI push handler -#### Upstream issue draft - -**Title:** Expand Windows node default allowlist for safe declared companion commands - -**Body:** - -Windows nodes are currently treated like Linux/headless exec hosts in `src/gateway/node-command-policy.ts`: - -```ts -windows: [...SYSTEM_COMMANDS] -``` - -That means the gateway filters out safe companion-app commands that a Windows node explicitly declares, including `canvas.*`, `camera.list`, `location.get`, and `screen.snapshot`. The Windows tray app is now a full companion node, not just an exec host, so this causes confusing behavior: the node can implement and advertise a command, but the gateway drops/rejects it unless users manually configure `gateway.nodes.allowCommands`. - -Proposal: - -- Add safe declared companion commands to Windows defaults, similar to macOS: - - `canvas.present` - - `canvas.hide` - - `canvas.navigate` - - `canvas.eval` - - `canvas.snapshot` - - `canvas.a2ui.push` - - `canvas.a2ui.pushJSONL` - - `canvas.a2ui.reset` - - `camera.list` - - `location.get` - - `screen.snapshot` - - `device.info` - - `device.status` -- Keep dangerous/privacy-heavy commands explicit opt-in via `gateway.nodes.allowCommands`: - - `camera.snap` - - `camera.clip` - - `screen.record` - - write commands such as `contacts.add`, `calendar.add`, etc. - -This does not grant capabilities to headless Windows hosts by itself. A command still has to pass both gates: the node must declare it in `commands`, and the gateway policy must allow it. Headless Windows node hosts that only declare `system.run` / `system.which` remain exec-only. - -Related documentation gap: `gateway.nodes.allowCommands` and `gateway.nodes.denyCommands` should be documented in the gateway configuration reference, including the requirement to re-pair after command-list changes because approved pairing records snapshot declared commands. +The gateway still enforces both gates: the node must declare a command in +`commands`, and gateway policy must allow it. Headless Windows node hosts that +only declare `system.run` / `system.which` remain exec-only. ### 5.5 User-Facing Documentation -When shipping the Windows node, README/wiki should tell users: - -> **First-time setup**: After pairing your Windows node, add these commands to your gateway config: -> ```bash -> openclaw config set gateway.nodes.allowCommands '["canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset", "camera.list", "screen.snapshot", "location.get", "device.info", "device.status", "system.execApprovals.get", "system.execApprovals.set"]' -> openclaw gateway restart -> ``` -> Then re-pair the node (`openclaw devices reject ` + re-approve). -> -> Add `camera.snap`, `camera.clip`, and `screen.record` only when you explicitly want to allow privacy-sensitive camera or screen capture. -> -> The Windows tray Command Center (`openclaw://commandcenter`) surfaces these policy problems directly: it separates safe companion allowlist fixes from privacy-sensitive opt-ins and provides copyable repair text for safe fixes or pending pairing approval. +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. +> The Windows tray Command Center (`openclaw://commandcenter`) surfaces policy +> problems directly, including pending pairing approval and privacy-sensitive +> opt-ins. --- diff --git a/docs/msix-feed/openclaw-x64.appinstaller b/docs/msix-feed/openclaw-x64.appinstaller deleted file mode 100644 index 0d985248d..000000000 --- a/docs/msix-feed/openclaw-x64.appinstaller +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 2be592b93..d73a4e31b 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -3,8 +3,6 @@ - - @@ -22,7 +20,12 @@ - + + + + + + @@ -42,7 +45,6 @@ - diff --git a/package.json b/package.json index 82b632b94..7646d733e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openclaw-windows-node-mxc", "version": "0.0.0", "private": true, - "description": "Node bridge dependencies for the OpenClaw tray's MXC sandbox integration. The C# tray spawns node.exe with scripts under tools/mxc/ that consume the @microsoft/mxc-sdk to drive wxc-exec.exe.", + "description": "MXC sandbox dependency used by the OpenClaw tray build to copy wxc-exec.exe into the app output.", "dependencies": { "@microsoft/mxc-sdk": "^0.1.8" } diff --git a/scripts/Test-Localization.ps1 b/scripts/Test-Localization.ps1 index b59f0051d..9ed36ee87 100644 --- a/scripts/Test-Localization.ps1 +++ b/scripts/Test-Localization.ps1 @@ -59,12 +59,24 @@ function Test-IsLocalizableValue { return $true } +function Test-IsSourceXaml { + param([System.IO.FileInfo]$File) + + $relativePath = Get-RelativePath $winUiRoot $File.FullName + $segments = $relativePath -split '[\\/]' + # Build output can contain generated/copied XAML; only source XAML should drive localization findings. + return $segments -notcontains 'bin' -and $segments -notcontains 'obj' +} + function Get-XamlLocalizationFindings { $resourceKeys = Get-ResourceKeys $missingResources = [System.Collections.Generic.List[string]]::new() $hardcodedValues = [System.Collections.Generic.List[string]]::new() - Get-ChildItem -LiteralPath $winUiRoot -Recurse -Filter *.xaml | Sort-Object FullName | ForEach-Object { + Get-ChildItem -LiteralPath $winUiRoot -Recurse -Filter *.xaml -File | + Where-Object { Test-IsSourceXaml $_ } | + Sort-Object FullName | + ForEach-Object { $xamlPath = $_.FullName $relativePath = Get-RelativePath $repoRoot $xamlPath [xml]$xml = Get-Content -LiteralPath $xamlPath -Raw -Encoding UTF8 diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 index 2eaab1c89..fc94a6a32 100644 --- a/scripts/validate-wsl-gateway-uninstall.ps1 +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -233,6 +233,7 @@ $settingsPath = Join-Path $appData "OpenClawTray\settings.json" $logsDir = Join-Path $localAppData "OpenClawTray\Logs" $execPolicyPath = Join-Path $localAppData "OpenClawTray\exec-policy.json" $vhdDirPath = Join-Path $localAppData "OpenClawTray\wsl\$DistroName" +$wslParentDirPath = Join-Path $localAppData "OpenClawTray\wsl" $autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" $autoStartAppName = "OpenClawTray" @@ -374,6 +375,7 @@ function Get-StateSnapshot { settings_exists = (Test-Path -LiteralPath $settingsPath) exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) + wsl_parent_dir_exists = (Test-Path -LiteralPath $wslParentDirPath) } processes_openclaw = @() } @@ -477,6 +479,7 @@ function Get-Postconditions { mcp_token_preserved = $mcpTokenPreserved keepalives_absent = $keepalivesAbsent vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath)) + wsl_parent_dir_absent = (-not (Test-Path -LiteralPath $wslParentDirPath)) } } @@ -488,7 +491,8 @@ function Get-Verdict { # Required postconditions (device_key_file_preserved and mcp_token_preserved are advisory). $required = @('wsl_distro_absent', 'autostart_cleared', 'setup_state_absent', - 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent') + 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent', + 'wsl_parent_dir_absent') $failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true }) $errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count } diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs index d1d9b0b5f..696bba178 100644 --- a/src/OpenClaw.Chat/ChatTimelineReducer.cs +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -189,20 +189,51 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b } } - if (replace && reconcilePrevious && state.Entries.Count > 0) + // A final ChatMessageEvent that arrives without an ActiveAssistantId + // must, in certain cases, reconcile into the most recent Assistant + // entry instead of creating a duplicate bubble: + // + // • `reconcilePrevious` flag: explicit opt-in from the provider, + // used when the gateway emits the final message AFTER tool entries + // have been appended (text → tool → tool output → final text). The + // flag lets the reducer collapse the streaming preview into the + // final text even though the immediate last entry is a ToolCall. + // + // • Identical-text safety net: if the most recent Assistant entry + // (within the same turn — i.e. before any User boundary) has + // byte-equal text to the incoming message, collapse them + // regardless of any flag. This catches duplicate ChatMessageEvent + // emissions from the gateway (see the duplicate-bubble screenshot + // bug where the same final text was rendered twice in a row). + if (replace && state.Entries.Count > 0) { - var lastIndex = state.Entries.Count - 1; - var last = state.Entries[lastIndex]; - if (last.Kind == ChatTimelineItemKind.Assistant) + // Scan backward for the most recent Assistant entry — not just + // the very last one (see reasons above). + for (var li = state.Entries.Count - 1; li >= 0; li--) { - return state with + var candidate = state.Entries[li]; + if (candidate.Kind == ChatTimelineItemKind.Assistant) { - Entries = state.Entries.SetItem(lastIndex, last with + var shouldMerge = + reconcilePrevious + || string.Equals(candidate.Text, text, StringComparison.Ordinal); + if (!shouldMerge) + break; + + return state with { - Text = text, - IsStreaming = streaming - }) - }; + Entries = state.Entries.SetItem(li, candidate with + { + Text = text, + IsStreaming = streaming + }) + }; + } + // Stop scanning once we hit a User entry — that's a turn + // boundary, the assistant entry above it belongs to a + // previous turn and must not be reconciled into. + if (candidate.Kind == ChatTimelineItemKind.User) + break; } } diff --git a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs index 447dd5611..7419905da 100644 --- a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs +++ b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs @@ -49,11 +49,6 @@ public override IListItem[] GetItems() Title = "💬 Web Chat", Subtitle = "Open the OpenClaw chat window" }, - new ListItem(new OpenUrlCommand("openclaw://send")) - { - Title = "📝 Quick Send", - Subtitle = "Send a message to OpenClaw" - }, new ListItem(new OpenUrlCommand("openclaw://setup")) { Title = "🧭 Setup Wizard", diff --git a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs index a152d966a..147fc29a5 100644 --- a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs +++ b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs @@ -20,4 +20,5 @@ public sealed record ConnectionSettingsSnapshot( bool NodeBrowserProxyEnabled, bool NodeSttEnabled, bool NodeTtsEnabled, + bool NodeSystemRunEnabled, string? FullSettingsJson); diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 55fa260ef..80baf4738 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -21,6 +21,7 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private readonly Func? _isNodeEnabled; private readonly IClock _clock; private readonly Func? _shouldStartNodeConnection; + private readonly Func _reconnectDelay; private readonly SemaphoreSlim _transitionSemaphore = new(1, 1); private long _generation; @@ -48,7 +49,8 @@ public GatewayConnectionManager( Func? isNodeEnabled = null, ConnectionDiagnostics? diagnostics = null, ISshTunnelManager? tunnelManager = null, - Func? shouldStartNodeConnection = null) + Func? shouldStartNodeConnection = null, + Func? reconnectDelay = null) { _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); @@ -60,6 +62,7 @@ public GatewayConnectionManager( _isNodeEnabled = isNodeEnabled; _clock = clock ?? SystemClock.Instance; _shouldStartNodeConnection = shouldStartNodeConnection; + _reconnectDelay = reconnectDelay ?? Task.Delay; _diagnostics = diagnostics ?? new ConnectionDiagnostics(clock: clock); _diagnostics.EventRecorded += (_, e) => DiagnosticEvent?.Invoke(this, e); @@ -239,6 +242,11 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _gatewayNeedsV2Signature = true; }; + // Local gateways only support v2 signatures — skip the v3 attempt entirely + // to avoid a spurious "metadata-upgrade" re-pairing triggered by the v3→v2 fallback. + if (record.IsLocal) + _gatewayNeedsV2Signature = true; + // If we already know this gateway needs v2, tell the client upfront if (_gatewayNeedsV2Signature) lifecycle.DataClient.UseV2Signature = true; @@ -900,7 +908,7 @@ private async void OnNodePairingStatusChanged(object? sender, PairingStatusEvent if (approved) { _diagnostics.Record("node", "Node pairing auto-approved — reconnecting node"); - await Task.Delay(1000); // brief delay for gateway to process + await _reconnectDelay(TimeSpan.FromMilliseconds(1000)); // brief delay for gateway to process if (Interlocked.Read(ref _generation) == approvalGeneration) await StartNodeConnectionAsync(); } diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index d4df48fca..025bc22f6 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -27,6 +27,9 @@ public sealed record GatewayRecord /// True for gateways provisioned locally (localhost/WSL). public bool IsLocal { get; init; } + /// WSL distro name for gateway records provisioned by SetupEngine. + public string? SetupManagedDistroName { get; init; } + /// Per-gateway SSH tunnel configuration. Null if no tunnel needed. public SshTunnelConfig? SshTunnel { get; init; } diff --git a/src/OpenClaw.Connection/OperatorScopeHelper.cs b/src/OpenClaw.Connection/OperatorScopeHelper.cs index d3daaf7d3..f57b2912f 100644 --- a/src/OpenClaw.Connection/OperatorScopeHelper.cs +++ b/src/OpenClaw.Connection/OperatorScopeHelper.cs @@ -6,4 +6,15 @@ public static bool CanApproveDevices(IReadOnlyList grantedScopes) => grantedScopes.Any(s => s.Equals("operator.admin", StringComparison.OrdinalIgnoreCase) || s.Equals("operator.pairing", StringComparison.OrdinalIgnoreCase)); + + public static bool CanReadConfig(IReadOnlyList grantedScopes) => + HasScope(grantedScopes, "operator.admin") || + HasScope(grantedScopes, "operator.read"); + + public static bool CanWriteConfig(IReadOnlyList grantedScopes) => + HasScope(grantedScopes, "operator.admin") || + HasScope(grantedScopes, "operator.write"); + + private static bool HasScope(IReadOnlyList grantedScopes, string scope) => + grantedScopes.Any(s => s.Equals(scope, StringComparison.OrdinalIgnoreCase)); } diff --git a/src/OpenClaw.Connection/SettingsChangeImpact.cs b/src/OpenClaw.Connection/SettingsChangeImpact.cs index fac031c55..0f00d6a1e 100644 --- a/src/OpenClaw.Connection/SettingsChangeImpact.cs +++ b/src/OpenClaw.Connection/SettingsChangeImpact.cs @@ -43,10 +43,16 @@ public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, Co return SettingsChangeImpact.OperatorReconnectRequired; // EnableNodeMode toggled → node reconnect - if (prev.EnableNodeMode != next.EnableNodeMode || - prev.EnableMcpServer != next.EnableMcpServer) + if (prev.EnableNodeMode != next.EnableNodeMode) return SettingsChangeImpact.NodeReconnectRequired; + // EnableMcpServer toggled → no gateway reconnect needed; the MCP + // server is purely local and managed by NodeService.SetMcpEnabled + // in the settings-change handler. Classify as UiOnly so the + // reconnect path is not triggered. + if (prev.EnableMcpServer != next.EnableMcpServer) + return SettingsChangeImpact.UiOnly; + // Node capability toggles → capability reload if (prev.NodeCanvasEnabled != next.NodeCanvasEnabled || prev.NodeScreenEnabled != next.NodeScreenEnabled || @@ -54,7 +60,8 @@ public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, Co prev.NodeLocationEnabled != next.NodeLocationEnabled || prev.NodeBrowserProxyEnabled != next.NodeBrowserProxyEnabled || prev.NodeSttEnabled != next.NodeSttEnabled || - prev.NodeTtsEnabled != next.NodeTtsEnabled) + prev.NodeTtsEnabled != next.NodeTtsEnabled || + prev.NodeSystemRunEnabled != next.NodeSystemRunEnabled) return SettingsChangeImpact.CapabilityReload; // Check if anything else changed (UI-only changes) diff --git a/src/OpenClaw.SetupEngine.UI/App.xaml b/src/OpenClaw.SetupEngine.UI/App.xaml new file mode 100644 index 000000000..1beb117c8 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/App.xaml.cs b/src/OpenClaw.SetupEngine.UI/App.xaml.cs new file mode 100644 index 000000000..006c38135 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/App.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.UI.Xaml; + +namespace OpenClaw.SetupEngine.UI; + +public partial class App : Application +{ + public static SetupWindow? MainWindow { get; private set; } + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + MainWindow = new SetupWindow(); + MainWindow.BringToFrontForSetupLaunch(); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj new file mode 100644 index 000000000..aaf86f8e7 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj @@ -0,0 +1,51 @@ + + + + WinExe + net10.0-windows10.0.22621.0 + true + enable + enable + OpenClaw.SetupEngine.UI + OpenClaw.SetupEngine.UI + x64;ARM64 + win-x64;win-arm64 + None + true + app.manifest + ..\OpenClaw.Tray.WinUI\Assets\openclaw.ico + en-US + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + + + win-x64 + + + win-arm64 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml new file mode 100644 index 000000000..71e14ddd4 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs new file mode 100644 index 000000000..b4362f80d --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs @@ -0,0 +1,116 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; + +namespace OpenClaw.SetupEngine.UI.Pages; + +public sealed partial class CapabilitiesPage : Page +{ + private SetupConfig? _config; + private readonly Dictionary _toggles = new(); + + // (config property, display name, description, fluent icon glyph) + private static readonly (string Key, string Name, string Desc, string Glyph)[] Capabilities = + [ + ("System", "System", "Shell commands, files, clipboard", "\uE756"), + ("Canvas", "Canvas", "Whiteboard and annotations", "\uE790"), + ("Screen", "Screen capture", "Screenshots and recording", "\uE7F4"), + ("Camera", "Camera", "Webcam photos and video", "\uE722"), + ("Location", "Location", "Share device location", "\uE81D"), + ("Browser", "Browser", "Web navigation and automation", "\uE774"), + ("Device", "Device", "Volume, brightness, system info", "\uE772"), + ("Tts", "Text-to-speech", "Speak text aloud", "\uE767"), + ("Stt", "Speech-to-text", "Transcribe spoken audio", "\uE720"), + ]; + + public CapabilitiesPage() + { + InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + _config = e.Parameter as SetupConfig ?? new SetupConfig(); + BuildToggles(); + } + + private void BuildToggles() + { + var caps = _config!.Capabilities; + var totalRows = (Capabilities.Length + 1) / 2; // ceiling division for 2 columns + + // Add row definitions + for (int i = 0; i < totalRows; i++) + CapGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + for (int i = 0; i < Capabilities.Length; i++) + { + var (key, name, desc, glyph) = Capabilities[i]; + var prop = typeof(CapabilitiesConfig).GetProperty(key); + var isEnabled = (bool)(prop?.GetValue(caps) ?? true); + + var toggle = new ToggleSwitch + { + IsOn = isEnabled, + OnContent = "", + OffContent = "", + MinWidth = 0, + }; + _toggles[key] = toggle; + + // Card-like item: icon + text + toggle + var item = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + Padding = new Thickness(10, 12, 6, 12), + }; + + var icon = new TextBlock + { + Text = glyph, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Segoe Fluent Icons"), + FontSize = 20, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 12, 0), + Opacity = 0.85, + }; + + var textStack = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center }; + textStack.Children.Add(new TextBlock { Text = name, FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + textStack.Children.Add(new TextBlock { Text = desc, FontSize = 11, Opacity = 0.55 }); + + Grid.SetColumn(icon, 0); + Grid.SetColumn(textStack, 1); + Grid.SetColumn(toggle, 2); + item.Children.Add(icon); + item.Children.Add(textStack); + item.Children.Add(toggle); + + int row = i / 2; + int col = i % 2; + Grid.SetRow(item, row); + Grid.SetColumn(item, col); + CapGrid.Children.Add(item); + } + } + + private void Continue_Click(object sender, RoutedEventArgs e) + { + var caps = _config!.Capabilities; + foreach (var (key, _, _, _) in Capabilities) + { + if (_toggles.TryGetValue(key, out var toggle)) + { + var prop = typeof(CapabilitiesConfig).GetProperty(key); + prop?.SetValue(caps, toggle.IsOn); + } + } + + App.MainWindow?.NavigateToProgress(); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml new file mode 100644 index 000000000..270222628 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs new file mode 100644 index 000000000..83329a268 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using Windows.UI; + +namespace OpenClaw.SetupEngine.UI.Pages; + +public sealed partial class CompletePage : Page +{ + private string? _logPath; + + public CompletePage() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (e.Parameter is CompletePageArgs args) + { + _logPath = args.LogPath; + + if (args.Success) + { + SuccessIcon.Visibility = Visibility.Visible; + FailureIcon.Visibility = Visibility.Collapsed; + TitleText.Text = "All set!"; + SubtitleText.Text = "OpenClaw is ready to go"; + ErrorCard.Visibility = Visibility.Collapsed; + } + else + { + SuccessIcon.Visibility = Visibility.Collapsed; + FailureIcon.Visibility = Visibility.Visible; + TitleText.Text = "Setup failed"; + SubtitleText.Text = args.ErrorMessage ?? "An error occurred during setup"; + NodeModeBanner.Visibility = Visibility.Collapsed; + StartupRow.Visibility = Visibility.Collapsed; + LaunchButton.Content = "Close"; + + // Show error card with details and log link + ErrorCard.Visibility = Visibility.Visible; + ErrorText.Text = args.ErrorMessage ?? "Unknown error"; + if (args.LogPath != null) + ViewLogLink.Content = $"View full log → {Path.GetFileName(args.LogPath)}"; + else + ViewLogLink.Visibility = Visibility.Collapsed; + } + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + // Style the Node Mode banner with amber/brown background + var isDark = ActualTheme == ElementTheme.Dark; + NodeModeBanner.Background = new SolidColorBrush(isDark + ? Color.FromArgb(255, 0x4A, 0x3D, 0x10) // dark amber + : Color.FromArgb(255, 0xF5, 0xE6, 0xB8)); // light amber + + // Default startup toggle to off (user can enable) + StartupToggle.IsOn = false; + } + + private void LaunchButton_Click(object sender, RoutedEventArgs e) + { + // Register startup if toggled on + if (StartupToggle.Visibility == Visibility.Visible && StartupToggle.IsOn) + RegisterStartup(); + + // Launch tray on success, just close on failure + if (LaunchButton.Content?.ToString() != "Close") + LaunchTray(); + App.MainWindow?.Close(); + } + + private void ViewLog_Click(object sender, RoutedEventArgs e) + { + if (_logPath != null && File.Exists(_logPath)) + Process.Start(new ProcessStartInfo(_logPath) { UseShellExecute = true }); + } + + private static void LaunchTray() + { + // Kill any existing tray instances so fresh one picks up new gateway + foreach (var proc in Process.GetProcessesByName("OpenClaw.Tray.WinUI")) + { + try { proc.Kill(); } catch { } + } + + // Brief pause for process cleanup + Thread.Sleep(1000); + + // Launch via protocol deep link — opens tray and navigates to chat + Process.Start(new ProcessStartInfo("openclaw://chat") { UseShellExecute = true }); + } + + private static void RegisterStartup() + { + try + { + // Find tray exe path for startup registration + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "..", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.exe"), + Path.Combine(AppContext.BaseDirectory, "OpenClaw.Tray.WinUI.exe"), + }; + var trayPath = candidates.FirstOrDefault(File.Exists); + if (trayPath == null) return; + + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true); + key?.SetValue("OpenClawTray", $"\"{Path.GetFullPath(trayPath)}\""); + } + catch { /* best effort */ } + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml new file mode 100644 index 000000000..02cc125d9 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs new file mode 100644 index 000000000..28f98978f --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs @@ -0,0 +1,227 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using Microsoft.Win32; +using Windows.Devices.Enumeration; +using Windows.Graphics.Capture; +using Windows.UI; + +namespace OpenClaw.SetupEngine.UI.Pages; + +public sealed partial class PermissionsPage : Page +{ + private SetupConfig? _config; + + private record PermDef(string Name, string Glyph, string SettingsUri, Func> Check); + + private static readonly PermDef[] Permissions = + [ + new("Notifications", "\uEA8F", "ms-settings:notifications", CheckNotificationsAsync), + new("Camera", "\uE722", "ms-settings:privacy-webcam", CheckCameraAsync), + new("Microphone", "\uE720", "ms-settings:privacy-microphone", CheckMicrophoneAsync), + new("Location (optional)", "\uE81D", "ms-settings:privacy-location", CheckLocationAsync), + new("Screen Capture", "\uE7F4", "", CheckScreenCaptureAsync), + ]; + + public PermissionsPage() + { + InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + _config = e.Parameter as SetupConfig ?? new SetupConfig(); + _ = RefreshPermissions(); + } + + private async Task RefreshPermissions() + { + PermRows.Children.Clear(); + var isDark = ActualTheme == ElementTheme.Dark; + var cardBg = new SolidColorBrush(isDark + ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C) + : Color.FromArgb(255, 0xF5, 0xF5, 0xF5)); + + foreach (var perm in Permissions) + { + var (status, granted) = await perm.Check(); + PermRows.Children.Add(BuildRow(perm, status, granted, cardBg, isDark)); + } + } + + private static FrameworkElement BuildRow(PermDef perm, string status, bool granted, Brush cardBg, bool isDark) + { + var statusColor = granted + ? Color.FromArgb(255, 0x2B, 0xC3, 0x6F) // green + : Color.FromArgb(255, 0xF4, 0xA6, 0xB0); // pink + + // Icon badge + var iconBadge = new Border + { + Width = 40, Height = 40, + CornerRadius = new CornerRadius(20), + Background = isDark + ? new SolidColorBrush(Microsoft.UI.Colors.Transparent) + : new SolidColorBrush(Color.FromArgb(255, 0x33, 0x33, 0x33)), + Child = new TextBlock + { + Text = perm.Glyph, + FontFamily = new FontFamily("Segoe Fluent Icons"), + FontSize = 20, + Foreground = new SolidColorBrush(isDark ? Microsoft.UI.Colors.White : Microsoft.UI.Colors.White), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 16, 0), + }; + + // Title + status + var textStack = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center }; + textStack.Children.Add(new TextBlock { Text = perm.Name, FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + textStack.Children.Add(new TextBlock { Text = status, FontSize = 13, Foreground = new SolidColorBrush(statusColor) }); + + // Open Settings button (only if URI exists) + FrameworkElement actionCol; + if (!string.IsNullOrEmpty(perm.SettingsUri)) + { + var btn = new Button + { + Padding = new Thickness(8, 6, 8, 6), + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + }; + var btnContent = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + btnContent.Children.Add(new TextBlock + { + Text = "\uE8A7", FontFamily = new FontFamily("Segoe Fluent Icons"), + FontSize = 14, VerticalAlignment = VerticalAlignment.Center + }); + btnContent.Children.Add(new TextBlock { Text = "Open Settings", FontSize = 13, VerticalAlignment = VerticalAlignment.Center }); + btn.Content = btnContent; + var uri = perm.SettingsUri; + btn.Click += async (_, _) => + { + try { await Windows.System.Launcher.LaunchUriAsync(new Uri(uri)); } + catch { /* best effort */ } + }; + actionCol = btn; + } + else + { + actionCol = new Border { Width = 1 }; + } + + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(iconBadge, 0); + Grid.SetColumn(textStack, 1); + Grid.SetColumn(actionCol, 2); + grid.Children.Add(iconBadge); + grid.Children.Add(textStack); + grid.Children.Add(actionCol); + + return new Border + { + Child = grid, + Background = cardBg, + CornerRadius = new CornerRadius(8), + Padding = new Thickness(20, 18, 20, 18), + }; + } + + private void Refresh_Click(object sender, RoutedEventArgs e) => _ = RefreshPermissions(); + + private void BackToWizard_Click(object sender, RoutedEventArgs e) + => App.MainWindow?.NavigateToWizard(); + + private void Next_Click(object sender, RoutedEventArgs e) + => App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, null); + + // ── Permission checks (passive, no OS consent dialogs) ── + + private static Task<(string, bool)> CheckNotificationsAsync() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\PushNotifications"); + if (key?.GetValue("ToastEnabled") is int val && val == 0) + return Task.FromResult(("Disabled", false)); + return Task.FromResult(("Enabled", true)); + } + catch + { + return Task.FromResult(("Unable to check", false)); + } + } + + private static async Task<(string, bool)> CheckCameraAsync() + { + try + { + var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture); + if (devices.Count == 0) return ("No camera detected", false); + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); + return access.CurrentStatus == DeviceAccessStatus.Allowed + ? ("Allowed", true) + : ("Denied — open Settings to allow", false); + } + catch { return ("Unable to check", false); } + } + + private static async Task<(string, bool)> CheckMicrophoneAsync() + { + try + { + var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture); + if (devices.Count == 0) return ("No microphone detected", false); + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); + return access.CurrentStatus == DeviceAccessStatus.Allowed + ? ("Allowed", true) + : ("Denied — open Settings to allow", false); + } + catch { return ("Unable to check", false); } + } + + private static Task<(string, bool)> CheckLocationAsync() + { + try + { + using var sysKey = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); + if (sysKey?.GetValue("Value") is string sv && sv.Equals("Deny", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(("Location services disabled", false)); + + using var userKey = Registry.CurrentUser.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); + var uv = userKey?.GetValue("Value") as string; + if (uv != null && uv.Equals("Deny", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(("Disabled for this user", false)); + + return Task.FromResult(("Location services enabled", true)); + } + catch { return Task.FromResult(("Unable to check", false)); } + } + + private static Task<(string, bool)> CheckScreenCaptureAsync() + { + try + { + return Task.FromResult(GraphicsCaptureSession.IsSupported() + ? ("Available — uses picker per capture", true) + : ("Not supported on this device", false)); + } + catch { return Task.FromResult(("Unable to check", false)); } + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml new file mode 100644 index 000000000..7bd78f52f --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs new file mode 100644 index 000000000..3fbb3eea4 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs @@ -0,0 +1,109 @@ +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using OpenClaw.SetupEngine; +using System.Diagnostics; +using System.Numerics; +using Windows.UI; + +namespace OpenClaw.SetupEngine.UI.Pages; + +public sealed partial class WelcomePage : Page +{ + private SetupConfig? _config; + + public WelcomePage() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + _config = e.Parameter as SetupConfig ?? new SetupConfig(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + var isDark = ActualTheme == ElementTheme.Dark; + InfoCard.Background = new SolidColorBrush(isDark + ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C) + : Color.FromArgb(255, 0xF0, 0xF0, 0xF0)); + + InfoText.Text = "This local setup installs a small WSL Linux instance dedicated to OpenClaw. " + + "If you'd rather connect to an existing or remote gateway, choose Advanced setup."; + + StartLobsterBreatheAnimation(); + } + + private void StartLobsterBreatheAnimation() + { + var visual = ElementCompositionPreview.GetElementVisual(LobsterHero); + var compositor = visual.Compositor; + var centerX = LobsterHero.ActualWidth > 0 ? LobsterHero.ActualWidth / 2 : LobsterHero.Width / 2; + var centerY = LobsterHero.ActualHeight > 0 ? LobsterHero.ActualHeight / 2 : LobsterHero.Height / 2; + visual.CenterPoint = new Vector3((float)centerX, (float)centerY, 0f); + + var pulse = compositor.CreateVector3KeyFrameAnimation(); + pulse.InsertKeyFrame(0f, new Vector3(1f, 1f, 1f)); + pulse.InsertKeyFrame(0.5f, new Vector3(1.025f, 1.025f, 1f)); + pulse.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f)); + pulse.Duration = TimeSpan.FromMilliseconds(4200); + pulse.IterationBehavior = AnimationIterationBehavior.Forever; + + visual.StartAnimation("Scale", pulse); + } + + private async void StartButton_Click(object sender, RoutedEventArgs e) + { + var dataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); + + var existing = ExistingConfigDetector.Detect(dataDir, _config!.DistroName); + var summary = ExistingConfigDetector.BuildReplacementSummary(existing); + + var dialog = new ContentDialog + { + Title = existing.HasLocalGateway || existing.HasDistro + ? "Replace existing WSL gateway?" + : "Install a new WSL gateway?", + Content = summary, + PrimaryButtonText = "Continue", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) + App.MainWindow?.NavigateToCapabilities(); + } + + private void AdvancedSetup_Click(object sender, RoutedEventArgs e) + { + // Launch tray app navigated to connection settings + var candidates = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray", "OpenClaw.Tray.WinUI.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "OpenClawTray", "OpenClaw.Tray.WinUI.exe"), + Path.Combine(AppContext.BaseDirectory, "..", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.exe"), + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "OpenClaw.Tray.WinUI", "bin", "x64", "Debug", "net10.0-windows10.0.22621.0", "win-x64", "OpenClaw.Tray.WinUI.exe"), + }; + + var trayPath = candidates.FirstOrDefault(File.Exists); + var args = "--page connection"; + + if (trayPath != null) + Process.Start(new ProcessStartInfo(trayPath, args) { UseShellExecute = true }); + else + { + try { Process.Start(new ProcessStartInfo("OpenClaw.Tray.WinUI.exe", args) { UseShellExecute = true }); } + catch { /* best effort */ } + } + + App.MainWindow?.Close(); + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml new file mode 100644 index 000000000..27c36b58f --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs new file mode 100644 index 000000000..03b912961 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -0,0 +1,653 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using OpenClaw.Connection; +using OpenClaw.Shared; +using Windows.ApplicationModel.DataTransfer; + +namespace OpenClaw.SetupEngine.UI.Pages; + +public sealed partial class WizardPage : Page +{ + private const int MaxWizardSteps = 50; + private const int MaxSameStepVisits = 3; + private SetupConfig? _config; + private OpenClawGatewayClient? _client; + private string _sessionId = ""; + private string _stepId = ""; + private string _stepType = ""; + private bool _sensitive; + private bool _errorState; + private int _wizardStepCount; + private readonly Dictionary _stepVisits = new(StringComparer.OrdinalIgnoreCase); + private readonly List _options = []; + + public WizardPage() + { + InitializeComponent(); + TextInput.TextChanged += (_, _) => UpdateContinueState(); + SecretInput.PasswordChanged += (_, _) => UpdateContinueState(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + _config = e.Parameter as SetupConfig ?? new SetupConfig(); + _ = StartWizardAsync(); + } + + protected override void OnNavigatedFrom(NavigationEventArgs e) + { + _ = DisconnectAsync(); + } + + private async Task StartWizardAsync() + { + try + { + _errorState = false; + await DisconnectAsync(); + _sessionId = ""; + _wizardStepCount = 0; + _stepVisits.Clear(); + SetBusy("Connecting to gateway..."); + _client = await ConnectClientAsync(); + SetBusy("Starting wizard..."); + var payload = await _client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); + await ApplyPayloadAsync(payload); + } + catch (Exception ex) + { + ShowError($"Gateway wizard failed: {ex.Message}"); + } + } + + private async Task ConnectClientAsync() + { + var config = _config!; + var dataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); + var registry = new GatewayRegistry(dataDir); + registry.Load(); + var record = registry.GetActive() ?? throw new InvalidOperationException("No active gateway record found."); + var identityPath = registry.GetIdentityDirectory(record.Id); + var token = DeviceIdentity.TryReadStoredDeviceToken(identityPath) + ?? record.SharedGatewayToken + ?? record.BootstrapToken + ?? throw new InvalidOperationException("No gateway credential found."); + + var client = new OpenClawGatewayClient(config.EffectiveGatewayUrl, token, logger: new UiGatewayLogger(), identityPath: identityPath) + { + UseV2Signature = true + }; + + var outcome = await WaitForConnectAsync(client, TimeSpan.FromSeconds(20)); + if (!outcome) + { + client.Dispose(); + throw new InvalidOperationException("Could not connect to the gateway."); + } + + return client; + } + + private static async Task WaitForConnectAsync(OpenClawGatewayClient client, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnStatusChanged(object? sender, ConnectionStatus status) + { + if (status == ConnectionStatus.Connected) + tcs.TrySetResult(true); + else if (status is ConnectionStatus.Error or ConnectionStatus.Disconnected) + tcs.TrySetResult(false); + } + + client.StatusChanged += OnStatusChanged; + try + { + await client.ConnectAsync(); + using var cts = new CancellationTokenSource(timeout); + await using var _ = cts.Token.Register(() => tcs.TrySetResult(false)); + return await tcs.Task; + } + finally + { + client.StatusChanged -= OnStatusChanged; + } + } + + private async Task ApplyPayloadAsync(JsonElement payload) + { + if (payload.TryGetProperty("sessionId", out var sid)) + _sessionId = sid.GetString() ?? _sessionId; + + if (payload.TryGetProperty("done", out var done) && done.ValueKind == JsonValueKind.True) + { + var error = payload.TryGetProperty("error", out var err) ? err.ToString() : ""; + if (!string.IsNullOrWhiteSpace(error) && !error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase)) + { + ShowError(error); + return; + } + + await DisconnectAsync(); + if (_config!.SkipPermissions) + App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); + else + App.MainWindow?.NavigateToPermissions(); + return; + } + + if (!payload.TryGetProperty("step", out var step)) + { + ShowError("Gateway wizard returned an invalid response."); + return; + } + + _stepId = step.TryGetProperty("id", out var id) ? id.ToString() : ""; + _stepType = step.TryGetProperty("type", out var type) ? type.ToString() : "note"; + var stepIndex = payload.TryGetProperty("stepIndex", out var indexProperty) && indexProperty.TryGetInt32(out var index) ? index : 0; + _sensitive = step.TryGetProperty("sensitive", out var sensitive) && sensitive.ValueKind == JsonValueKind.True; + var title = step.TryGetProperty("title", out var titleProp) ? titleProp.ToString() : ""; + var message = step.TryGetProperty("message", out var msgProp) ? msgProp.ToString() : ""; + var initial = step.TryGetProperty("initialValue", out var initialProp) ? initialProp : default; + + if (string.IsNullOrWhiteSpace(_stepId)) + { + ShowError("Gateway wizard step is missing an id."); + return; + } + + _wizardStepCount++; + if (_wizardStepCount > MaxWizardSteps) + { + ShowError($"Gateway wizard exceeded {MaxWizardSteps} steps."); + return; + } + + var visitKey = $"{_stepId}:{stepIndex}"; + _stepVisits.TryGetValue(visitKey, out var visits); + _stepVisits[visitKey] = visits + 1; + if (_stepVisits[visitKey] > MaxSameStepVisits) + { + ShowError($"Gateway wizard repeated step '{_stepId}' too many times."); + return; + } + + ResetInputs(); + TitleText.Text = string.IsNullOrWhiteSpace(title) ? DisplayTitleFor(_stepType) : title; + RenderMessage(message); + StepCard.MinHeight = _stepType == "note" && string.IsNullOrWhiteSpace(message) ? 140 : 260; + ErrorText.Visibility = Visibility.Collapsed; + BusyRing.Visibility = Visibility.Collapsed; + BusyRing.IsActive = false; + StatusText.Text = "Answer the gateway setup question"; + PrimaryButton.IsEnabled = !WizardSelection.RequiresAnswer(_stepType); + SecondaryButton.IsEnabled = true; + PrimaryButton.Content = _stepType == "confirm" ? "Yes" : "Continue"; + SecondaryButton.Content = "No"; + SecondaryButton.Visibility = _stepType == "confirm" ? Visibility.Visible : Visibility.Collapsed; + + if (!BuildOptions(step, initial)) + return; + + if (_stepType == "text") + { + if (_sensitive) + { + SecretInput.Visibility = Visibility.Visible; + SecretInput.Password = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + } + else + { + TextInput.Visibility = Visibility.Visible; + TextInput.Text = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : ""; + } + + UpdateContinueState(); + } + + if (_stepType == "note") + { + SecondaryButton.IsEnabled = false; + SecondaryButton.Visibility = Visibility.Collapsed; + } + } + + private bool BuildOptions(JsonElement step, JsonElement initial) + { + if (_stepType is not ("select" or "multiselect")) + return true; + + _options.Clear(); + if (step.TryGetProperty("options", out var options) && options.ValueKind == JsonValueKind.Array) + { + foreach (var option in options.EnumerateArray()) + { + var value = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("value", out var valueProp) + ? valueProp.ToString() + : option.ToString(); + var label = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("label", out var labelProp) + ? labelProp.ToString() + : value; + var hint = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("hint", out var hintProp) + ? hintProp.ToString() + : ""; + _options.Add(new(value, label, hint)); + } + } + + if (!WizardSelection.HasSelectableOptions(_stepType, _options.Select(o => o.Value).ToArray())) + { + ShowError("Gateway wizard returned a choice step without any selectable options."); + return false; + } + + if (_stepType == "select") + { + SelectOptions.Visibility = Visibility.Visible; + foreach (var option in _options) + { + SelectOptions.Children.Add(new RadioButton + { + Content = BuildOptionContent(option), + Tag = option.Value, + GroupName = $"wizard-step-{_stepId}", + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 0, 0, 2), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }); + } + + var initialValue = initial.ValueKind == JsonValueKind.String ? initial.GetString() : null; + var index = WizardSelection.SelectedIndex(initialValue, _options.Select(o => o.Value).ToArray()); + if (index >= 0 && index < SelectOptions.Children.Count && SelectOptions.Children[index] is RadioButton radio) + radio.IsChecked = true; + + foreach (var optionRadio in SelectOptions.Children.OfType()) + optionRadio.Checked += (_, _) => UpdateContinueState(); + + UpdateContinueState(); + } + else + { + MultiOptions.Visibility = Visibility.Visible; + var initialValues = initial.ValueKind == JsonValueKind.Array + ? initial.EnumerateArray().Select(v => v.ToString()).ToHashSet(StringComparer.Ordinal) + : []; + foreach (var option in _options) + { + var checkBox = new CheckBox + { + Content = BuildOptionContent(option), + Tag = option.Value, + IsChecked = initialValues.Contains(option.Value), + Padding = new Thickness(8, 6, 8, 6), + Margin = new Thickness(0, 0, 0, 2), + HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Stretch + }; + checkBox.Checked += (_, _) => UpdateContinueState(); + checkBox.Unchecked += (_, _) => UpdateContinueState(); + MultiOptions.Children.Add(checkBox); + } + + UpdateContinueState(); + } + + return true; + } + + private static FrameworkElement BuildOptionContent(WizardOption option) + { + var panel = new StackPanel + { + Spacing = 3, + Margin = new Thickness(2, 0, 0, 0), + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + panel.Children.Add(new TextBlock + { + Text = option.Label, + FontSize = 14, + TextWrapping = TextWrapping.Wrap + }); + + if (!string.IsNullOrWhiteSpace(option.Hint)) + { + panel.Children.Add(new TextBlock + { + Text = option.Hint, + FontSize = 12, + Foreground = ResourceBrush("TextFillColorSecondaryBrush"), + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.None + }); + } + + return panel; + } + + private static Brush ResourceBrush(string key) + { + return Application.Current.Resources.TryGetValue(key, out var brush) + && brush is Brush typedBrush + ? typedBrush + : new SolidColorBrush(Microsoft.UI.Colors.Gray); + } + + private async void Primary_Click(object sender, RoutedEventArgs e) + { + if (_errorState) + { + await StartWizardAsync(); + return; + } + + await SendCurrentAnswerAsync(skip: false); + } + + private async void Secondary_Click(object sender, RoutedEventArgs e) + { + if (_errorState) + { + await SkipWizardAsync(); + return; + } + + await SendCurrentAnswerAsync(skip: true); + } + + private async Task SendCurrentAnswerAsync(bool skip) + { + if (_client == null) return; + + try + { + object? answerValue = null; + if (!skip && !TryBuildAnswerValue(out answerValue)) + { + ErrorText.Text = _stepType == "multiselect" + ? "Choose at least one valid option." + : _stepType == "text" + ? "Enter a value to continue." + : "Choose a valid option."; + ErrorText.Visibility = Visibility.Visible; + UpdateContinueState(); + return; + } + + SetBusy(skip ? "Skipping..." : "Submitting..."); + object parameters; + if (skip) + { + parameters = _stepType == "confirm" + ? new { sessionId = _sessionId, answer = new { stepId = _stepId, value = false } } + : new { sessionId = _sessionId }; + } + else + { + parameters = new { sessionId = _sessionId, answer = new { stepId = _stepId, value = answerValue } }; + } + + var payload = await _client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutForCurrentStep()); + await ApplyPayloadAsync(payload); + } + catch (Exception ex) + { + ShowError(ex.Message); + } + } + + private bool TryBuildAnswerValue(out object value) + { + value = _stepType switch + { + "confirm" => true, + "select" => SelectOptions.Children.OfType() + .FirstOrDefault(r => r.IsChecked == true) + ?.Tag?.ToString() ?? "", + "multiselect" => MultiOptions.Children.OfType() + .Where(c => c.IsChecked == true) + .Select(c => c.Tag?.ToString() ?? "") + .Where(v => v.Length > 0) + .ToArray(), + "text" => _sensitive ? SecretInput.Password : TextInput.Text, + _ => "true" + }; + + if (!WizardSelection.RequiresAnswer(_stepType)) + return true; + + if (_stepType == "text") + return !WizardSelection.ShouldDisableContinue(_stepType, value?.ToString()); + + return !WizardSelection.ShouldDisableContinue(_stepType, GetSelectedOptionValues(), _options.Select(o => o.Value).ToArray()); + } + + private string[] GetSelectedOptionValues() + { + return _stepType switch + { + "select" => SelectOptions.Children.OfType() + .Where(r => r.IsChecked == true) + .Select(r => r.Tag?.ToString() ?? "") + .Where(v => v.Length > 0) + .ToArray(), + "multiselect" => MultiOptions.Children.OfType() + .Where(c => c.IsChecked == true) + .Select(c => c.Tag?.ToString() ?? "") + .Where(v => v.Length > 0) + .ToArray(), + _ => [] + }; + } + + private void UpdateContinueState() + { + if (_errorState || !WizardSelection.RequiresAnswer(_stepType)) + return; + + PrimaryButton.IsEnabled = _stepType == "text" + ? !WizardSelection.ShouldDisableContinue(_stepType, _sensitive ? SecretInput.Password : TextInput.Text) + : !WizardSelection.ShouldDisableContinue( + _stepType, + GetSelectedOptionValues(), + _options.Select(o => o.Value).ToArray()); + + if (PrimaryButton.IsEnabled) + ErrorText.Visibility = Visibility.Collapsed; + } + + private int TimeoutForCurrentStep() + { + var text = $"{TitleText.Text} {string.Join(' ', MessagePanel.Children.OfType().Select(t => t.Text))}"; + return text.Contains("device", StringComparison.OrdinalIgnoreCase) + || text.Contains("authorize", StringComparison.OrdinalIgnoreCase) + || text.Contains("login", StringComparison.OrdinalIgnoreCase) + || text.Contains("sign in", StringComparison.OrdinalIgnoreCase) + || text.Contains("oauth", StringComparison.OrdinalIgnoreCase) + ? 300_000 + : 30_000; + } + + private void ResetInputs() + { + SelectOptions.Children.Clear(); + SelectOptions.Visibility = Visibility.Collapsed; + MultiOptions.Children.Clear(); + MultiOptions.Visibility = Visibility.Collapsed; + TextInput.Visibility = Visibility.Collapsed; + SecretInput.Visibility = Visibility.Collapsed; + MessagePanel.Children.Clear(); + } + + private void RenderMessage(string message) + { + MessagePanel.Children.Clear(); + if (string.IsNullOrWhiteSpace(message)) + return; + + foreach (var line in message.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + var codeMatch = Regex.Match(trimmed, @"^((?:Code|code|user_code|USER_CODE)\s*[:=]\s*)([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b"); + if (codeMatch.Success) + { + MessagePanel.Children.Add(BuildCodeRow(codeMatch.Groups[1].Value, codeMatch.Groups[2].Value)); + continue; + } + + var urlMatch = Regex.Match(trimmed, @"https?://[^\s\)\""]+", RegexOptions.IgnoreCase); + if (urlMatch.Success && Uri.TryCreate(urlMatch.Value.TrimEnd('.', ','), UriKind.Absolute, out var uri)) + { + MessagePanel.Children.Add(BuildLinkLine(trimmed, urlMatch.Value, uri)); + continue; + } + + MessagePanel.Children.Add(new TextBlock + { + Text = trimmed, + FontSize = 14, + Opacity = 0.82, + TextWrapping = TextWrapping.Wrap, + IsTextSelectionEnabled = true + }); + } + } + + private static FrameworkElement BuildLinkLine(string line, string urlText, Uri uri) + { + var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + var prefix = line[..line.IndexOf(urlText, StringComparison.Ordinal)]; + if (!string.IsNullOrEmpty(prefix)) + panel.Children.Add(new TextBlock { Text = prefix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center }); + + var button = new HyperlinkButton + { + Content = urlText, + NavigateUri = uri, + Padding = new Thickness(0), + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center + }; + panel.Children.Add(button); + + var suffix = line[(line.IndexOf(urlText, StringComparison.Ordinal) + urlText.Length)..]; + if (!string.IsNullOrEmpty(suffix)) + panel.Children.Add(new TextBlock { Text = suffix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center }); + + return panel; + } + + private static FrameworkElement BuildCodeRow(string prefix, string code) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + new ColumnDefinition { Width = GridLength.Auto }, + }, + ColumnSpacing = 10 + }; + + var label = new TextBlock { Text = prefix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center }; + var codeText = new TextBlock + { + Text = code, + FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"), + FontSize = 18, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + IsTextSelectionEnabled = true, + VerticalAlignment = VerticalAlignment.Center + }; + var copy = new Button { Content = "Copy", Padding = new Thickness(8, 4, 8, 4) }; + copy.Click += (_, _) => + { + var package = new DataPackage(); + package.SetText(code); + Clipboard.SetContent(package); + }; + + Grid.SetColumn(label, 0); + Grid.SetColumn(codeText, 1); + Grid.SetColumn(copy, 2); + grid.Children.Add(label); + grid.Children.Add(codeText); + grid.Children.Add(copy); + return grid; + } + + private void SetBusy(string status) + { + StatusText.Text = status; + BusyRing.Visibility = Visibility.Visible; + BusyRing.IsActive = true; + PrimaryButton.IsEnabled = false; + SecondaryButton.IsEnabled = false; + } + + private void ShowError(string message) + { + _errorState = true; + BusyRing.Visibility = Visibility.Collapsed; + BusyRing.IsActive = false; + StatusText.Text = "Wizard needs attention"; + ErrorText.Text = message; + ErrorText.Visibility = Visibility.Visible; + PrimaryButton.Content = "Retry"; + PrimaryButton.IsEnabled = true; + SecondaryButton.Content = "Skip wizard"; + SecondaryButton.IsEnabled = true; + SecondaryButton.Visibility = Visibility.Visible; + } + + private async Task SkipWizardAsync() + { + if (_client != null && !string.IsNullOrWhiteSpace(_sessionId)) + { + try { await _client.SendWizardRequestAsync("wizard.cancel", new { sessionId = _sessionId }, timeoutMs: 10_000); } + catch { } + } + + await DisconnectAsync(); + if (_config!.SkipPermissions) + App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); + else + App.MainWindow?.NavigateToPermissions(); + } + + private async Task DisconnectAsync() + { + if (_client == null) return; + try { await _client.DisconnectAsync(); } catch { } + _client.Dispose(); + _client = null; + } + + private static string DisplayTitleFor(string stepType) => stepType switch + { + "confirm" => "Confirm", + "select" => "Choose an option", + "multiselect" => "Choose options", + "text" => "Enter value", + _ => "Setup" + }; + + private sealed record WizardOption(string Value, string Label, string Hint); + + private sealed class UiGatewayLogger : IOpenClawLogger + { + public void Info(string message) { } + public void Debug(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? ex = null) { } + } +} diff --git a/src/OpenClaw.SetupEngine.UI/Program.cs b/src/OpenClaw.SetupEngine.UI/Program.cs new file mode 100644 index 000000000..194936269 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Program.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace OpenClaw.SetupEngine.UI; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + // Headless mode: bypass UI entirely, run pipeline directly + if (Array.Exists(args, a => a.Equals("--headless", StringComparison.OrdinalIgnoreCase))) + { + return OpenClaw.SetupEngine.Program.Main(args).GetAwaiter().GetResult(); + } + + WinRT.ComWrappersSupport.InitializeComWrappers(); + Application.Start(p => + { + var dispatcher = DispatcherQueue.GetForCurrentThread(); + var context = new DispatcherQueueSynchronizationContext(dispatcher); + SynchronizationContext.SetSynchronizationContext(context); + new App(); + }); + return 0; + } +} diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml new file mode 100644 index 000000000..866956cbd --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs new file mode 100644 index 000000000..8451f59e8 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -0,0 +1,117 @@ +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using OpenClaw.SetupEngine.UI.Pages; +using System.Runtime.InteropServices; + +namespace OpenClaw.SetupEngine.UI; + +public sealed partial class SetupWindow : Window +{ + private SetupConfig _config = null!; + private SetupRunLock? _setupLock; + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hwnd); + + public SetupWindow() + { + InitializeComponent(); + + // Size window accounting for DPI + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var dpi = GetDpiForWindow(hwnd); + var scale = dpi / 96.0; + AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(720 * scale), (int)(820 * scale))); + + // Extend into title bar for modern look + ExtendsContentIntoTitleBar = true; + SetTitleBar(TitleBarDrag); + + // Mica backdrop + SystemBackdrop = new MicaBackdrop(); + + // Load config: explicit --config arg, or bundled default-config.json (required) + var args = Environment.GetCommandLineArgs(); + var configPath = GetArg(args, "--config"); + if (configPath == null) + { + var defaultPath = Path.Combine(AppContext.BaseDirectory, "default-config.json"); + if (File.Exists(defaultPath)) + configPath = defaultPath; + } + + if (configPath == null || !File.Exists(configPath)) + { + // Cannot run without config + Console.Error.WriteLine("ERROR: No config file found. Place default-config.json next to the exe or pass --config ."); + Environment.Exit(1); + return; + } + + _config = SetupConfig.LoadFromFile(configPath); + _config = SetupConfig.FromEnvironment(_config); + _config.ApplyUiDefaults(rollbackOnFailure: !HasFlag(args, "--no-rollback-on-failure")); + + Closed += (_, _) => + { + _setupLock?.Dispose(); + _setupLock = null; + }; + + if (!SetupRunLock.TryAcquire(SetupContext.ResolveDataDir(), out _setupLock, out var lockMessage)) + { + RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(false, TimeSpan.Zero, null, lockMessage ?? "Another setup run is active.")); + return; + } + + RootFrame.Navigate(typeof(WelcomePage), _config); + } + + public void NavigateToCapabilities() => RootFrame.Navigate(typeof(CapabilitiesPage), _config); + public void NavigateToProgress() => RootFrame.Navigate(typeof(ProgressPage), _config); + public void NavigateToWizard() => RootFrame.Navigate(typeof(WizardPage), _config); + public void NavigateToPermissions() => RootFrame.Navigate(typeof(PermissionsPage), _config); + public void NavigateToComplete(bool success, TimeSpan elapsed, string? logPath, string? errorMessage = null) + => RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(success, elapsed, logPath, errorMessage)); + + public void BringToFrontForSetupLaunch() + { + Activate(); + + if (AppWindow.Presenter is not OverlappedPresenter presenter) + return; + + if (presenter.State == OverlappedPresenterState.Minimized) + presenter.Restore(); + + var wasAlwaysOnTop = presenter.IsAlwaysOnTop; + presenter.IsAlwaysOnTop = true; + Activate(); + + var timer = DispatcherQueue.CreateTimer(); + timer.Interval = TimeSpan.FromMilliseconds(750); + timer.Tick += (_, _) => + { + timer.Stop(); + if (!wasAlwaysOnTop && AppWindow.Presenter is OverlappedPresenter p) + p.IsAlwaysOnTop = false; + }; + timer.Start(); + } + + private static string? GetArg(string[] args, string name) + { + for (int i = 0; i < args.Length - 1; i++) + if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + return null; + } + + private static bool HasFlag(string[] args, string name) + => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); +} + +public sealed record CompletePageArgs(bool Success, TimeSpan Elapsed, string? LogPath, string? ErrorMessage = null); diff --git a/src/OpenClaw.SetupEngine.UI/app.manifest b/src/OpenClaw.SetupEngine.UI/app.manifest new file mode 100644 index 000000000..cf56a1a34 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/app.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + + + true/pm + PerMonitorV2 + + + + + + + + diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs new file mode 100644 index 000000000..f8ce94a38 --- /dev/null +++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs @@ -0,0 +1,160 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OpenClaw.SetupEngine; + +internal enum ApprovalRequestKind +{ + Device, + Node +} + +internal static partial class ApprovalRequestHelper +{ + internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID"; + + internal static bool IsSafeRequestId(string? requestId) + => !string.IsNullOrWhiteSpace(requestId) + && SafeRequestIdPattern().IsMatch(requestId.Trim()); + + internal static string ApprovalCommand(ApprovalRequestKind kind) + => $"openclaw {Noun(kind)} approve \"${RequestIdEnvironmentVariable}\" --json"; + + internal static Dictionary AddRequestIdEnvironment( + IReadOnlyDictionary environment, + string requestId) + { + if (!IsSafeRequestId(requestId)) + throw new ArgumentException("Unsafe approval request ID.", nameof(requestId)); + + var result = new Dictionary(environment) + { + [RequestIdEnvironmentVariable] = requestId.Trim() + }; + return result; + } + + internal static RequestIdParseResult TryReadSelectedRequestId(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return RequestIdParseResult.NotFound("Approval output was empty."); + + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("selected", out var selected) || + selected.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return RequestIdParseResult.NotFound("Approval output did not include a selected request."); + } + + return TryReadRequestId(selected); + } + catch (JsonException ex) + { + return RequestIdParseResult.NotFound($"Approval output was not valid JSON: {ex.Message}"); + } + } + + internal static RequestIdParseResult TryReadApprovedRequestId(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return RequestIdParseResult.NotFound("Approval output was empty."); + + try + { + using var doc = JsonDocument.Parse(json); + return TryReadRequestId(doc.RootElement); + } + catch (JsonException ex) + { + return RequestIdParseResult.NotFound($"Approval output was not valid JSON: {ex.Message}"); + } + } + + internal static RequestIdParseResult TryReadSinglePendingRequestId(string json) + { + var all = TryReadPendingRequestIds(json); + if (!all.Success) + return RequestIdParseResult.NotFound(all.Error ?? "Could not read pending approval requests."); + + return all.RequestIds.Count switch + { + 0 => RequestIdParseResult.NotFound("No pending approval request was found."), + 1 => RequestIdParseResult.Found(all.RequestIds[0]), + _ => RequestIdParseResult.NotFound("Multiple pending approval requests were found; refusing to auto-approve an ambiguous request.") + }; + } + + internal static PendingRequestIdsParseResult TryReadPendingRequestIds(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return PendingRequestIdsParseResult.Fail("Pending approval output was empty."); + + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("pending", out var pending) || + pending.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return PendingRequestIdsParseResult.SuccessResult([]); + } + + if (pending.ValueKind != JsonValueKind.Array) + return PendingRequestIdsParseResult.Fail("Pending approval output did not contain an array."); + + var requestIds = new List(); + foreach (var item in pending.EnumerateArray()) + { + var parsed = TryReadRequestId(item); + if (!parsed.Success) + return PendingRequestIdsParseResult.Fail(parsed.Error ?? "Pending approval request did not include a safe request ID."); + + requestIds.Add(parsed.RequestId!); + } + + return PendingRequestIdsParseResult.SuccessResult(requestIds); + } + catch (JsonException ex) + { + return PendingRequestIdsParseResult.Fail($"Pending approval output was not valid JSON: {ex.Message}"); + } + } + + private static RequestIdParseResult TryReadRequestId(JsonElement element) + { + if (!element.TryGetProperty("requestId", out var requestIdElement) || + requestIdElement.ValueKind != JsonValueKind.String) + { + return RequestIdParseResult.NotFound("Approval request did not include requestId."); + } + + var requestId = requestIdElement.GetString()?.Trim(); + return IsSafeRequestId(requestId) + ? RequestIdParseResult.Found(requestId!) + : RequestIdParseResult.NotFound("Approval request ID contained unsafe characters."); + } + + private static string Noun(ApprovalRequestKind kind) + => kind switch + { + ApprovalRequestKind.Device => "devices", + ApprovalRequestKind.Node => "nodes", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + + [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled)] + private static partial Regex SafeRequestIdPattern(); +} + +internal sealed record RequestIdParseResult(bool Success, string? RequestId, string? Error) +{ + public static RequestIdParseResult Found(string requestId) => new(true, requestId, null); + public static RequestIdParseResult NotFound(string error) => new(false, null, error); +} + +internal sealed record PendingRequestIdsParseResult(bool Success, IReadOnlyList RequestIds, string? Error) +{ + public static PendingRequestIdsParseResult SuccessResult(IReadOnlyList requestIds) => new(true, requestIds, null); + public static PendingRequestIdsParseResult Fail(string error) => new(false, [], error); +} diff --git a/src/OpenClaw.SetupEngine/AssemblyInfo.cs b/src/OpenClaw.SetupEngine/AssemblyInfo.cs new file mode 100644 index 000000000..12ce8d9ad --- /dev/null +++ b/src/OpenClaw.SetupEngine/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenClaw.SetupEngine.Tests")] diff --git a/src/OpenClaw.SetupEngine/AtomicFile.cs b/src/OpenClaw.SetupEngine/AtomicFile.cs new file mode 100644 index 000000000..169be185c --- /dev/null +++ b/src/OpenClaw.SetupEngine/AtomicFile.cs @@ -0,0 +1,58 @@ +namespace OpenClaw.SetupEngine; + +internal static class AtomicFile +{ + public static void WriteAllText(string path, string contents) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + var tempPath = Path.Combine( + directory ?? Directory.GetCurrentDirectory(), + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + + try + { + File.WriteAllText(tempPath, contents); + File.Move(tempPath, path, overwrite: true); + } + finally + { + TryDeleteTemp(tempPath); + } + } + + public static async Task WriteAllTextAsync(string path, string contents, CancellationToken ct = default) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + var tempPath = Path.Combine( + directory ?? Directory.GetCurrentDirectory(), + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(tempPath, contents, ct); + File.Move(tempPath, path, overwrite: true); + } + finally + { + TryDeleteTemp(tempPath); + } + } + + private static void TryDeleteTemp(string tempPath) + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + } + } +} diff --git a/src/OpenClaw.SetupEngine/CommandRunner.cs b/src/OpenClaw.SetupEngine/CommandRunner.cs new file mode 100644 index 000000000..b3818a35d --- /dev/null +++ b/src/OpenClaw.SetupEngine/CommandRunner.cs @@ -0,0 +1,220 @@ +using System.Diagnostics; +using System.Text; + +namespace OpenClaw.SetupEngine; + +// ─── Command Runner ─── + +public sealed record CommandResult(int ExitCode, string Stdout, string Stderr, TimeSpan Elapsed, bool TimedOut); + +public interface ICommandRunner +{ + Task RunAsync( + string executable, + string[] arguments, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + string? workingDirectory = null, + string? stdinInput = null, + CancellationToken ct = default); + + Task RunInWslAsync( + string distroName, + string command, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + CancellationToken ct = default, + string? user = null); +} + +public sealed class CommandRunner : ICommandRunner +{ + private readonly SetupLogger _logger; + private const int DrainTimeoutMs = 5000; // bounded drain for orphan WSL processes + private const int MaxCapturedStreamChars = 1_048_576; + + public CommandRunner(SetupLogger logger) => _logger = logger; + + /// + /// Run a process and capture output. Timeout kills the process. + /// + public async Task RunAsync( + string executable, + string[] arguments, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + string? workingDirectory = null, + string? stdinInput = null, + CancellationToken ct = default) + { + _logger.CommandStarted(executable, arguments, timeout); + var sw = Stopwatch.StartNew(); + + var psi = new ProcessStartInfo + { + FileName = executable, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdinInput != null, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? "" + }; + + foreach (var arg in arguments) + psi.ArgumentList.Add(arg); + + if (environment != null) + { + foreach (var (key, value) in environment) + psi.Environment[key] = value; + } + + using var process = new Process { StartInfo = psi }; + var stdout = new BoundedOutputBuffer(MaxCapturedStreamChars); + var stderr = new BoundedOutputBuffer(MaxCapturedStreamChars); + var timedOut = false; + + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + try + { + process.Start(); + } + catch (System.ComponentModel.Win32Exception ex) + { + sw.Stop(); + var errorResult = new CommandResult(-1, "", $"Failed to start process '{executable}': {ex.Message}", sw.Elapsed, false); + _logger.CommandCompleted(executable, errorResult, sw.Elapsed); + return errorResult; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (stdinInput != null) + { + await process.StandardInput.WriteAsync(stdinInput); + process.StandardInput.Close(); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + // Timeout (not user cancellation) + timedOut = true; + TryKill(process); + } + catch (OperationCanceledException) + { + // User cancelled + TryKill(process); + throw; + } + + // Flush async output handlers. WaitForExitAsync observes process exit, but the + // OutputDataReceived/ErrorDataReceived callbacks can still be draining. + if (!timedOut) + process.WaitForExit(DrainTimeoutMs); + + sw.Stop(); + var result = new CommandResult( + timedOut ? -1 : process.ExitCode, + stdout.ToString(), + stderr.ToString(), + sw.Elapsed, + timedOut); + + _logger.CommandCompleted(executable, result, sw.Elapsed); + return result; + } + + /// + /// Run a command inside a WSL distro. + /// + public Task RunInWslAsync( + string distroName, + string command, + TimeSpan timeout, + IReadOnlyDictionary? environment = null, + CancellationToken ct = default, + string? user = null) + { + // Strip Windows \r to avoid bash "$'\r': command not found" errors + command = command.Replace("\r", ""); + + // Build wsl.exe arguments: -d [-u ] -- + var args = new List { "-d", distroName }; + if (!string.IsNullOrWhiteSpace(user)) + { + args.Add("-u"); + args.Add(user); + } + + args.AddRange(["--", "bash", "-c", command]); + + // Pass WSL environment variables via WSLENV + Dictionary? env = null; + if (environment is { Count: > 0 }) + { + env = new Dictionary(environment); + var wslEnvKeys = string.Join(":", environment.Keys); + env["WSLENV"] = env.TryGetValue("WSLENV", out var existing) + ? $"{existing}:{wslEnvKeys}" + : wslEnvKeys; + } + + return RunAsync("wsl.exe", args.ToArray(), timeout, env, ct: ct); + } + + private static void TryKill(Process process) + { + try { process.Kill(entireProcessTree: true); } catch { /* best effort */ } + } + + private sealed class BoundedOutputBuffer(int maxChars) + { + private readonly StringBuilder _builder = new(); + private readonly object _lock = new(); + private int _droppedChars; + + public void AppendLine(string line) + { + lock (_lock) + { + if (_builder.Length < maxChars) + { + var remaining = maxChars - _builder.Length; + if (line.Length + Environment.NewLine.Length <= remaining) + { + _builder.AppendLine(line); + return; + } + + if (remaining > 0) + _builder.Append(line[..Math.Min(line.Length, remaining)]); + } + + _droppedChars += line.Length + Environment.NewLine.Length; + } + } + + public override string ToString() + { + lock (_lock) + { + if (_droppedChars == 0) + return _builder.ToString(); + + return _builder.ToString() + Environment.NewLine + $"... [truncated {_droppedChars} chars]"; + } + } + } +} diff --git a/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs new file mode 100644 index 000000000..9332beb15 --- /dev/null +++ b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs @@ -0,0 +1,105 @@ +using OpenClaw.Connection; + +namespace OpenClaw.SetupEngine; + +/// +/// Detects existing local gateway configuration to show accurate replacement summaries. +/// +public sealed class ExistingConfigDetector +{ + public sealed record ExistingConfig( + bool HasLocalGateway, + string? LocalGatewayId, + string? LocalGatewayUrl, + bool HasDistro, + string? DistroName, + bool HasIdentityFiles, + int PreservedGatewayCount, + IReadOnlyList PreservedGatewayNames); + + /// + /// Detect existing local configuration by checking the gateway registry and WSL distros. + /// + public static ExistingConfig Detect(string dataDir, string targetDistroName) + { + var registry = new GatewayRegistry(dataDir); + registry.Load(); + var all = registry.GetAll(); + + var localRecord = all.FirstOrDefault(r => r.IsLocal && r.SshTunnel == null); + var preserved = all.Where(r => !r.IsLocal || r.SshTunnel != null).ToList(); + + var hasDistro = false; + try + { + var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "--list --quiet") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var proc = System.Diagnostics.Process.Start(psi); + if (proc != null) + { + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(5000); + var distros = output.Replace("\0", string.Empty) + .Replace("\uFEFF", string.Empty) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim()) + .Where(d => d.Length > 0); + hasDistro = distros.Any(d => d.Equals(targetDistroName, StringComparison.OrdinalIgnoreCase)); + } + } + catch + { + // WSL not available. + } + + var hasIdentity = false; + if (localRecord != null) + { + var identityDir = registry.GetIdentityDirectory(localRecord.Id); + hasIdentity = Directory.Exists(identityDir) && Directory.EnumerateFiles(identityDir).Any(); + } + + return new ExistingConfig( + HasLocalGateway: localRecord != null, + LocalGatewayId: localRecord?.Id, + LocalGatewayUrl: localRecord?.Url, + HasDistro: hasDistro, + DistroName: hasDistro ? targetDistroName : null, + HasIdentityFiles: hasIdentity, + PreservedGatewayCount: preserved.Count, + PreservedGatewayNames: preserved.Select(r => r.FriendlyName ?? r.Url).ToList()); + } + + /// + /// Build a human-readable summary of what will happen during setup. + /// + public static string BuildReplacementSummary(ExistingConfig config) + { + if (!config.HasLocalGateway && !config.HasDistro) + return "A new local WSL gateway will be created. No existing configuration will be affected."; + + var lines = new List(); + + if (config.HasDistro) + lines.Add($"• WSL distro '{config.DistroName}' will be deleted and recreated"); + if (config.HasLocalGateway) + lines.Add("• Local gateway record will be replaced"); + if (config.HasIdentityFiles) + lines.Add("• Device identity files for the local gateway will be regenerated"); + + if (config.PreservedGatewayCount > 0) + { + lines.Add(string.Empty); + lines.Add($"The following {config.PreservedGatewayCount} gateway(s) will NOT be affected:"); + foreach (var name in config.PreservedGatewayNames) + lines.Add($" • {name}"); + } + + return string.Join("\n", lines); + } +} diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj new file mode 100644 index 000000000..7e339fdd1 --- /dev/null +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + OpenClaw.SetupEngine + win-x64;win-arm64 + + + + + + + diff --git a/src/OpenClaw.SetupEngine/Program.cs b/src/OpenClaw.SetupEngine/Program.cs new file mode 100644 index 000000000..6323defec --- /dev/null +++ b/src/OpenClaw.SetupEngine/Program.cs @@ -0,0 +1,216 @@ +namespace OpenClaw.SetupEngine; + +public static class Program +{ + public static async Task Main(string[] args) + { + Console.WriteLine("OpenClaw Setup Engine v0.1"); + Console.WriteLine("─────────────────────────────"); + + // Parse CLI arguments + var configPath = GetArg(args, "--config"); + var logPath = GetArg(args, "--log-path"); + var headless = HasFlag(args, "--headless"); + var rollback = HasFlag(args, "--rollback-on-failure"); + var noRollback = HasFlag(args, "--no-rollback-on-failure"); + var dryRun = HasFlag(args, "--dry-run"); + var wizardOnly = HasFlag(args, "--wizard-only"); + var uninstall = HasFlag(args, "--uninstall"); + var confirmDestructive = HasFlag(args, "--confirm-destructive"); + var jsonOutput = GetArg(args, "--json-output"); + var preserveLogs = HasFlag(args, "--preserve-logs"); + + // Load config + SetupConfig config; + if (configPath != null && File.Exists(configPath)) + { + Console.WriteLine($"Loading config from: {configPath}"); + config = SetupConfig.LoadFromFile(configPath); + } + else + { + // Look for default-config.json next to the exe + var defaultPath = Path.Combine(AppContext.BaseDirectory, "default-config.json"); + if (File.Exists(defaultPath)) + { + Console.WriteLine($"Loading config from: {defaultPath}"); + config = SetupConfig.LoadFromFile(defaultPath); + } + else + { + Console.Error.WriteLine("ERROR: No config file found. Provide --config or place default-config.json next to the exe."); + return 1; + } + } + + // Apply CLI overrides + config = SetupConfig.FromEnvironment(config); + if (headless) config.Headless = true; + if (rollback) config.RollbackOnFailure = true; + if (noRollback) config.RollbackOnFailure = false; + if (wizardOnly) config.SkipWizard = false; + if (logPath != null) config.LogPath = logPath; + if (dryRun) config.DryRun = true; + if (confirmDestructive) config.ConfirmDestructive = true; + + // Default log path if not specified + var logLabel = uninstall ? "uninstall" : "setup"; + config.LogPath ??= Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray", "Logs", "Setup", $"{logLabel}-engine-{DateTime.UtcNow:yyyyMMdd-HHmmss}.jsonl"); + + Console.WriteLine($"Log file: {config.LogPath}"); + Console.WriteLine($"Distro: {config.DistroName}"); + Console.WriteLine($"Gateway: {config.EffectiveGatewayUrl}"); + Console.WriteLine($"Mode: {(uninstall ? "UNINSTALL" : "SETUP")}"); + if (uninstall) + { + Console.WriteLine($"Dry run: {config.DryRun}"); + Console.WriteLine($"Confirm destructive: {config.ConfirmDestructive}"); + } + Console.WriteLine(); + + if (dryRun && !uninstall) + { + Console.WriteLine("DRY RUN — config validated, exiting."); + return 0; + } + + // Uninstall safety gate + if (uninstall && !confirmDestructive && !dryRun) + { + Console.Error.WriteLine("ERROR: --uninstall requires --confirm-destructive (or use --dry-run to preview)."); + return 2; + } + + if (!SetupRunLock.TryAcquire(SetupContext.ResolveDataDir(), out var setupLock, out var lockMessage)) + { + Console.Error.WriteLine($"ERROR: {lockMessage}"); + return 2; + } + + using var acquiredSetupLock = setupLock; + + // Create infrastructure after acquiring the run lock so a concurrent loser + // cannot truncate the active run's log or journal files. + using var logger = new SetupLogger(config.LogPath, Enum.TryParse(config.LogLevel, true, out var lvl) ? lvl : LogLevel.Trace); + var journalPath = Path.ChangeExtension(config.LogPath, ".journal.jsonl"); + using var journal = new TransactionJournal(journalPath); + var commands = new CommandRunner(logger); + using var cts = new CancellationTokenSource(); + + // Handle Ctrl+C gracefully + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + logger.Warn("Cancellation requested (Ctrl+C)"); + cts.Cancel(); + }; + + var ctx = new SetupContext(config, logger, journal, commands, cts.Token); + + // Build step pipeline + List steps; + if (uninstall) + { + steps = SetupStepFactory.BuildDefaultSteps(); + } + else if (wizardOnly) + { + steps = [new RunGatewayWizardStep()]; + } + else + { + steps = BuildSteps(config); + } + + var pipeline = new SetupPipeline(steps); + + pipeline.StepProgress += (_, e) => + { + var icon = e.Outcome switch + { + StepOutcome.Success => "✓", + StepOutcome.Skipped => "⊘", + StepOutcome.Failed or StepOutcome.FailedTerminal => "✗", + null => "►", + _ => "?" + }; + var elapsed = e.Elapsed.HasValue ? $" ({e.Elapsed.Value.TotalSeconds:F1}s)" : ""; + Console.WriteLine($" {icon} {e.DisplayName}{elapsed}"); + }; + + // Run! + logger.Info($"{(uninstall ? "Uninstall" : "Setup")} engine starting", new { version = "0.1", args = string.Join(' ', args) }); + + PipelineResult result; + if (uninstall) + { + result = await pipeline.UninstallAsync(ctx); + + // Post-rollback tray-artifact cleanup (autostart, run.marker, settings, logs) + if (result.Outcome == PipelineOutcome.Success || result.Outcome == PipelineOutcome.Failed || result.Outcome == PipelineOutcome.Cancelled) + { + if (!config.DryRun) + { + logger.Info("Running tray-artifact cleanup..."); + TrayArtifactCleanup.Run(ctx, preserveLogs); + } + } + } + else + { + result = await pipeline.RunAsync(ctx); + } + + Console.WriteLine(); + var label = uninstall ? "UNINSTALL" : "SETUP"; + Console.WriteLine(result.Outcome switch + { + PipelineOutcome.Success => $"═══ {label} COMPLETE ═══", + PipelineOutcome.Failed => $"═══ {label} FAILED ═══\n {result.Message}", + PipelineOutcome.Cancelled => $"═══ {label} CANCELLED ═══", + _ => "═══ UNKNOWN STATE ═══" + }); + + Console.WriteLine($"\nLog: {config.LogPath}"); + Console.WriteLine($"Journal: {journalPath}"); + + // Write JSON output if requested (for programmatic callers like CliUninstallHandler) + if (jsonOutput != null) + { + var outputDir = Path.GetDirectoryName(jsonOutput); + if (outputDir != null) Directory.CreateDirectory(outputDir); + + var jsonResult = new + { + outcome = result.Outcome.ToString(), + exitCode = result.ExitCode, + failedStepId = result.FailedStepId, + message = result.Message, + logPath = config.LogPath, + journalPath + }; + var json = System.Text.Json.JsonSerializer.Serialize(jsonResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await AtomicFile.WriteAllTextAsync(jsonOutput, json); + } + + return result.ExitCode; + } + + private static List BuildSteps(SetupConfig config) + => SetupStepFactory.BuildDefaultSteps(); + + private static string? GetArg(string[] args, string name) + { + for (int i = 0; i < args.Length - 1; i++) + { + if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + return null; + } + + private static bool HasFlag(string[] args, string name) + => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/OpenClaw.SetupEngine/RetryExecutor.cs b/src/OpenClaw.SetupEngine/RetryExecutor.cs new file mode 100644 index 000000000..36c1a7f14 --- /dev/null +++ b/src/OpenClaw.SetupEngine/RetryExecutor.cs @@ -0,0 +1,59 @@ +namespace OpenClaw.SetupEngine; + +// ─── Retry Executor ─── + +public sealed record RetryPolicy(int MaxAttempts = 3, TimeSpan? InitialDelay = null, double BackoffMultiplier = 2.0, TimeSpan? MaxDelay = null) +{ + public static readonly RetryPolicy Default = new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(2)); + public static readonly RetryPolicy None = new(MaxAttempts: 1); + + public TimeSpan EffectiveInitialDelay => InitialDelay ?? TimeSpan.FromSeconds(2); + public TimeSpan EffectiveMaxDelay => MaxDelay ?? TimeSpan.FromSeconds(30); +} + +public static class RetryExecutor +{ + public static async Task ExecuteWithRetry( + Func> action, + RetryPolicy policy, + SetupLogger logger, + string stepId, + CancellationToken ct) + { + var delay = policy.EffectiveInitialDelay; + StepResult? lastResult = null; + + for (int attempt = 1; attempt <= policy.MaxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + + try + { + lastResult = await action(); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // propagate cancellation + } + catch (Exception ex) + { + logger.Error($"Step '{stepId}' threw exception (attempt {attempt}/{policy.MaxAttempts}): {ex.Message}"); + lastResult = StepResult.Fail($"Unhandled exception: {ex.Message}", ex); + } + + if (lastResult.IsSuccess || lastResult.Outcome == StepOutcome.FailedTerminal) + return lastResult; + + if (attempt < policy.MaxAttempts) + { + logger.Warn($"Step '{stepId}' failed (attempt {attempt}/{policy.MaxAttempts}), retrying in {delay.TotalSeconds:F0}s: {lastResult.Message}"); + await Task.Delay(delay, ct); + delay = TimeSpan.FromMilliseconds(Math.Min( + delay.TotalMilliseconds * policy.BackoffMultiplier, + policy.EffectiveMaxDelay.TotalMilliseconds)); + } + } + + return lastResult ?? StepResult.Fail("Retry exhausted with no result"); + } +} diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs new file mode 100644 index 000000000..0accdd230 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -0,0 +1,308 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.SetupEngine; + +// ─── Configuration ─── + +public sealed class SetupConfig +{ + public string DistroName { get; set; } = "OpenClawGateway"; + public int GatewayPort { get; set; } = 18789; + public string BaseDistro { get; set; } = "Ubuntu-24.04"; + public bool SkipPermissions { get; set; } + public bool SkipWizard { get; set; } + public bool Headless { get; set; } + public bool AutoApprovePairing { get; set; } + public bool RollbackOnFailure { get; set; } + public int RollbackTimeoutSeconds { get; set; } = 60; + public bool CleanBeforeRun { get; set; } + public bool DryRun { get; set; } + public bool ConfirmDestructive { get; set; } + public string LogLevel { get; set; } = "trace"; + public string? LogPath { get; set; } + public string? GatewayUrl { get; set; } + public string? BootstrapToken { get; set; } + public Dictionary? WizardAnswers { get; set; } + + // Nested config sections — everything is configurable + public WslConfig Wsl { get; set; } = new(); + public GatewayConfig Gateway { get; set; } = new(); + public CapabilitiesConfig Capabilities { get; set; } = new(); + public TraySettingsConfig Settings { get; set; } = new(); + public PairingConfig Pairing { get; set; } = new(); + + public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://localhost:{GatewayPort}"; + + public static SetupConfig LoadFromFile(string path) + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new SetupConfig(); + } + + public static SetupConfig FromEnvironment(SetupConfig? baseConfig = null) + { + var config = baseConfig ?? new SetupConfig(); + + if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_DISTRO") is { Length: > 0 } distro) + config.DistroName = distro; + if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_PORT") is { Length: > 0 } port && int.TryParse(port, out var p)) + config.GatewayPort = p; + if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_HEADLESS") is "1" or "true") + config.Headless = true; + if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_LOG_PATH") is { Length: > 0 } logPath) + config.LogPath = logPath; + + return config; + } + + public SetupConfig ApplyUiDefaults(bool rollbackOnFailure = true) + { + Headless = false; + RollbackOnFailure = rollbackOnFailure; + return this; + } + + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; +} + +// ─── WSL Configuration ─── + +public sealed class WslConfig +{ + private static readonly System.Text.RegularExpressions.Regex s_linuxUserNamePattern = + new("^[a-z_][a-z0-9_-]{0,31}$", System.Text.RegularExpressions.RegexOptions.Compiled); + + public string User { get; set; } = "openclaw"; + public bool Systemd { get; set; } = true; + public bool Interop { get; set; } = false; + public bool AppendWindowsPath { get; set; } = false; + public bool Automount { get; set; } = false; + public bool MountFsTab { get; set; } = false; + public bool UseWindowsTimezone { get; set; } = true; + public string? Memory { get; set; } + public string? Swap { get; set; } + + public static bool IsValidLinuxUserName(string value) + => s_linuxUserNamePattern.IsMatch(value); +} + +// ─── Gateway Configuration ─── + +public sealed class GatewayConfig +{ + public string Bind { get; set; } = "loopback"; + public string? InstallUrl { get; set; } + public string? Version { get; set; } + public int HealthTimeoutSeconds { get; set; } = 90; + public string ReloadMode { get; set; } = "hot"; + public string AuthMode { get; set; } = "token"; + public Dictionary? ExtraConfig { get; set; } +} + +// ─── Capabilities Configuration ─── + +public sealed class CapabilitiesConfig +{ + public bool System { get; set; } = true; + public bool Canvas { get; set; } = true; + public bool Screen { get; set; } = true; + public bool Camera { get; set; } = true; + public bool Location { get; set; } = true; + public bool Browser { get; set; } = true; + public bool Device { get; set; } = true; + public bool Tts { get; set; } = true; + public bool Stt { get; set; } = true; + + /// + /// Returns the list of enabled capability categories and their commands + /// for registration on the WindowsNodeClient. + /// + public IReadOnlyList<(string Category, string[] Commands)> GetEnabledCapabilities() + { + var result = new List<(string, string[])>(); + + if (System) result.Add(("system", ["system.notify", "system.run", "system.run.prepare", "system.which", "system.execApprovals.get", "system.execApprovals.set"])); + if (Canvas) result.Add(("canvas", ["canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset", "canvas.a2ui.dump", "canvas.caps"])); + if (Screen) result.Add(("screen", ["screen.snapshot", "screen.record"])); + if (Camera) result.Add(("camera", ["camera.list", "camera.snap", "camera.clip"])); + if (Location) result.Add(("location", ["location.get"])); + if (Tts) result.Add(("tts", ["tts.speak"])); + if (Stt) result.Add(("stt", ["stt.transcribe", "stt.listen", "stt.status"])); + if (Device) result.Add(("device", ["device.info", "device.status"])); + if (Browser) result.Add(("browser", ["browser.proxy"])); + + return result; + } + + public IReadOnlyList GetEnabledCommandIds() + => GetEnabledCapabilities() + .SelectMany(capability => capability.Commands) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); +} + +// ─── Tray Settings (written to settings.json) ─── + +public sealed class TraySettingsConfig +{ + public bool EnableNodeMode { get; set; } = true; + public bool AutoStart { get; set; } = false; + public bool NodeSystemRunEnabled { get; set; } = true; + public bool NodeCanvasEnabled { get; set; } = true; + public bool NodeScreenEnabled { get; set; } = true; + public bool NodeCameraEnabled { get; set; } = true; + public bool NodeLocationEnabled { get; set; } = true; + public bool NodeBrowserProxyEnabled { get; set; } = true; + public bool NodeTtsEnabled { get; set; } = true; + public bool NodeSttEnabled { get; set; } = true; + + /// + /// Merges these settings into an existing settings.json (or creates a new one). + /// Only overwrites the fields we control — preserves all other user settings. + /// + public void MergeIntoSettingsFile(string settingsPath) + { + Dictionary? existing = null; + + if (File.Exists(settingsPath)) + { + try + { + var content = File.ReadAllText(settingsPath); + existing = JsonSerializer.Deserialize>(content, SetupConfig.JsonOptions); + } + catch (JsonException ex) + { + var backupPath = settingsPath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.bak"; + File.Copy(settingsPath, backupPath, overwrite: false); + throw new InvalidDataException($"settings.json is corrupt; backed up to {backupPath}", ex); + } + } + + var setupOwnedSettings = new Dictionary + { + ["EnableNodeMode"] = EnableNodeMode, + ["AutoStart"] = AutoStart, + }; + + var initialDefaults = new Dictionary + { + ["NodeSystemRunEnabled"] = NodeSystemRunEnabled, + ["NodeCanvasEnabled"] = NodeCanvasEnabled, + ["NodeScreenEnabled"] = NodeScreenEnabled, + ["NodeCameraEnabled"] = NodeCameraEnabled, + ["NodeLocationEnabled"] = NodeLocationEnabled, + ["NodeBrowserProxyEnabled"] = NodeBrowserProxyEnabled, + ["NodeTtsEnabled"] = NodeTtsEnabled, + ["NodeSttEnabled"] = NodeSttEnabled + }; + + var settings = new Dictionary(); + if (existing != null) + { + foreach (var kvp in existing) + settings[kvp.Key] = kvp.Value; + } + + foreach (var kvp in setupOwnedSettings) + settings[kvp.Key] = kvp.Value; + + foreach (var kvp in initialDefaults) + settings.TryAdd(kvp.Key, kvp.Value); + + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + AtomicFile.WriteAllText(settingsPath, json); + } +} + +// ─── Pairing Configuration ─── + +public sealed class PairingConfig +{ + // TODO: Wire OperatorScopes/NodeScopes/CliScopes into pairing requests + // when the gateway protocol supports scoped token issuance. + public int TimeoutSeconds { get; set; } = 60; +} + +// ─── Step Result ─── + +public enum StepOutcome { Success, Skipped, Failed, FailedTerminal } + +public sealed record StepResult(StepOutcome Outcome, string? Message = null, Exception? Error = null) +{ + public static StepResult Ok(string? message = null) => new(StepOutcome.Success, message); + public static StepResult Skip(string reason) => new(StepOutcome.Skipped, reason); + public static StepResult Fail(string message, Exception? ex = null) => new(StepOutcome.Failed, message, ex); + public static StepResult Terminal(string message, Exception? ex = null) => new(StepOutcome.FailedTerminal, message, ex); + + public bool IsSuccess => Outcome is StepOutcome.Success or StepOutcome.Skipped; +} + +// ─── Setup Context ─── + +public sealed class SetupContext +{ + public SetupConfig Config { get; } + public SetupLogger Logger { get; } + public TransactionJournal Journal { get; } + public ICommandRunner Commands { get; } + public CancellationToken CancellationToken { get; } + + // Accumulated state from steps + public string? DistroName { get; set; } + public string? GatewayUrl { get; set; } + public string? SharedGatewayToken { get; set; } + public string? BootstrapToken { get; set; } + public string? GatewayRecordId { get; set; } + public string? OperatorDeviceId { get; set; } + public string? NodeDeviceId { get; set; } + + // Data directory for gateway registry and identity files + public string DataDir { get; } + public string LocalDataDir { get; } + + // WSL PATH prefix using configured user + public string WslPathPrefix => WslConstants.GetPathPrefix(Config.Wsl.User); + + public SetupContext(SetupConfig config, SetupLogger logger, TransactionJournal journal, ICommandRunner commands, CancellationToken ct) + { + Config = config; + Logger = logger; + Journal = journal; + Commands = commands; + CancellationToken = ct; + + DataDir = ResolveDataDir(); + LocalDataDir = ResolveLocalDataDir(); + + DistroName = config.DistroName; + GatewayUrl = config.EffectiveGatewayUrl; + } + + public static string ResolveDataDir() + => Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); + + public static string ResolveLocalDataDir() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") is { Length: > 0 } localAppDataRoot) + return Path.Combine(localAppDataRoot, "OpenClawTray"); + + // Compatibility alias used by early SetupEngine tests/builds. Unlike + // LOCALAPPDATA_DIR, this points directly at the OpenClawTray data folder. + if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR") is { Length: > 0 } localDataDir) + return localDataDir; + + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray"); + } +} diff --git a/src/OpenClaw.SetupEngine/SetupLogger.cs b/src/OpenClaw.SetupEngine/SetupLogger.cs new file mode 100644 index 000000000..e2b2a9305 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupLogger.cs @@ -0,0 +1,178 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OpenClaw.SetupEngine; + +// ─── Structured JSONL Logger ─── + +public enum LogLevel { Trace, Debug, Info, Warn, Error } + +public sealed partial class SetupLogger : IDisposable +{ + private readonly StreamWriter? _writer; + private readonly string? _filePath; + private readonly LogLevel _minLevel; + private readonly string _runId; + private readonly ConcurrentQueue _recentEntries = new(); + private readonly object _writeLock = new(); + private const int MaxRecentEntries = 256; + + public event EventHandler? LogEmitted; + public string RunId => _runId; + public string? FilePath => _filePath; + + public SetupLogger(string? filePath, LogLevel minLevel = LogLevel.Trace) + { + _minLevel = minLevel; + _runId = Guid.NewGuid().ToString("N")[..12]; + + if (filePath != null) + { + _filePath = filePath; + var dir = Path.GetDirectoryName(filePath); + if (dir != null) Directory.CreateDirectory(dir); + _writer = new StreamWriter(filePath, append: false) { AutoFlush = true }; + } + } + + public void Trace(string message, object? data = null) => Write(LogLevel.Trace, message, data); + public void Debug(string message, object? data = null) => Write(LogLevel.Debug, message, data); + public void Info(string message, object? data = null) => Write(LogLevel.Info, message, data); + public void Warn(string message, object? data = null) => Write(LogLevel.Warn, message, data); + public void Error(string message, object? data = null) => Write(LogLevel.Error, message, data); + + public void StepStarted(string stepId, string displayName) + => Write(LogLevel.Info, $"step.started: {displayName}", new { step_id = stepId }); + + public void StepCompleted(string stepId, StepResult result, TimeSpan elapsed) + => Write(LogLevel.Info, $"step.completed: {stepId} → {result.Outcome}", new { step_id = stepId, outcome = result.Outcome.ToString(), message = result.Message, elapsed_ms = elapsed.TotalMilliseconds }); + + public void CommandStarted(string exe, string[] args, TimeSpan timeout) + => Write(LogLevel.Debug, $"cmd.start: {exe} {Sanitize(string.Join(' ', args))}", new { exe, args = args.Select(Sanitize).ToArray(), timeout_ms = timeout.TotalMilliseconds }); + + public void CommandCompleted(string exe, CommandResult result, TimeSpan elapsed) + { + var level = result.ExitCode == 0 ? LogLevel.Debug : LogLevel.Warn; + Write(level, $"cmd.done: {exe} exit={result.ExitCode} ({elapsed.TotalMilliseconds:F0}ms)", new + { + exe, + exit_code = result.ExitCode, + stdout = Truncate(Sanitize(result.Stdout)), + stderr = Truncate(Sanitize(result.Stderr)), + elapsed_ms = elapsed.TotalMilliseconds, + timed_out = result.TimedOut + }); + } + + public void Decision(string description, string chosen) + => Write(LogLevel.Info, $"decision: {description} → {chosen}"); + + public void StateChange(string key, string? from, string? to) + => Write(LogLevel.Debug, $"state: {key} [{from ?? "null"}] → [{to ?? "null"}]"); + + private void Write(LogLevel level, string message, object? data = null) + { + if (level < _minLevel) return; + + var sanitizedMessage = Sanitize(message); + var entry = new LogEntry(DateTimeOffset.UtcNow, _runId, level, sanitizedMessage, SanitizeData(data)); + _recentEntries.Enqueue(entry); + while (_recentEntries.Count > MaxRecentEntries) + _recentEntries.TryDequeue(out _); + + LogEmitted?.Invoke(this, entry); + + var json = JsonSerializer.Serialize(new + { + ts = entry.Timestamp.ToString("O"), + run = entry.RunId, + level = entry.Level.ToString().ToLowerInvariant(), + msg = entry.Message, + data = entry.Data + }, _jsonOptions); + + lock (_writeLock) + { + _writer?.WriteLine(json); + } + + // Also write to console for headless mode + var color = level switch + { + LogLevel.Error => ConsoleColor.Red, + LogLevel.Warn => ConsoleColor.Yellow, + LogLevel.Info => ConsoleColor.White, + _ => ConsoleColor.DarkGray + }; + var prev = Console.ForegroundColor; + Console.ForegroundColor = color; + Console.WriteLine($"[{entry.Timestamp:HH:mm:ss.fff}] [{level}] {entry.Message}"); + Console.ForegroundColor = prev; + } + + // ─── Secret Redaction ─── + + internal static string Sanitize(string input) + { + if (string.IsNullOrEmpty(input)) return input; + input = PrivateKeyPattern().Replace(input, "[REDACTED-PRIVATE-KEY]"); + input = BearerPattern().Replace(input, "$1[REDACTED]"); + input = JwtPattern().Replace(input, "[REDACTED-JWT]"); + input = SensitiveKeyPattern().Replace(input, "$1[REDACTED]"); + input = HexTokenPattern().Replace(input, "[REDACTED-HEX]"); + input = Base64TokenPattern().Replace(input, "[REDACTED-SECRET]"); + return input; + } + + private static object? SanitizeData(object? data) + { + if (data == null) + return null; + + if (data is string value) + return Sanitize(value); + + try + { + var json = JsonSerializer.Serialize(data, _jsonOptions); + var sanitized = Sanitize(json); + using var doc = JsonDocument.Parse(sanitized); + return doc.RootElement.Clone(); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentException) + { + return Sanitize(data.ToString() ?? string.Empty); + } + } + + private static string Truncate(string input, int max = 4096) + => input.Length <= max ? input : input[..max] + $"... [truncated {input.Length - max} chars]"; + + [GeneratedRegex(@"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", RegexOptions.Compiled)] + private static partial Regex PrivateKeyPattern(); + + [GeneratedRegex(@"(\bBearer\s+)[A-Za-z0-9._~+/=-]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex BearerPattern(); + + [GeneratedRegex(@"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", RegexOptions.Compiled)] + private static partial Regex JwtPattern(); + + [GeneratedRegex(@"((?:""[^""]*(?:token|password|secret|api[_-]?key|setup[_-]?code|authorization)[^""]*""|[A-Za-z0-9_.-]*(?:token|password|secret|api[_-]?key|setup[_-]?code|authorization)[A-Za-z0-9_.-]*)\s*[:=]?\s*""?)[^""\s,}]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex SensitiveKeyPattern(); + + [GeneratedRegex(@"\b[a-fA-F0-9]{32,}\b", RegexOptions.Compiled)] + private static partial Regex HexTokenPattern(); + + [GeneratedRegex(@"\b[A-Za-z0-9+/_-]{48,}={0,2}\b", RegexOptions.Compiled)] + private static partial Regex Base64TokenPattern(); + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + public void Dispose() => _writer?.Dispose(); +} + +public sealed record LogEntry(DateTimeOffset Timestamp, string RunId, LogLevel Level, string Message, object? Data); diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs new file mode 100644 index 000000000..b33a132e7 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs @@ -0,0 +1,319 @@ +using System.Diagnostics; + +namespace OpenClaw.SetupEngine; + +// ─── Abstract Step Base ─── + +public abstract class SetupStep +{ + public abstract string Id { get; } + public abstract string DisplayName { get; } + + public abstract Task ExecuteAsync(SetupContext ctx, CancellationToken ct); + + public virtual Task RollbackAsync(SetupContext ctx, CancellationToken ct) => Task.CompletedTask; + public virtual bool CanSkip(SetupContext ctx) => false; + public virtual bool CanRetry => true; + public virtual RetryPolicy Retry => RetryPolicy.Default; +} + +// ─── Pipeline Result ─── + +public enum PipelineOutcome { Success, Failed, Cancelled } + +public sealed record PipelineResult(PipelineOutcome Outcome, string? FailedStepId = null, string? Message = null) +{ + public int ExitCode => Outcome switch + { + PipelineOutcome.Success => 0, + PipelineOutcome.Failed => 1, + PipelineOutcome.Cancelled => 3, + _ => 1 + }; +} + +// ─── Pipeline Events ─── + +public sealed record StepProgressEvent(string StepId, string DisplayName, StepOutcome? Outcome, TimeSpan? Elapsed); + +public static class SetupStepFactory +{ + public static List BuildDefaultSteps() + { + return + [ + new PreflightOsStep(), + new PreflightWslStep(), + new CleanupStaleDistroStep(), + new CleanupStaleGatewayStep(), + new PreflightPortStep(), + new CreateWslInstanceStep(), + new ConfigureWslInstanceStep(), + new ValidateWslLockdownStep(), + new InstallCliStep(), + new ConfigureGatewayStep(), + new InstallGatewayServiceStep(), + new StartGatewayStep(), + new MintBootstrapTokenStep(), + new PairOperatorStep(), + new PairNodeStep(), + new VerifyEndToEndStep(), + new RunGatewayWizardStep(), + new StartKeepaliveStep(), + ]; + } +} + +// ─── Setup Pipeline ─── + +public sealed class SetupPipeline +{ + private readonly List _steps; + private readonly List _completedSteps = new(); + + public event EventHandler? StepProgress; + + public SetupPipeline(IEnumerable steps) + { + _steps = steps.ToList(); + } + + public async Task RunAsync(SetupContext ctx) + { + _completedSteps.Clear(); + var ct = ctx.CancellationToken; + ctx.Journal.RecordPipelineEvent("pipeline_started", $"steps={_steps.Count}"); + ctx.Logger.Info($"Pipeline starting with {_steps.Count} steps", new { run_id = ctx.Logger.RunId }); + + var pipelineSw = Stopwatch.StartNew(); + + foreach (var step in _steps) + { + if (ct.IsCancellationRequested) + { + ctx.Journal.RecordPipelineEvent("pipeline_cancelled"); + return new PipelineResult(PipelineOutcome.Cancelled); + } + + // Check if step can be skipped + if (step.CanSkip(ctx)) + { + ctx.Logger.Info($"Skipping step: {step.DisplayName}"); + ctx.Journal.RecordStepCompleted(step.Id, StepOutcome.Skipped, TimeSpan.Zero, "precondition met"); + StepProgress?.Invoke(this, new(step.Id, step.DisplayName, StepOutcome.Skipped, null)); + continue; + } + + // Execute with retry + ctx.Logger.StepStarted(step.Id, step.DisplayName); + ctx.Journal.RecordStepStarted(step.Id); + StepProgress?.Invoke(this, new(step.Id, step.DisplayName, null, null)); + + var sw = Stopwatch.StartNew(); + StepResult result; + + if (step.CanRetry && step.Retry.MaxAttempts > 1) + { + try + { + result = await RetryExecutor.ExecuteWithRetry( + () => step.ExecuteAsync(ctx, ct), + step.Retry, + ctx.Logger, + step.Id, + ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + ctx.Journal.RecordPipelineEvent("pipeline_cancelled", $"during step {step.Id}"); + return new PipelineResult(PipelineOutcome.Cancelled); + } + } + else + { + try + { + result = await step.ExecuteAsync(ctx, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + ctx.Journal.RecordPipelineEvent("pipeline_cancelled", $"during step {step.Id}"); + return new PipelineResult(PipelineOutcome.Cancelled); + } + catch (Exception ex) + { + result = StepResult.Fail($"Unhandled exception: {ex.Message}", ex); + } + } + + sw.Stop(); + ctx.Logger.StepCompleted(step.Id, result, sw.Elapsed); + ctx.Journal.RecordStepCompleted(step.Id, result.Outcome, sw.Elapsed, result.Message); + StepProgress?.Invoke(this, new(step.Id, step.DisplayName, result.Outcome, sw.Elapsed)); + + if (result.IsSuccess) + { + _completedSteps.Add(step); + continue; + } + + // Step failed — handle rollback if configured + ctx.Logger.Error($"Step '{step.Id}' failed: {result.Message}"); + + if (ctx.Config.RollbackOnFailure) + { + await RollbackFailedStep(step, ctx); + await RollbackCompletedSteps(ctx); + } + + ctx.Journal.RecordPipelineEvent("pipeline_failed", $"step={step.Id}, message={result.Message}"); + return new PipelineResult(PipelineOutcome.Failed, step.Id, result.Message); + } + + pipelineSw.Stop(); + ctx.Journal.RecordPipelineEvent("pipeline_completed", $"elapsed={pipelineSw.Elapsed.TotalSeconds:F1}s"); + ctx.Logger.Info($"Pipeline completed successfully in {pipelineSw.Elapsed.TotalSeconds:F1}s"); + return new PipelineResult(PipelineOutcome.Success); + } + + private async Task RollbackCompletedSteps(SetupContext ctx) + { + ctx.Logger.Warn($"Rolling back {_completedSteps.Count} completed steps"); + for (int i = _completedSteps.Count - 1; i >= 0; i--) + { + var step = _completedSteps[i]; + try + { + ctx.Logger.Info($"Rolling back: {step.DisplayName}"); + await RunRollbackWithTimeout(step, ctx, ctx.CancellationToken); + ctx.Journal.RecordRollback(step.Id, success: true); + } + catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + ctx.Logger.Error($"Rollback failed for {step.Id}: {ex.Message}"); + ctx.Journal.RecordRollback(step.Id, success: false); + } + } + } + + private static async Task RollbackFailedStep(SetupStep step, SetupContext ctx) + { + ctx.Logger.Warn($"Attempting cleanup for failed step: {step.DisplayName}"); + + try + { + await RunRollbackWithTimeout(step, ctx, ctx.CancellationToken); + ctx.Journal.RecordRollback(step.Id, success: true); + } + catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + ctx.Logger.Error($"Cleanup failed for failed step {step.Id}: {ex.Message}"); + ctx.Journal.RecordRollback(step.Id, success: false); + } + } + + /// + /// Runs all step rollbacks in reverse order (full teardown / uninstall). + /// Unlike RollbackCompletedSteps, this runs ALL steps regardless of install state. + /// Continues past individual failures to ensure maximum cleanup. + /// + public async Task UninstallAsync(SetupContext ctx) + { + _completedSteps.Clear(); + var ct = ctx.CancellationToken; + + if (!ctx.Config.ConfirmDestructive && !ctx.Config.DryRun) + { + ctx.Logger.Error("Uninstall requires --confirm-destructive flag"); + return new PipelineResult(PipelineOutcome.Failed, Message: "Safety gate: --confirm-destructive required for live uninstall"); + } + + ctx.Journal.RecordPipelineEvent("uninstall_started", $"steps={_steps.Count}, dry_run={ctx.Config.DryRun}"); + ctx.Logger.Info($"Uninstall starting — {_steps.Count} steps in reverse order (dry_run={ctx.Config.DryRun})"); + + var pipelineSw = Stopwatch.StartNew(); + var failures = new List<(string StepId, string Message)>(); + + // Run rollbacks in reverse order + for (int i = _steps.Count - 1; i >= 0; i--) + { + if (ct.IsCancellationRequested) + { + ctx.Journal.RecordPipelineEvent("uninstall_cancelled"); + return new PipelineResult(PipelineOutcome.Cancelled); + } + + var step = _steps[i]; + ctx.Logger.Info($"Uninstalling: {step.DisplayName}"); + StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", null, null)); + + var sw = Stopwatch.StartNew(); + try + { + if (ctx.Config.DryRun) + { + ctx.Logger.Info($"[DRY RUN] Would rollback: {step.Id}"); + ctx.Journal.RecordRollback(step.Id, success: true); + } + else + { + await RunRollbackWithTimeout(step, ctx, ct); + ctx.Journal.RecordRollback(step.Id, success: true); + } + sw.Stop(); + StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", StepOutcome.Success, sw.Elapsed)); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + ctx.Journal.RecordPipelineEvent("uninstall_cancelled", $"during rollback of {step.Id}"); + return new PipelineResult(PipelineOutcome.Cancelled); + } + catch (Exception ex) + { + sw.Stop(); + ctx.Logger.Error($"Rollback failed for {step.Id}: {ex.Message}"); + ctx.Journal.RecordRollback(step.Id, success: false); + failures.Add((step.Id, ex.Message)); + StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", StepOutcome.Failed, sw.Elapsed)); + // Continue past failures — best-effort cleanup + } + } + + pipelineSw.Stop(); + + if (failures.Count > 0) + { + var failMsg = $"{failures.Count} rollback(s) failed: {string.Join(", ", failures.Select(f => f.StepId))}"; + ctx.Journal.RecordPipelineEvent("uninstall_completed_with_errors", failMsg); + ctx.Logger.Warn($"Uninstall completed with errors in {pipelineSw.Elapsed.TotalSeconds:F1}s — {failMsg}"); + return new PipelineResult(PipelineOutcome.Failed, Message: failMsg); + } + + ctx.Journal.RecordPipelineEvent("uninstall_completed", $"elapsed={pipelineSw.Elapsed.TotalSeconds:F1}s"); + ctx.Logger.Info($"Uninstall completed successfully in {pipelineSw.Elapsed.TotalSeconds:F1}s"); + return new PipelineResult(PipelineOutcome.Success); + } + + private static async Task RunRollbackWithTimeout(SetupStep step, SetupContext ctx, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, ctx.Config.RollbackTimeoutSeconds))); + + try + { + await step.RollbackAsync(ctx, cts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + throw new TimeoutException($"Rollback for step '{step.Id}' exceeded {ctx.Config.RollbackTimeoutSeconds}s."); + } + } +} diff --git a/src/OpenClaw.SetupEngine/SetupRunLock.cs b/src/OpenClaw.SetupEngine/SetupRunLock.cs new file mode 100644 index 000000000..af15c7f73 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupRunLock.cs @@ -0,0 +1,51 @@ +namespace OpenClaw.SetupEngine; + +public sealed class SetupRunLock : IDisposable +{ + private readonly FileStream _stream; + private readonly string _path; + + private SetupRunLock(FileStream stream, string path) + { + _stream = stream; + _path = path; + } + + public static bool TryAcquire(string dataDir, out SetupRunLock? runLock, out string? message) + { + Directory.CreateDirectory(dataDir); + var path = Path.Combine(dataDir, "setup.lock"); + + try + { + var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + stream.SetLength(0); + using var writer = new StreamWriter(stream, leaveOpen: true) { AutoFlush = true }; + writer.WriteLine($"pid={Environment.ProcessId}"); + writer.WriteLine($"startedUtc={DateTimeOffset.UtcNow:O}"); + stream.Flush(flushToDisk: true); + + runLock = new SetupRunLock(stream, path); + message = null; + return true; + } + catch (IOException) + { + runLock = null; + message = $"Another OpenClaw setup run appears to be active. Wait for it to finish, then retry. Lock file: {path}"; + return false; + } + catch (UnauthorizedAccessException ex) + { + runLock = null; + message = $"Cannot create setup lock at {path}: {ex.Message}"; + return false; + } + } + + public void Dispose() + { + _stream.Dispose(); + try { File.Delete(_path); } catch { } + } +} diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs new file mode 100644 index 000000000..b30c3a868 --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -0,0 +1,2656 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + +// PATH prefix for all openclaw CLI commands in WSL +internal static class WslConstants +{ + public static string GetPathPrefix(string user) => + $"""export PATH="/home/{user}/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:$PATH" """; + + public static string WslExePath + { + get + { + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (string.IsNullOrWhiteSpace(windowsDir)) + windowsDir = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows"; + return Path.Combine(windowsDir, "System32", "wsl.exe"); + } + } + + public static string SafeWindowsWorkingDirectory + => Environment.GetFolderPath(Environment.SpecialFolder.System) is { Length: > 0 } systemDir + ? systemDir + : Path.GetPathRoot(Environment.SystemDirectory) ?? @"C:\"; + + // Default (for backward compat with steps that don't have user context yet) + public const string PathPrefix = """export PATH="/home/openclaw/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:$PATH" """; +} + +internal static class WslInstallSupport +{ + private static readonly Version s_minDirectNamedInstallVersion = new(2, 4, 4); + + public static IReadOnlyList ParseQuietDistroList(string output) + => Normalize(output) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim().TrimStart('*').Trim()) + .Where(d => d.Length > 0) + .ToArray(); + + public static bool ContainsDistro(string output, string distroName) + => ParseQuietDistroList(output).Any(d => d.Equals(distroName, StringComparison.OrdinalIgnoreCase)); + + public static bool TryParseWslVersion(string output, out Version version) + { + var match = System.Text.RegularExpressions.Regex.Match( + Normalize(output), + @"WSL\s+version:\s*(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (!match.Success) + { + version = new Version(); + return false; + } + + var major = int.Parse(match.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture); + var minor = int.Parse(match.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture); + var build = int.Parse(match.Groups[3].Value, System.Globalization.CultureInfo.InvariantCulture); + var revision = match.Groups[4].Success + ? int.Parse(match.Groups[4].Value, System.Globalization.CultureInfo.InvariantCulture) + : -1; + version = revision >= 0 + ? new Version(major, minor, build, revision) + : new Version(major, minor, build); + return true; + } + + public static bool SupportsDirectNamedInstall(Version version) + => version.CompareTo(s_minDirectNamedInstallVersion) >= 0; + + public static string[] BuildDirectInstallArgs(string baseDistro, string distroName, string installPath) + => + [ + "--install", + "--distribution", + baseDistro, + "--name", + distroName, + "--location", + installPath, + "--no-launch", + "--web-download" + ]; + + public static bool TryGetDistroVersion(string verboseOutput, string distroName, out int version) + { + foreach (var rawLine in Normalize(verboseOutput).Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim().TrimStart('*').Trim(); + if (line.Length == 0 || line.StartsWith("NAME", StringComparison.OrdinalIgnoreCase)) + continue; + + var parts = line.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3 || !parts[0].Equals(distroName, StringComparison.OrdinalIgnoreCase)) + continue; + + return int.TryParse(parts[^1], out version); + } + + version = 0; + return false; + } + + public static string Normalize(string value) + => value.Replace("\0", "").Replace("\uFEFF", ""); +} + +// Adapter to bridge SetupLogger → IOpenClawLogger for WebSocket clients +internal sealed class SetupOpenClawLogger(SetupLogger logger) : IOpenClawLogger +{ + public void Info(string message) => logger.Info($"[WS] {message}"); + public void Debug(string message) => logger.Debug($"[WS] {message}"); + public void Warn(string message) => logger.Warn($"[WS] {message}"); + public void Error(string message, Exception? ex = null) => logger.Error($"[WS] {message}{(ex != null ? $": {ex}" : "")}"); +} + +// ═══════════════════════════════════════════════════════════════════ +// CLEANUP STEPS +// ═══════════════════════════════════════════════════════════════════ + +public sealed class CleanupStaleDistroStep : SetupStep +{ + public override string Id => "cleanup-distro"; + public override string DisplayName => "Clean up stale WSL distro"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var wslDir = Path.Combine(ctx.LocalDataDir, "wsl", distro); + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (list.ExitCode != 0) + return StepResult.Ok("WSL not available or no distros - nothing to clean"); + + var distros = WslInstallSupport.ParseQuietDistroList(list.Stdout); + + ctx.Logger.Debug($"Found WSL distros: [{string.Join(", ", distros)}]"); + + if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase))) + { + // Distro not registered, but disk directory may still exist from prior crash + if (Directory.Exists(wslDir)) + { + ctx.Logger.Info($"Removing orphaned WSL directory: {wslDir}"); + // Shut down WSL VM to release VHD locks + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); + + var delete = await DeleteDistroDirectoryWithRetries(ctx, wslDir, ct); + if (!delete.IsSuccess) + return delete; + } + ctx.Logger.Decision("No stale distro found", "skip cleanup"); + return StepResult.Ok("No stale distro to clean"); + } + + ctx.Logger.Decision($"Found existing distro '{distro}'", "terminating and unregistering"); + + // Terminate first (stops gateway service), then shut WSL down to release VHD/port locks. + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); // Let port release + + var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode != 0) + { + ctx.Logger.Warn($"First unregister attempt failed (exit {unregister.ExitCode}); forcing WSL shutdown and retrying"); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(3000, ct); + unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + } + + if (unregister.ExitCode == 0) + { + // Also remove the on-disk WSL vhdx directory (--import fails if it exists) + var delete = await DeleteDistroDirectoryWithRetries(ctx, wslDir, ct); + if (!delete.IsSuccess) + return delete; + + // Wait for port to be released + ctx.Logger.Info("Waiting for port release after distro termination..."); + await Task.Delay(3000, ct); + return StepResult.Ok($"Unregistered stale distro '{distro}'"); + } + + return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}"); + } + + internal static async Task DeleteDistroDirectoryWithRetries(SetupContext ctx, string wslDir, CancellationToken ct) + { + Exception? lastError = null; + + for (var attempt = 0; attempt < 4; attempt++) + { + try + { + if (File.Exists(wslDir)) + { + if (File.GetAttributes(wslDir).HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL path '{wslDir}' is a reparse point; remove it manually and retry setup."); + + ctx.Logger.Info($"Removing app-owned WSL file at install path: {wslDir}"); + File.Delete(wslDir); + } + else if (Directory.Exists(wslDir)) + { + if (new DirectoryInfo(wslDir).Attributes.HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL directory '{wslDir}' is a reparse point; remove it manually and retry setup."); + + ctx.Logger.Info($"Removing app-owned WSL directory: {wslDir}"); + Directory.Delete(wslDir, recursive: true); + } + + var parent = Path.GetDirectoryName(wslDir); + if (!string.IsNullOrWhiteSpace(parent) && + Directory.Exists(parent) && + !Directory.EnumerateFileSystemEntries(parent).Any()) + { + Directory.Delete(parent); + ctx.Logger.Info("Deleted empty wsl\\ parent directory"); + } + + return StepResult.Ok("WSL directory removed"); + } + catch (DirectoryNotFoundException) + { + return StepResult.Ok("WSL directory already absent"); + } + catch (IOException ex) + { + lastError = ex; + if (attempt >= 3) + break; + + ctx.Logger.Warn($"VHD directory still locked, retrying in {(attempt + 1) * 2}s..."); + await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); + } + catch (UnauthorizedAccessException ex) + { + lastError = ex; + if (attempt >= 3) + break; + + ctx.Logger.Warn($"VHD directory access denied, retrying in {(attempt + 1) * 2}s..."); + await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct); + } + } + + return StepResult.Fail( + $"Failed to remove app-owned WSL directory '{wslDir}'. Close any process using the OpenClaw WSL distro and retry setup." + + (lastError is null ? "" : $" Last error: {lastError.Message}")); + } +} + +public sealed class CleanupStaleGatewayStep : SetupStep +{ + public override string Id => "cleanup-gateway"; + public override string DisplayName => "Clean up stale gateway state"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + // Remove stale setup-state.json from AppData (legacy location) + var stateFile = Path.Combine(ctx.DataDir, "setup-state.json"); + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + ctx.Logger.Info("Deleted stale setup-state.json (AppData)"); + } + + // Also remove from LocalAppData (current write location) + var localStateFile = Path.Combine(ctx.LocalDataDir, "setup-state.json"); + if (File.Exists(localStateFile)) + { + File.Delete(localStateFile); + ctx.Logger.Info("Deleted stale setup-state.json (LocalAppData)"); + } + + // Remove stale gateway record for our local URL if it exists + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + var existing = registry.FindByUrl(ctx.GatewayUrl!); + if (existing != null) + { + // Preserve non-local records and SSH-tunneled gateways — they may be + // remote gateways that happen to use localhost as a forwarded port. + if (!PairOperatorStep.IsSetupManagedLocalRecord(existing, ctx)) + { + ctx.Logger.Warn($"Skipping cleanup of gateway record {existing.Id}: " + + "not a SetupEngine-managed local gateway"); + } + else + { + // Clean identity directory + var identityDir = registry.GetIdentityDirectory(existing.Id); + if (Directory.Exists(identityDir)) + { + Directory.Delete(identityDir, recursive: true); + ctx.Logger.Info($"Deleted stale identity directory: {identityDir}"); + } + registry.Remove(existing.Id); + registry.Save(); + ctx.Logger.Info($"Removed stale gateway record for {ctx.GatewayUrl}"); + } + } + + await Task.CompletedTask; + return StepResult.Ok("Gateway state cleaned"); + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + // Delete setup-state.json (written by VerifyEndToEndStep) + var localDataPath = ctx.LocalDataDir; + + var stateFile = Path.Combine(localDataPath, "setup-state.json"); + if (File.Exists(stateFile)) + { + File.Delete(stateFile); + ctx.Logger.Info("[Uninstall] Deleted setup-state.json"); + } + else + { + ctx.Logger.Info("[Uninstall] setup-state.json already absent"); + } + + return Task.CompletedTask; + } +} + +// ═══════════════════════════════════════════════════════════════════ +// PREFLIGHT STEPS +// ═══════════════════════════════════════════════════════════════════ + +public sealed class PreflightOsStep : SetupStep +{ + public override string Id => "preflight-os"; + public override string DisplayName => "Verify Windows OS"; + public override bool CanRetry => false; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + if (!Environment.Is64BitOperatingSystem) + return Task.FromResult(StepResult.Terminal("64-bit Windows required")); + + if (!OperatingSystem.IsWindows()) + return Task.FromResult(StepResult.Terminal("Windows OS required")); + + var version = Environment.OSVersion.Version; + ctx.Logger.Info($"OS: Windows {version} (64-bit)"); + + return Task.FromResult(StepResult.Ok($"Windows {version}")); + } +} + +public sealed class PreflightWslStep : SetupStep +{ + public override string Id => "preflight-wsl"; + public override string DisplayName => "Verify WSL available"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + if (versionResult.ExitCode != 0 && LooksUnavailable(versionResult)) + { + var installResult = await InstallWslPlatformAsync(ctx, ct); + if (!installResult.IsSuccess) + return installResult; + + versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + } + + if (versionResult.ExitCode != 0) + { + if (LooksTooOldForVersionCommand(versionResult)) + return StepResult.Terminal("WSL is installed but too old for clean app-owned gateway setup. Update WSL from Microsoft Store or run `wsl --update`, then retry setup."); + + return StepResult.Terminal($"WSL is not available. {FirstUsefulLine(versionResult)}"); + } + + var versionOutput = NormalizeWslOutput($"{versionResult.Stdout}\n{versionResult.Stderr}"); + if (!WslInstallSupport.TryParseWslVersion(versionOutput, out var wslVersion)) + return StepResult.Terminal("WSL version output did not include a parseable WSL version. Update WSL from Microsoft Store or run `wsl --update`, then retry setup."); + + if (!WslInstallSupport.SupportsDirectNamedInstall(wslVersion)) + return StepResult.Terminal($"WSL {wslVersion} cannot create a clean app-owned OpenClaw gateway distro. Update WSL with `wsl --update`, then retry setup."); + + ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}"); + ctx.Logger.Info($"WSL direct named install is supported (version {wslVersion})"); + return StepResult.Ok("WSL available"); + } + + private static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct) + { + ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install"); + try + { + var psi = new ProcessStartInfo + { + FileName = WslConstants.WslExePath, + UseShellExecute = true, + Verb = "runas", + CreateNoWindow = true, + WorkingDirectory = WslConstants.SafeWindowsWorkingDirectory + }; + psi.ArgumentList.Add("--install"); + psi.ArgumentList.Add("--no-distribution"); + + using var process = Process.Start(psi); + if (process == null) + return StepResult.Fail("Could not start elevated WSL platform installer."); + + await process.WaitForExitAsync(ct); + + if (process.ExitCode == 3010) + return StepResult.Terminal("WSL platform install requires a restart. Reboot Windows, then run setup again."); + + if (process.ExitCode != 0) + return StepResult.Fail($"WSL platform install failed with exit code {process.ExitCode}."); + + var probe = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct); + if (probe.ExitCode != 0 || LooksUnavailable(probe)) + return StepResult.Terminal("WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again."); + + return StepResult.Ok("WSL platform installed"); + } + catch (System.ComponentModel.Win32Exception ex) when ((uint)ex.NativeErrorCode == 1223) + { + return StepResult.Fail("WSL platform install was cancelled at the elevation prompt."); + } + catch (Exception ex) + { + return StepResult.Fail($"WSL platform install failed: {ex.Message}", ex); + } + } + + private static bool LooksUnavailable(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase) + || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase) + || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase) + || text.Contains("not installed", StringComparison.OrdinalIgnoreCase); + } + + private static bool LooksTooOldForVersionCommand(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}"); + return text.Contains("Invalid command line option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unrecognized option", StringComparison.OrdinalIgnoreCase) + || text.Contains("unknown option", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeWslOutput(string value) + => value.Replace("\0", "").Replace("\uFEFF", ""); + + private static string FirstUsefulLine(CommandResult result) + { + var text = NormalizeWslOutput($"{result.Stderr}\n{result.Stdout}"); + return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim() + ?? "Run wsl --install from an elevated terminal and retry setup."; + } +} + +public sealed class PreflightPortStep : SetupStep +{ + public override string Id => "preflight-port"; + public override string DisplayName => "Check gateway port available"; + public override bool CanRetry => false; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var port = ctx.Config.GatewayPort; + var addresses = ctx.Config.Gateway.Bind.Equals("lan", StringComparison.OrdinalIgnoreCase) + ? new[] { IPAddress.Any, IPAddress.IPv6Any } + : [IPAddress.Loopback]; + + foreach (var address in addresses) + { + if (!CanBind(address, port, out var error)) + return Task.FromResult(StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})")); + } + + return Task.FromResult(StepResult.Ok($"Port {port} is available")); + } + + private static bool CanBind(IPAddress address, int port, out SocketException error) + { + var listener = new TcpListener(address, port) + { + ExclusiveAddressUse = true + }; + + try + { + listener.Start(); + error = null!; + return true; + } + catch (SocketException ex) + { + error = ex; + return false; + } + finally + { + listener.Stop(); + } + } + + private static string DescribeBind(IPAddress address) + => address.Equals(IPAddress.Any) ? "LAN IPv4 bind" : + address.Equals(IPAddress.IPv6Any) ? "LAN IPv6 bind" : + "loopback bind"; +} + +// ═══════════════════════════════════════════════════════════════════ +// WSL STEPS +// ═══════════════════════════════════════════════════════════════════ + +public sealed class CreateWslInstanceStep : SetupStep +{ + public override string Id => "wsl-create"; + public override string DisplayName => "Create WSL instance"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var baseDistro = ctx.Config.BaseDistro.Trim(); + + if (string.IsNullOrWhiteSpace(baseDistro)) + return StepResult.Terminal("BaseDistro is required for fresh WSL gateway setup."); + + var installPath = Path.Combine(ctx.LocalDataDir, "wsl", distro); + ctx.Logger.Info($"Creating clean app-owned WSL distro '{distro}' from '{baseDistro}' at '{installPath}'"); + + var existing = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (existing.ExitCode != 0) + return StepResult.Fail($"Failed to list WSL distros before creating '{distro}': {existing.Stderr}"); + + if (WslInstallSupport.ContainsDistro(existing.Stdout, distro)) + return StepResult.Fail($"Target WSL distro '{distro}' still exists after cleanup; refusing to create a new gateway over unknown state."); + + var pathCheck = EnsureInstallPathReady(installPath); + if (!pathCheck.IsSuccess) + return pathCheck; + + Directory.CreateDirectory(Path.GetDirectoryName(installPath)!); + + var installArgs = WslInstallSupport.BuildDirectInstallArgs(baseDistro, distro, installPath); + ctx.Logger.Info($"Installing fresh WSL distro with arguments: {string.Join(' ', installArgs)}"); + var install = await ctx.Commands.RunAsync( + WslConstants.WslExePath, + installArgs, + TimeSpan.FromMinutes(15), + ct: ct); + + if (install.ExitCode != 0) + { + var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); + return StepResult.Fail( + $"Fresh WSL install failed for '{distro}' from '{baseDistro}' (exit {install.ExitCode}): {FirstNonEmpty(install.Stderr, install.Stdout)}{cleanupError}"); + } + + var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); + if (!verify.IsSuccess) + { + var cleanupError = await CleanupPartialInstall(ctx, distro, installPath, ct); + return StepResult.Fail($"{verify.Message}{cleanupError}"); + } + + return verify; + } + + private static StepResult EnsureInstallPathReady(string installPath) + { + if (File.Exists(installPath)) + { + if (File.GetAttributes(installPath).HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL install path '{installPath}' is a reparse point; remove it manually and retry setup."); + + File.Delete(installPath); + return StepResult.Ok(); + } + + if (!Directory.Exists(installPath)) + return StepResult.Ok(); + + if (new DirectoryInfo(installPath).Attributes.HasFlag(FileAttributes.ReparsePoint)) + return StepResult.Fail($"App-owned WSL install directory '{installPath}' is a reparse point; remove it manually and retry setup."); + + if (Directory.EnumerateFileSystemEntries(installPath).Any()) + { + return StepResult.Fail( + $"App-owned WSL install directory '{installPath}' still contains files after cleanup; refusing to create a new gateway over unknown state."); + } + + Directory.Delete(installPath); + return StepResult.Ok(); + } + + private static async Task VerifyFreshDistro(SetupContext ctx, string distro, string installPath, CancellationToken ct) + { + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + if (list.ExitCode != 0 || !WslInstallSupport.ContainsDistro(list.Stdout, distro)) + return StepResult.Fail($"Fresh WSL install did not register expected distro '{distro}'."); + + var verbose = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--verbose"], TimeSpan.FromSeconds(15), ct: ct); + if (verbose.ExitCode != 0 || !WslInstallSupport.TryGetDistroVersion(verbose.Stdout, distro, out var version)) + return StepResult.Fail($"Fresh WSL install registered '{distro}', but setup could not verify it is WSL2."); + + if (version != 2) + return StepResult.Fail($"Fresh WSL install registered '{distro}' as WSL{version}; WSL2 is required."); + + var probe = await ctx.Commands.RunAsync( + WslConstants.WslExePath, + ["-d", distro, "-u", "root", "--", "sh", "-lc", "id -u && test -d / && echo OPENCLAW_FRESH_WSL_READY"], + TimeSpan.FromSeconds(30), + ct: ct); + + if (probe.ExitCode != 0 || !probe.Stdout.Contains("OPENCLAW_FRESH_WSL_READY", StringComparison.Ordinal)) + return StepResult.Fail($"Fresh WSL distro '{distro}' could not run a root verification command: {FirstNonEmpty(probe.Stderr, probe.Stdout)}"); + + return StepResult.Ok($"Created clean WSL2 distro '{distro}' at '{installPath}'"); + } + + private static async Task CleanupPartialInstall(SetupContext ctx, string distro, string installPath, CancellationToken ct) + { + var cleanupErrors = new List(); + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + var distroExists = list.ExitCode == 0 && WslInstallSupport.ContainsDistro(list.Stdout, distro); + + if (!distroExists && !Directory.Exists(installPath) && !File.Exists(installPath)) + return ""; + + if (distroExists) + { + var terminate = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + if (terminate.ExitCode != 0) + cleanupErrors.Add($"terminate exit {terminate.ExitCode}: {FirstNonEmpty(terminate.Stderr, terminate.Stdout)}"); + + var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode != 0) + { + ctx.Logger.Warn($"Partial install unregister failed (exit {unregister.ExitCode}); forcing WSL shutdown and retrying"); + var shutdown = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct); + if (shutdown.ExitCode != 0) + cleanupErrors.Add($"shutdown exit {shutdown.ExitCode}: {FirstNonEmpty(shutdown.Stderr, shutdown.Stdout)}"); + + unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + if (unregister.ExitCode != 0) + cleanupErrors.Add($"unregister exit {unregister.ExitCode}: {FirstNonEmpty(unregister.Stderr, unregister.Stdout)}"); + } + } + + var delete = await CleanupStaleDistroStep.DeleteDistroDirectoryWithRetries(ctx, installPath, ct); + if (!delete.IsSuccess) + cleanupErrors.Add(delete.Message ?? "install directory cleanup failed"); + + return cleanupErrors.Count == 0 + ? "" + : $" Partial app-owned distro cleanup also failed: {string.Join("; ", cleanupErrors)}"; + } + + private static string FirstNonEmpty(params string[] values) + => values.Select(v => v.Trim()).FirstOrDefault(v => v.Length > 0) ?? "no output"; + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); // Let port/VHD locks release + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct); + + // VHD parent dir cleanup (mirrors old uninstall step 5a) + var localDataPath = ctx.LocalDataDir; + var vhdDir = Path.Combine(localDataPath, "wsl", distro); + if (Directory.Exists(vhdDir)) + { + Directory.Delete(vhdDir, recursive: true); + ctx.Logger.Info($"[Uninstall] Deleted VHD parent directory: {vhdDir}"); + } + + // WSL parent dir cleanup — remove empty wsl\ directory (mirrors old step 5b) + var wslDir = Path.Combine(localDataPath, "wsl"); + if (Directory.Exists(wslDir) && !Directory.EnumerateFileSystemEntries(wslDir).Any()) + { + Directory.Delete(wslDir); + ctx.Logger.Info("[Uninstall] Deleted empty wsl\\ parent directory"); + } + } +} + +public sealed class ConfigureWslInstanceStep : SetupStep +{ + public override string Id => "wsl-configure"; + public override string DisplayName => "Configure WSL instance"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var wsl = ctx.Config.Wsl; + + if (!WslConfig.IsValidLinuxUserName(wsl.User)) + return StepResult.Terminal($"Invalid WSL user '{wsl.User}'. Use a Linux username matching [a-z_][a-z0-9_-]{{0,31}}."); + + // Build wsl.conf from config + var wslConf = $""" +[boot] +systemd={wsl.Systemd.ToString().ToLower()} + +[automount] +enabled={wsl.Automount.ToString().ToLower()} +mountFsTab={wsl.MountFsTab.ToString().ToLower()} + +[interop] +enabled={wsl.Interop.ToString().ToLower()} +appendWindowsPath={wsl.AppendWindowsPath.ToString().ToLower()} + +[user] +default={wsl.User} + +[time] +useWindowsTimezone={wsl.UseWindowsTimezone.ToString().ToLower()} +"""; + + // Create user and directories + var script = $""" + set -e + + # Create user if not exists + if ! id -u {wsl.User} &>/dev/null; then + useradd -m -s /bin/bash {wsl.User} + fi + + # Create required directories + mkdir -p /home/{wsl.User}/.openclaw + mkdir -p /var/lib/openclaw + mkdir -p /var/log/openclaw + mkdir -p /opt/openclaw + + chown -R {wsl.User}:{wsl.User} /home/{wsl.User}/.openclaw + chown -R {wsl.User}:{wsl.User} /var/lib/openclaw + chown -R {wsl.User}:{wsl.User} /var/log/openclaw + chown -R {wsl.User}:{wsl.User} /opt/openclaw + + # Write wsl.conf + cat > /etc/wsl.conf << 'WSLCONF' + {wslConf} + WSLCONF + + echo "CONFIGURED_OK" + """; + + var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(60), ct: ct, user: "root"); + + if (result.ExitCode != 0 || !result.Stdout.Contains("CONFIGURED_OK")) + return StepResult.Fail($"Configuration failed: {result.Stderr}"); + + // Restart WSL to apply wsl.conf (systemd) + ctx.Logger.Info("Restarting WSL to apply configuration (systemd)"); + await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct); + await Task.Delay(2000, ct); // Let WSL settle + + return StepResult.Ok("WSL instance configured"); + } +} + +public sealed class ValidateWslLockdownStep : SetupStep +{ + public override string Id => "validate-wsl-lockdown"; + public override string DisplayName => "Validate WSL lockdown"; + public override bool CanRetry => false; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var wsl = ctx.Config.Wsl; + + var readConf = await ctx.Commands.RunInWslAsync(distro, "cat /etc/wsl.conf", TimeSpan.FromSeconds(15), ct: ct); + if (readConf.ExitCode != 0) + return StepResult.Terminal("Cannot read /etc/wsl.conf - WSL configuration may not have been applied"); + + var errors = ValidateWslConf(readConf.Stdout, wsl); + if (errors.Count > 0) + { + var msg = "WSL lockdown validation failed:\n" + string.Join("\n", errors.Select(e => $" - {e}")); + return StepResult.Terminal(msg); + } + + var requiredDirs = new[] + { + $"/home/{wsl.User}/.openclaw", + "/var/lib/openclaw", + "/var/log/openclaw", + "/opt/openclaw" + }; + + // Generate per-directory checks inline (no bash variables). + // wsl.exe -- bash -c mangles double-quotes and bash $var references, + // so we avoid both: paths have no spaces (safe unquoted) and all + // values are C#-interpolated rather than stored in bash variables. + var dirChecks = new System.Text.StringBuilder(); + foreach (var d in requiredDirs) + { + dirChecks.AppendLine($"test -d {d} || {{ echo DIR_MISSING:{d}; exit 1; }}"); + dirChecks.AppendLine($"test $(stat -c %U {d} 2>/dev/null) = {wsl.User} || {{ echo OWNER_MISMATCH:{d}:$(stat -c %U {d} 2>/dev/null); exit 1; }}"); + } + + var verifyScript = "set -e\n" + + $"id -u {wsl.User} &>/dev/null || {{ echo USER_MISSING; exit 1; }}\n" + + dirChecks + + "echo LOCKDOWN_VALID\n"; + + var verify = await ctx.Commands.RunInWslAsync(distro, verifyScript, TimeSpan.FromSeconds(30), ct: ct); + + ctx.Logger.Debug($"Lockdown verify exit={verify.ExitCode} stdout={verify.Stdout.Trim()} stderr={verify.Stderr.Trim()}"); + + if (verify.Stdout.Contains("USER_MISSING", StringComparison.Ordinal)) + return StepResult.Terminal($"User '{wsl.User}' does not exist in distro '{distro}'"); + + if (verify.Stdout.Contains("DIR_MISSING:", StringComparison.Ordinal)) + { + var line = verify.Stdout.Split('\n').FirstOrDefault(l => l.Contains("DIR_MISSING:")) ?? ""; + var dir = line.Trim().Split(':', 2).ElementAtOrDefault(1)?.Trim() ?? "unknown"; + return StepResult.Terminal($"Required directory missing: {dir}"); + } + + if (verify.Stdout.Contains("OWNER_MISMATCH:", StringComparison.Ordinal)) + { + var line = verify.Stdout.Split('\n').FirstOrDefault(l => l.Contains("OWNER_MISMATCH:")) ?? ""; + var parts = line.Trim().Split(':'); + return StepResult.Terminal($"Directory {parts.ElementAtOrDefault(1)} owned by '{parts.ElementAtOrDefault(2)}', expected '{wsl.User}'"); + } + + if (!verify.Stdout.Contains("LOCKDOWN_VALID", StringComparison.Ordinal)) + { + var detail = string.IsNullOrWhiteSpace(verify.Stderr) ? verify.Stdout.Trim() : verify.Stderr.Trim(); + return StepResult.Terminal($"WSL lockdown validation failed: {detail}"); + } + + if (!string.IsNullOrEmpty(wsl.Memory)) + ctx.Logger.Warn($"Wsl.Memory='{wsl.Memory}' is set but requires host-level .wslconfig, not per-distro wsl.conf"); + if (!string.IsNullOrEmpty(wsl.Swap)) + ctx.Logger.Warn($"Wsl.Swap='{wsl.Swap}' is set but requires host-level .wslconfig, not per-distro wsl.conf"); + + ctx.Logger.Info("WSL lockdown validated: all invariants verified"); + return StepResult.Ok("WSL lockdown validated"); + } + + internal static List ValidateWslConf(string conf, WslConfig wsl) + { + var values = ParseWslConf(conf); + var errors = new List(); + + ValidateConfValue(values, "boot", "systemd", wsl.Systemd, errors); + ValidateConfValue(values, "interop", "enabled", wsl.Interop, errors); + ValidateConfValue(values, "interop", "appendWindowsPath", wsl.AppendWindowsPath, errors); + ValidateConfValue(values, "automount", "enabled", wsl.Automount, errors); + ValidateConfValue(values, "automount", "mountFsTab", wsl.MountFsTab, errors); + ValidateConfValue(values, "user", "default", wsl.User, errors); + + return errors; + } + + private static Dictionary> ParseWslConf(string conf) + { + var values = new Dictionary>(StringComparer.OrdinalIgnoreCase); + string? currentSection = null; + + using var reader = new StringReader(conf); + string? line; + while ((line = reader.ReadLine()) != null) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#') || trimmed.StartsWith(';')) + continue; + + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) + { + currentSection = trimmed[1..^1].Trim(); + if (!values.ContainsKey(currentSection)) + values[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase); + continue; + } + + if (currentSection is null) + continue; + + var separator = trimmed.IndexOf('='); + if (separator <= 0) + continue; + + var key = trimmed[..separator].Trim(); + var value = trimmed[(separator + 1)..].Trim(); + values[currentSection][key] = value; + } + + return values; + } + + private static void ValidateConfValue(Dictionary> conf, string section, string key, bool expected, List errors) => + ValidateConfValue(conf, section, key, expected.ToString().ToLowerInvariant(), errors); + + private static void ValidateConfValue(Dictionary> conf, string section, string key, string expected, List errors) + { + if (!conf.TryGetValue(section, out var sectionValues) || + !sectionValues.TryGetValue(key, out var actual) || + !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Expected [{section}] {key}={expected} in wsl.conf"); + } + } +} + +// ═══════════════════════════════════════════════════════════════════ +// GATEWAY INSTALL STEPS +// ═══════════════════════════════════════════════════════════════════ + +public sealed class InstallCliStep : SetupStep +{ + public override string Id => "install-cli"; + public override string DisplayName => "Install OpenClaw CLI"; + public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(5)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var user = ctx.Config.Wsl.User; + + // Download and run install script (URL configurable) + var installUrl = ctx.Config.Gateway.InstallUrl ?? "https://openclaw.ai/install-cli.sh"; + + // Validate URL is HTTPS to prevent downgrade attacks + if (!Uri.TryCreate(installUrl, UriKind.Absolute, out var parsedUrl) || + !string.Equals(parsedUrl.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + return StepResult.Fail($"Installer URL must be HTTPS: {installUrl}"); + } + + // Shell-quote the URL and enforce TLS + var escapedUrl = installUrl.Replace("'", "'\\''"); + var installScript = $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash"; + var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct); + + if (result.ExitCode != 0) + return StepResult.Fail($"CLI install failed (exit {result.ExitCode}): {result.Stderr}"); + + var verifyCommands = new (string Command, string? ExecutablePath)[] + { + ("openclaw --version", null), + ($"/home/{user}/.openclaw/bin/openclaw --version", $"/home/{user}/.openclaw/bin/openclaw"), + ("/opt/openclaw/bin/openclaw --version", "/opt/openclaw/bin/openclaw"), + ("/usr/local/bin/openclaw --version", "/usr/local/bin/openclaw") + }; + + foreach (var (cmd, executablePath) in verifyCommands) + { + var verify = await ctx.Commands.RunInWslAsync(distro, cmd, TimeSpan.FromSeconds(15), ct: ct); + if (verify.ExitCode == 0 && !string.IsNullOrWhiteSpace(verify.Stdout)) + { + if (executablePath != null) + { + var pathResult = await EnsureCliOnDefaultPathAsync(ctx, distro, executablePath, ct); + if (!pathResult.IsSuccess) + return pathResult; + } + + ctx.Logger.Info($"OpenClaw CLI version: {verify.Stdout.Trim()}"); + return StepResult.Ok($"CLI installed: {verify.Stdout.Trim()}"); + } + } + + return StepResult.Fail("CLI installed but not found in any known location"); + } + + private static async Task EnsureCliOnDefaultPathAsync( + SetupContext ctx, + string distro, + string executablePath, + CancellationToken ct) + { + var user = ctx.Config.Wsl.User; + + if (!executablePath.StartsWith("/", StringComparison.Ordinal) || + executablePath.Contains('\'') || + executablePath.Contains('\n')) + { + return StepResult.Fail($"Refusing to create openclaw PATH symlink for unexpected install path: {executablePath}"); + } + + if (!string.Equals(executablePath, "/usr/local/bin/openclaw", StringComparison.Ordinal)) + { + var linkCommand = $""" + set -e + ln -sfn {executablePath} /usr/local/bin/openclaw + echo OPENCLAW_PATH_READY + """; + + var link = await ctx.Commands.RunInWslAsync( + distro, + linkCommand, + TimeSpan.FromSeconds(15), + ct: ct, + user: "root"); + + if (link.ExitCode != 0 || !link.Stdout.Contains("OPENCLAW_PATH_READY", StringComparison.Ordinal)) + return StepResult.Fail($"Failed to make openclaw available on default PATH: {link.Stderr}"); + } + + var bareVerify = await ctx.Commands.RunInWslAsync( + distro, + $"env -i HOME=/home/{user} USER={user} PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin openclaw --version", + TimeSpan.FromSeconds(15), + ct: ct); + + if (bareVerify.ExitCode != 0 || string.IsNullOrWhiteSpace(bareVerify.Stdout)) + return StepResult.Fail($"openclaw PATH symlink verification failed: {bareVerify.Stderr}"); + + ctx.Logger.Info($"OpenClaw CLI available on default PATH: {bareVerify.Stdout.Trim()}"); + return StepResult.Ok(); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var user = ctx.Config.Wsl.User; + await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"rm -rf /opt/openclaw /home/{user}/.openclaw /usr/local/bin/openclaw", TimeSpan.FromSeconds(30), ct: ct, user: "root"); + } +} + +public sealed class ConfigureGatewayStep : SetupStep +{ + internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl"; + + public override string Id => "configure-gateway"; + public override string DisplayName => "Configure gateway"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var port = ctx.Config.GatewayPort; + var gw = ctx.Config.Gateway; + + // Validate bind value — only "loopback" and "lan" are accepted + if (gw.Bind is not ("loopback" or "lan")) + return StepResult.Terminal($"Invalid Gateway.Bind value '{gw.Bind}'. Must be 'loopback' or 'lan'."); + + // Generate a shared gateway token + var token = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + ctx.SharedGatewayToken = token; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + + var allowedCommandsJson = JsonSerializer.Serialize(ctx.Config.Capabilities.GetEnabledCommandIds()); + var escapedAllowedCommands = ShellEscape(allowedCommandsJson); + var extraConfigOverridesAllowCommands = gw.ExtraConfig?.ContainsKey("gateway.nodes.allowCommands") == true; + if (gw.ExtraConfig is { Count: > 0 }) + { + foreach (var key in gw.ExtraConfig.Keys) + { + if (!IsSafeExtraConfigKey(key)) + return StepResult.Fail($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'."); + } + } + + var configCommands = BuildConfigCommands(gw, port, escapedAllowedCommands); + + ctx.Logger.Info($"Gateway node allowCommands derived from setup capabilities: {allowedCommandsJson}"); + if (extraConfigOverridesAllowCommands) + ctx.Logger.Warn("Gateway.ExtraConfig overrides derived gateway.nodes.allowCommands"); + if (GetDefaultDevicePairPublicUrl(gw, port) is { } defaultPublicUrl && + gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true) + { + ctx.Logger.Info($"Configured device-pair public URL for loopback gateway: {defaultPublicUrl}"); + } + + var pathPrefix = ctx.WslPathPrefix; + var script = $""" + set -e + {pathPrefix} + + {configCommands} + + echo "GATEWAY_CONFIGURED" + """; + + var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(30), env, ct); + + if (result.ExitCode != 0 || !result.Stdout.Contains("GATEWAY_CONFIGURED")) + return StepResult.Fail($"Gateway configuration failed (exit {result.ExitCode}): {result.Stderr}"); + + ctx.Logger.StateChange("shared_gateway_token", null, "[SET]"); + return StepResult.Ok("Gateway configured"); + } + + internal static string BuildConfigCommands(GatewayConfig gw, int port, string escapedAllowedCommands) + { + var configCommands = $""" + openclaw config set gateway.mode local + openclaw config set gateway.port {port} + openclaw config set gateway.bind {gw.Bind} + openclaw config set gateway.auth.mode {gw.AuthMode} + openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" + openclaw config set gateway.reload.mode {gw.ReloadMode} + openclaw config set gateway.nodes.allowCommands {escapedAllowedCommands} + """; + + if (GetDefaultDevicePairPublicUrl(gw, port) is { } defaultPublicUrl && + gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true) + { + configCommands += $"\n openclaw config set {DevicePairPublicUrlKey} {ShellEscape(defaultPublicUrl)}"; + } + + // Apply any extra config key/value pairs from config (shell-escape values) + if (gw.ExtraConfig is { Count: > 0 }) + { + foreach (var (key, value) in gw.ExtraConfig) + { + if (!IsSafeExtraConfigKey(key)) + throw new ArgumentException($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.", nameof(gw)); + + var escapedValue = ShellEscape(value); + configCommands += $"\n openclaw config set {key} {escapedValue}"; + } + } + + return configCommands; + } + + internal static string? GetDefaultDevicePairPublicUrl(GatewayConfig gw, int port) => + gw.Bind == "loopback" ? $"http://127.0.0.1:{port}" : null; + + private static string ShellEscape(string value) => "'" + value.Replace("'", "'\\''") + "'"; + + internal static bool IsSafeExtraConfigKey(string value) + => System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9._-]+$"); +} + +public sealed class InstallGatewayServiceStep : SetupStep +{ + public override string Id => "install-service"; + public override string DisplayName => "Install gateway service"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + var result = await ctx.Commands.RunInWslAsync( + distro, $"{ctx.WslPathPrefix} && openclaw gateway install --force", TimeSpan.FromSeconds(60), ct: ct); + + if (result.ExitCode != 0) + return StepResult.Fail($"Service install failed (exit {result.ExitCode}): {result.Stderr}"); + + return StepResult.Ok("Gateway service installed"); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw gateway uninstall", TimeSpan.FromSeconds(30), ct: 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) + { + var distro = ctx.DistroName!; + var pathCmd = ctx.WslPathPrefix; + + // 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}")) + { + 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"); + } + + // Start the service + var start = await ctx.Commands.RunInWslAsync( + distro, $"{pathCmd} && openclaw gateway start", TimeSpan.FromSeconds(30), ct: ct); + + if (start.ExitCode != 0) + { + // Check if systemd start-limit-hit + if (start.Stderr.Contains("start-limit", StringComparison.OrdinalIgnoreCase)) + { + ctx.Logger.Warn("Start-limit hit, resetting and retrying"); + await ctx.Commands.RunInWslAsync( + distro, + "systemctl --user reset-failed openclaw-gateway.service", + 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); + if (start.ExitCode != 0) + return StepResult.Fail($"Gateway start failed after reset: {start.Stderr}"); + } + else + { + return StepResult.Fail($"Gateway start failed (exit {start.ExitCode}): {start.Stderr}"); + } + } + + // Wait for health endpoint + ctx.Logger.Info("Waiting for gateway health endpoint..."); + var healthDeadline = DateTimeOffset.UtcNow.Add(TimeSpan.FromSeconds(ctx.Config.Gateway.HealthTimeoutSeconds)); + + while (DateTimeOffset.UtcNow < healthDeadline) + { + ct.ThrowIfCancellationRequested(); + + var status = await ctx.Commands.RunInWslAsync( + distro, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:" + ctx.Config.GatewayPort + "/ --max-time 3", + TimeSpan.FromSeconds(10), ct: ct); + + if (status.ExitCode == 0 && status.Stdout.Trim() is "200" or "401" or "403") + { + ctx.Logger.Info($"Gateway is accepting connections (HTTP {status.Stdout.Trim()})"); + return StepResult.Ok("Gateway running"); + } + + ctx.Logger.Debug($"Gateway not yet accepting connections (curl exit={status.ExitCode}, response={status.Stdout.Trim()})"); + + await Task.Delay(2000, ct); + } + + // Capture service status and journal for diagnostics + var statusResult = await ctx.Commands.RunInWslAsync( + distro, + "systemctl --user status openclaw-gateway.service 2>&1 || true", + TimeSpan.FromSeconds(10), + ct: ct); + + var journal = await ctx.Commands.RunInWslAsync( + distro, + "journalctl --user-unit openclaw-gateway.service --no-pager -n 30 2>&1 || true", + TimeSpan.FromSeconds(10), + ct: ct); + + var redactedStatus = RedactTokens(statusResult.Stdout); + var redactedJournal = RedactTokens(journal.Stdout); + + ctx.Logger.Error($"Gateway health timeout.\nService status:\n{redactedStatus}\nJournal:\n{redactedJournal}"); + + return StepResult.Fail($"Gateway did not become healthy within {ctx.Config.Gateway.HealthTimeoutSeconds}s"); + } + + internal static string RedactTokens(string text) + { + if (string.IsNullOrEmpty(text)) + return text; + + return System.Text.RegularExpressions.Regex.Replace( + text, + @"[0-9a-fA-F]{32,}", + m => m.Value[..8] + "…[REDACTED]"); + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + // Check if distro is running before trying systemctl stop + var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct); + var distros = list.Stdout + .Replace("\0", "").Replace("\uFEFF", "") + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim()).Where(d => d.Length > 0).ToList(); + + if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase))) + { + ctx.Logger.Info("[Uninstall] Distro not registered — skipping gateway stop"); + return; + } + + // Check distro state — only stop if Running + var verbose = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--verbose"], TimeSpan.FromSeconds(15), ct: ct); + var isRunning = verbose.Stdout + .Replace("\0", "").Replace("\uFEFF", "") + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Any(line => line.Contains(distro, StringComparison.OrdinalIgnoreCase) + && line.Contains("Running", StringComparison.OrdinalIgnoreCase)); + + if (!isRunning) + { + ctx.Logger.Info("[Uninstall] Distro not running — skipping systemctl stop"); + return; + } + + // Stop gateway service with 5-second timeout (mirrors old uninstall step 3) + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await ctx.Commands.RunInWslAsync( + distro, "bash -c 'systemctl --user stop openclaw-gateway 2>&1 || true'", + TimeSpan.FromSeconds(10), ct: cts.Token); + ctx.Logger.Info("[Uninstall] Stopped gateway service"); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + ctx.Logger.Warn("[Uninstall] systemctl stop timed out (5s); distro may be wedged — wsl --unregister will force-terminate"); + } + } +} + +// ═══════════════════════════════════════════════════════════════════ +// PAIRING STEPS +// ═══════════════════════════════════════════════════════════════════ + +public sealed class MintBootstrapTokenStep : SetupStep +{ + public override string Id => "mint-token"; + public override string DisplayName => "Mint bootstrap token"; + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + + // Token was already set by ConfigureGatewayStep + if (string.IsNullOrWhiteSpace(ctx.SharedGatewayToken)) + return StepResult.Fail("No shared gateway token set by previous step"); + + // Mint a bootstrap/QR token + var env = new Dictionary + { + ["OPENCLAW_GATEWAY_TOKEN"] = ctx.SharedGatewayToken + }; + + var mint = await ctx.Commands.RunInWslAsync( + distro, $"{ctx.WslPathPrefix} && openclaw qr --json", TimeSpan.FromSeconds(30), env, ct); + + if (mint.ExitCode == 0 && !string.IsNullOrWhiteSpace(mint.Stdout)) + { + // Parse bootstrap token from JSON output + try + { + if (TryReadBootstrapToken(mint.Stdout.Trim(), out var bootstrapToken, out var source)) + { + ctx.BootstrapToken = bootstrapToken; + ctx.Logger.StateChange("bootstrap_token", null, "[SET]"); + return StepResult.Ok($"Bootstrap token minted from {source}"); + } + } + catch (JsonException ex) + { + ctx.Logger.Warn($"Failed to parse QR JSON: {ex.Message}"); + } + } + + ctx.Logger.Warn("QR/bootstrap token mint failed or did not return a bootstrapToken/setupCode"); + return StepResult.Fail("Could not mint bootstrap token; refusing to use the shared gateway token as bootstrap."); + } + + internal static bool TryReadBootstrapToken(string json, out string? token, out string? source) + { + using var doc = JsonDocument.Parse(json); + foreach (var propertyName in new[] { "bootstrapToken", "setupCode" }) + { + if (doc.RootElement.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(property.GetString())) + { + token = property.GetString(); + source = propertyName; + return true; + } + } + + token = null; + source = null; + return false; + } +} + +public sealed class PairOperatorStep : SetupStep +{ + public override string Id => "pair-operator"; + public override string DisplayName => "Pair operator connection"; + public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var gatewayUrl = ctx.GatewayUrl!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + + if (string.IsNullOrEmpty(token)) + return StepResult.Terminal("No credential available for operator pairing"); + + // Register gateway in registry (only once — reuse across retries) + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + + string identityPath; + if (!string.IsNullOrEmpty(ctx.GatewayRecordId)) + { + var existing = registry.GetById(ctx.GatewayRecordId); + if (existing == null) + return StepResult.Fail($"Gateway record {ctx.GatewayRecordId} not found"); + identityPath = registry.GetIdentityDirectory(existing.Id); + ctx.Logger.Info($"Reusing existing gateway record: id={existing.Id}"); + } + else + { + var record = new GatewayRecord + { + Id = Guid.NewGuid().ToString("N")[..16], + Url = gatewayUrl, + FriendlyName = $"Local ({ctx.DistroName})", + SharedGatewayToken = ctx.SharedGatewayToken, + BootstrapToken = ctx.BootstrapToken, + IsLocal = true, + SetupManagedDistroName = ctx.DistroName, + LastConnected = DateTime.UtcNow + }; + + record = registry.AddOrUpdate(record); + registry.SetActive(record.Id); + registry.Save(); + ctx.GatewayRecordId = record.Id; + identityPath = registry.GetIdentityDirectory(record.Id); + ctx.Logger.Info($"Gateway record created: id={record.Id}"); + } + + // Initialize device identity + Directory.CreateDirectory(identityPath); + var identity = new DeviceIdentity(identityPath); + identity.Initialize(); + ctx.Logger.Info($"Device identity initialized: {identity.DeviceId[..16]}..."); + ctx.OperatorDeviceId = identity.DeviceId; + + // Connect operator WebSocket — handle pairing-required flow + var wsLogger = new SetupOpenClawLogger(ctx.Logger); + OpenClawGatewayClient? client = null; + + try + { + // Phase 1: Initial connect (may get PAIRING_REQUIRED) + client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath); + client.UseV2Signature = true; // Local gateway uses v2 signature format + var phase1Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (phase1Result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Operator connected directly (no pairing needed)"); + return StepResult.Ok("Operator connected and paired"); + } + + if (phase1Result == ConnectionOutcome.PairingRequired) + { + if (!ctx.Config.AutoApprovePairing) + return StepResult.Fail("Pairing required but auto-approve is disabled"); + + ctx.Logger.Info("Pairing required — auto-approving via CLI"); + var requestId = client.PairingRequiredRequestId; + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Auto-approve the pending pairing request + var approveResult = await AutoApprovePairing(ctx, requestId, ct); + if (!approveResult.IsSuccess) + return approveResult; + + // Wait for gateway to process the approval + await Task.Delay(2000, ct); + + // Phase 2: Reconnect — the device should now be approved + client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath); + client.UseV2Signature = true; + var phase2Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(20), ct); + + if (phase2Result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Operator paired successfully after approval"); + // Disconnect before finalization + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Phase 3: Skip operator finalization here — it must happen AFTER node pairing. + // The node pairing changes the device's "current metadata" to node/node-host, + // so operator finalization (as cli/cli) must come last to match what the tray sends. + ctx.Logger.Info("Operator paired — finalization deferred to after node pairing"); + return StepResult.Ok("Operator paired (finalization deferred)"); + } + + return StepResult.Fail($"Reconnection after approval failed: {phase2Result}"); + } + + return StepResult.Fail($"Operator connection failed: {phase1Result}"); + } + catch (Exception ex) + { + return StepResult.Fail($"Operator pairing failed: {ex.Message}", ex); + } + finally + { + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + /// + /// After initial pairing, the gateway knows us via auth.token (shared gateway token). + /// The tray will connect using auth.deviceToken (the token we just received). + /// This "finalizes" the transition so the gateway doesn't flag it as metadata-upgrade. + /// + private static async Task FinalizeWithDeviceToken( + SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct) + { + ctx.Logger.Info("Finalizing: reconnect with device token (like tray will)"); + + // Read the device token we just stored + var identity = new DeviceIdentity(identityPath); + identity.Initialize(); + var deviceToken = identity.DeviceToken; + + if (string.IsNullOrEmpty(deviceToken)) + { + ctx.Logger.Warn("No device token stored after pairing — skipping finalization"); + return StepResult.Ok("Operator paired (no finalization needed)"); + } + + // Wait for the gateway's internal session grace period to expire. + // Without this delay, the gateway accepts the deviceToken connect within grace + // but would later reject the tray's identical connect as "metadata-upgrade". + ctx.Logger.Info("Waiting for gateway grace period to expire before finalization..."); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + + // Connect exactly as the tray would: pass deviceToken as the credential + var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); + finalClient.UseV2Signature = true; + + try + { + var result = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (result == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Finalization connected — tray will connect seamlessly"); + return StepResult.Ok("Operator paired and finalized for tray"); + } + + if (result == ConnectionOutcome.PairingRequired) + { + ctx.Logger.Info("Metadata-upgrade detected during finalization — auto-approving"); + var requestId = finalClient.PairingRequiredRequestId; + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + finalClient = null; + + // Approve the metadata-upgrade + var approveResult = await AutoApprovePairing(ctx, requestId, ct); + if (!approveResult.IsSuccess) + return StepResult.Fail($"Finalization approval failed: {approveResult.Message}"); + + await Task.Delay(2000, ct); + + // One more connect to confirm + finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); + finalClient.UseV2Signature = true; + var finalResult = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (finalResult == ConnectionOutcome.Connected) + { + ctx.Logger.Info("Finalization approved — tray will connect seamlessly"); + return StepResult.Ok("Operator paired and finalized for tray"); + } + + return StepResult.Fail($"Finalization failed after approval: {finalResult}"); + } + + return StepResult.Fail($"Finalization connect failed: {result}"); + } + finally + { + if (finalClient != null) + { + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + } + } + } + + internal static async Task AutoApprovePairing(SetupContext ctx, CancellationToken ct) + => await AutoApprovePairing(ctx, requestId: null, ct); + + internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + + if (string.IsNullOrWhiteSpace(requestId)) + { + var preview = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && openclaw devices approve --latest --json""", + TimeSpan.FromSeconds(30), env, ct); + + ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}"); + + var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim()); + if (!parsed.Success) + { + ctx.Logger.Warn($"Could not select pairing request: {parsed.Error}"); + return StepResult.Fail("Could not find a safe pending pairing request to approve"); + } + + requestId = parsed.RequestId; + } + + if (!ApprovalRequestHelper.IsSafeRequestId(requestId)) + { + ctx.Logger.Warn("Refusing to approve pairing request with unsafe request ID"); + return StepResult.Fail("Pairing request ID contained unsafe characters"); + } + + ctx.Logger.Info($"Approving pairing request: {requestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!); + + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", + TimeSpan.FromSeconds(30), approvalEnv, ct); + + ctx.Logger.Info($"Approve result: exit={approve.ExitCode}"); + + if (approve.ExitCode != 0) + return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); + + return StepResult.Ok($"Approved request {requestId}"); + } + + internal enum ConnectionOutcome { Connected, PairingRequired, Error, Timeout } + + internal static async Task WaitForConnectionOrPairing( + OpenClawGatewayClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct) + { + 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); + } + } + + client.StatusChanged += OnStatusChanged; + EventHandler onDeviceToken = (_, _) => ctx.Logger.Info("Device token received from gateway"); + client.DeviceTokenReceived += onDeviceToken; + + try + { + await client.ConnectAsync(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + return await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) + { + return ConnectionOutcome.Timeout; + } + catch (Exception ex) + { + ctx.Logger.Warn($"Operator connection failed: {ex.Message}"); + return ConnectionOutcome.Error; + } + finally + { + client.StatusChanged -= OnStatusChanged; + client.DeviceTokenReceived -= onDeviceToken; + } + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + + // Find all local gateway records to remove (mirrors old uninstall step 6a) + var localRecords = registry.GetAll() + .Where(r => IsSetupManagedLocalRecord(r, ctx)) + .ToList(); + + if (localRecords.Count > 0) + { + foreach (var record in localRecords) + { + // Remove identity directory + var identityDir = registry.GetIdentityDirectory(record.Id); + if (Directory.Exists(identityDir)) + { + Directory.Delete(identityDir, recursive: true); + ctx.Logger.Info($"[Uninstall] Deleted identity directory: {identityDir}"); + } + registry.Remove(record.Id); + } + registry.Save(); + ctx.Logger.Info($"[Uninstall] Removed {localRecords.Count} local gateway record(s)"); + } + else + { + ctx.Logger.Info("[Uninstall] No local gateway records found"); + } + + // Null operator device token (mirrors old uninstall step 7) + // Check if external gateways remain — if so, preserve root device tokens + var hasExternalGateways = registry.GetAll().Any(r => + !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url))); + + if (hasExternalGateways) + { + ctx.Logger.Info("[Uninstall] Preserving root device tokens — external gateway records remain"); + } + else + { + var operatorCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "operator"); + ctx.Logger.Info(operatorCleared + ? "[Uninstall] Cleared operator device token" + : "[Uninstall] Operator device token already absent"); + } + + // Best-effort revoke operator token via gateway HTTP endpoint (mirrors old step 4) + await TryRevokeOperatorTokenAsync(ctx, ct); + } + + internal static bool IsSetupManagedLocalRecord(GatewayRecord record, SetupContext ctx) + { + if (!record.IsLocal || record.SshTunnel != null) + return false; + + if (string.Equals(record.SetupManagedDistroName, ctx.DistroName, StringComparison.Ordinal)) + return true; + + return string.IsNullOrWhiteSpace(record.SetupManagedDistroName) + && string.Equals(record.Url, ctx.GatewayUrl, StringComparison.OrdinalIgnoreCase) + && string.Equals(record.FriendlyName, $"Local ({ctx.DistroName})", StringComparison.Ordinal); + } + + private static async Task TryRevokeOperatorTokenAsync(SetupContext ctx, CancellationToken ct) + { + try + { + // Read settings.json for legacy token if available + var settingsPath = Path.Combine(ctx.DataDir, "settings.json"); + if (!File.Exists(settingsPath)) return; + + var settingsJson = await File.ReadAllTextAsync(settingsPath, ct); + using var doc = JsonDocument.Parse(settingsJson); + + string? token = null; + if (doc.RootElement.TryGetProperty("Token", out var tokenProp)) + token = tokenProp.GetString(); + + if (string.IsNullOrWhiteSpace(token)) return; + + var gatewayUrl = ctx.GatewayUrl ?? "ws://localhost:18789"; + var httpBase = gatewayUrl + .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) + .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase) + .TrimEnd('/'); + + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}"); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + var response = await http.PostAsync($"{httpBase}/api/v1/operator/disconnect", content: null, cts.Token); + ctx.Logger.Info($"[Uninstall] Revoke operator token: HTTP {(int)response.StatusCode}"); + } + catch (Exception ex) + { + ctx.Logger.Info($"[Uninstall] Best-effort token revoke failed ({ex.GetType().Name}); gateway may be down"); + } + } +} + +public sealed class PairNodeStep : SetupStep +{ + public override string Id => "pair-node"; + public override string DisplayName => "Pair node connection"; + public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var gatewayUrl = ctx.GatewayUrl!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + + if (string.IsNullOrEmpty(token)) + return StepResult.Terminal("No credential available for node pairing"); + + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + var record = registry.GetById(ctx.GatewayRecordId!); + if (record == null) + return StepResult.Fail("Gateway record not found in registry"); + + var identityPath = registry.GetIdentityDirectory(record.Id); + + // Verify gateway is reachable before connecting + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var resp = await http.GetAsync($"http://localhost:{ctx.Config.GatewayPort}/", ct); + ctx.Logger.Debug($"Gateway health check: HTTP {(int)resp.StatusCode}"); + } + catch (Exception ex) + { + return StepResult.Fail($"Gateway not reachable before node pairing: {ex.Message}"); + } + + var wsLogger = new SetupOpenClawLogger(ctx.Logger); + WindowsNodeClient? client = null; + + try + { + // Phase 1: Connect (may get PAIRING_REQUIRED) + client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger); + client.UseV2Signature = true; + + // Register capabilities BEFORE connect — gateway stores them from hello message + RegisterCapabilitiesFromConfig(client, ctx); + + var outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (outcome.Outcome == NodeConnectionOutcome.Connected) + { + ctx.NodeDeviceId = client.ShortDeviceId; + ctx.Logger.Info($"Node connected directly: {ctx.NodeDeviceId}"); + return StepResult.Ok("Node connected and paired"); + } + + if (outcome.Outcome == NodeConnectionOutcome.PairingRequired) + { + if (!ctx.Config.AutoApprovePairing) + return StepResult.Fail("Node pairing required but auto-approve is disabled"); + + ctx.Logger.Info("Node pairing required — auto-approving via CLI"); + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, ct); + if (!approveResult.IsSuccess) + return approveResult; + + await Task.Delay(2000, ct); + + // Phase 2: Reconnect after approval + client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger); + client.UseV2Signature = true; + RegisterCapabilitiesFromConfig(client, ctx); + + outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(20), ct); + if (outcome.Outcome == NodeConnectionOutcome.Connected) + { + ctx.NodeDeviceId = client.ShortDeviceId; + ctx.Logger.Info($"Node paired after approval: {ctx.NodeDeviceId}"); + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + // Skip node finalization — the operator finalization in VerifyEndToEndStep + // will be the last connect, ensuring operator metadata is "current". + // Node finalization would rotate tokens and potentially invalidate the operator token. + ctx.Logger.Info("Node paired — skipping node finalization (operator finalization is last)"); + return StepResult.Ok("Node paired successfully"); + } + + return StepResult.Fail($"Node reconnection after approval failed: {outcome.Outcome}"); + } + + return StepResult.Fail($"Node connection failed: {outcome.Outcome}"); + } + catch (Exception ex) + { + return StepResult.Fail($"Node pairing failed: {ex.Message}", ex); + } + finally + { + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + /// + /// After node pairing, finalize by connecting with the node device token to avoid + /// metadata-upgrade when the tray reconnects. + /// + private static async Task FinalizeNodeWithDeviceToken( + SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct) + { + ctx.Logger.Info("Finalizing node: reconnect with node device token"); + + var identity = new DeviceIdentity(identityPath); + identity.Initialize(); + var nodeToken = identity.NodeDeviceToken; + + if (string.IsNullOrEmpty(nodeToken)) + { + ctx.Logger.Warn("No node device token stored after pairing — skipping node finalization"); + return StepResult.Ok("Node paired (no finalization needed)"); + } + + // Wait for grace period (same as operator finalization) + ctx.Logger.Info("Waiting for gateway grace period before node finalization..."); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + + var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); + finalClient.UseV2Signature = true; + + try + { + var result = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (result.Outcome == NodeConnectionOutcome.Connected) + { + ctx.Logger.Info("Node finalization connected — tray will connect seamlessly"); + return StepResult.Ok("Node paired and finalized for tray"); + } + + if (result.Outcome == NodeConnectionOutcome.PairingRequired) + { + ctx.Logger.Info("Node metadata-upgrade detected — auto-approving"); + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + finalClient = null; + + var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, ct); + if (!approveResult.IsSuccess) + return StepResult.Fail($"Node finalization approval failed: {approveResult.Message}"); + + await Task.Delay(2000, ct); + + finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger); + finalClient.UseV2Signature = true; + var finalResult = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct); + + if (finalResult.Outcome == NodeConnectionOutcome.Connected) + { + ctx.Logger.Info("Node finalization approved — tray will connect seamlessly"); + return StepResult.Ok("Node paired and finalized for tray"); + } + + return StepResult.Fail($"Node finalization failed after approval: {finalResult.Outcome}"); + } + + return StepResult.Fail($"Node finalization failed: {result.Outcome}"); + } + finally + { + if (finalClient != null) + { + await finalClient.DisconnectAsync(); + finalClient.Dispose(); + } + } + } + + private enum NodeConnectionOutcome { Connected, PairingRequired, Error, Timeout } + + private sealed record NodeConnectionResult(NodeConnectionOutcome Outcome, string? RequestId = null); + + private static async Task WaitForNodeConnection( + WindowsNodeClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct) + { + var tcs = new TaskCompletionSource(); + string? pairingRequestId = null; + + void OnStatusChanged(object? sender, ConnectionStatus status) + { + ctx.Logger.Debug($"Node connection status: {status}"); + if (status == ConnectionStatus.Connected) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Connected)); + else if (status == ConnectionStatus.Error) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error)); + else if (status == ConnectionStatus.Disconnected) + { + if (client.IsPendingApproval) + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.PairingRequired, pairingRequestId)); + else + tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error)); + } + } + + void OnPairingStatusChanged(object? sender, PairingStatusEventArgs args) + { + if (args.Status == PairingStatus.Pending && ApprovalRequestHelper.IsSafeRequestId(args.RequestId)) + pairingRequestId = args.RequestId; + } + + client.StatusChanged += OnStatusChanged; + client.PairingStatusChanged += OnPairingStatusChanged; + + try + { + await client.ConnectAsync(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + return await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) + { + return new NodeConnectionResult(NodeConnectionOutcome.Timeout); + } + finally + { + client.StatusChanged -= OnStatusChanged; + client.PairingStatusChanged -= OnPairingStatusChanged; + } + } + + internal static async Task AutoApproveNodePairing(SetupContext ctx, string? requestId, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve"); + + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + var approvalKind = ApprovalRequestKind.Device; + + if (string.IsNullOrWhiteSpace(requestId)) + { + approvalKind = ApprovalRequestKind.Node; + var pending = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && openclaw nodes list --json""", + TimeSpan.FromSeconds(30), env, ct); + + ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}"); + + if (pending.ExitCode != 0) + return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pending.Stdout.Trim()}"); + + var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim()); + if (!parsed.Success) + { + ctx.Logger.Warn($"Could not select node pairing request: {parsed.Error}"); + return StepResult.Fail(parsed.Error ?? "Could not find a safe pending node pairing request"); + } + + requestId = parsed.RequestId; + } + + if (!ApprovalRequestHelper.IsSafeRequestId(requestId)) + return StepResult.Fail("Node pairing request ID contained unsafe characters"); + + ctx.Logger.Info($"Approving node pairing request: {requestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!); + + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(approvalKind)}""", + TimeSpan.FromSeconds(30), approvalEnv, ct); + + ctx.Logger.Info($"Node approve result: exit={approve.ExitCode}"); + + return approve.ExitCode == 0 + ? StepResult.Ok($"Node approved: {requestId}") + : StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}"); + } + + private static void RegisterCapabilitiesFromConfig(WindowsNodeClient client, SetupContext ctx) + { + var capabilities = ctx.Config.Capabilities.GetEnabledCapabilities(); + foreach (var (category, commands) in capabilities) + { + client.RegisterCapability(new StubNodeCapability(category, commands)); + } + if (ctx.Config.Settings.NodeCameraEnabled && ctx.Config.Capabilities.Camera) + client.SetPermission("camera.capture", true); + if (ctx.Config.Settings.NodeScreenEnabled && ctx.Config.Capabilities.Screen) + client.SetPermission("screen.record", true); + + ctx.Logger.Info($"Registered {capabilities.Count} capability categories with {capabilities.Sum(c => c.Commands.Length)} total commands"); + } + + public override Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + // Null node device token (mirrors old uninstall step 7 for node role) + // Only clear if no external gateways remain (same logic as PairOperatorStep) + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + var hasExternalGateways = registry.GetAll().Any(r => + !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url))); + + if (hasExternalGateways) + { + ctx.Logger.Info("[Uninstall] Preserving node device token — external gateway records remain"); + } + else + { + var nodeCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "node"); + ctx.Logger.Info(nodeCleared + ? "[Uninstall] Cleared node device token" + : "[Uninstall] Node device token already absent"); + } + + return Task.CompletedTask; + } +} + +public sealed class VerifyEndToEndStep : SetupStep +{ + public override string Id => "verify-e2e"; + public override string DisplayName => "Verify end-to-end connectivity"; + public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(3)); + + public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + // Verify gateway is still healthy + var distro = ctx.DistroName!; + var status = await ctx.Commands.RunInWslAsync( + distro, $"{ctx.WslPathPrefix} && openclaw gateway status --json", TimeSpan.FromSeconds(15), ct: ct); + + if (status.ExitCode != 0 || !status.Stdout.Contains("running", StringComparison.OrdinalIgnoreCase)) + return StepResult.Fail("Gateway is not running"); + + // Verify registry state + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + var record = registry.GetById(ctx.GatewayRecordId!); + if (record == null) + return StepResult.Fail("Gateway record missing from registry"); + + var identityPath = registry.GetIdentityDirectory(record.Id); + if (!DeviceIdentity.HasStoredDeviceToken(identityPath)) + { + ctx.Logger.Warn("No stored device token found — tray app may need to re-pair"); + } + else + { + ctx.Logger.Info("Device token present — performing final operator handshake"); + + // CRITICAL: The operator finalization must happen AFTER node pairing. + // Node pairing changes the device's "current metadata" to node-host/node. + // The tray connects as operator (cli/cli), so we must re-establish operator + // as the device's last-seen metadata. This prevents "metadata-upgrade" errors. + var wsLogger = new SetupOpenClawLogger(ctx.Logger); + var finalResult = await FinalizeOperatorForTray(ctx, ctx.GatewayUrl!, identityPath, wsLogger, ct); + if (!finalResult.IsSuccess) + return finalResult; + } + + // Write setup-state.json so tray knows the distro name for WSL keepalive + await WriteSetupStateAsync(ctx, ct); + + // Write settings.json with EnableNodeMode + capability toggles from config + WriteSettingsJson(ctx); + + // Drain any remaining pending approvals (device or node) so tray starts clean + var drainResult = await DrainPendingApprovalsAsync(ctx, ct); + if (!drainResult.IsSuccess) + return drainResult; + + ClearPersistedBootstrapCredentials(ctx); + + return StepResult.Ok("Gateway running; operator finalized; settings written for tray."); + } + + private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken; + if (string.IsNullOrWhiteSpace(token)) + return StepResult.Fail("No gateway token available to drain pending approvals"); + + var pathPrefix = ctx.WslPathPrefix; + var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token }; + const int maxDrainIterations = 10; + + for (var i = 0; i < maxDrainIterations; i++) + { + var preview = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && openclaw devices approve --latest --json""", + TimeSpan.FromSeconds(15), env, ct); + + if (preview.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || + preview.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim()); + if (parsed.Success) + { + ctx.Logger.Info($"Draining pending device approval: {parsed.RequestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!); + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", + TimeSpan.FromSeconds(15), approvalEnv, ct); + + if (approve.ExitCode != 0) + return StepResult.Fail($"Device approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); + + if (i == maxDrainIterations - 1) + return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain"); + + continue; + } + + if (preview.ExitCode == 0) + { + var approved = ApprovalRequestHelper.TryReadApprovedRequestId(preview.Stdout.Trim()); + if (approved.Success) + { + ctx.Logger.Info($"Drained pending device approval via latest command: {approved.RequestId}"); + if (i == maxDrainIterations - 1) + return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain"); + + continue; + } + } + + return StepResult.Fail($"Could not select pending device approval for drain (exit {preview.ExitCode}): {parsed.Error ?? preview.Stderr.Trim()}"); + } + + for (var i = 0; i < maxDrainIterations; i++) + { + var nodeList = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && openclaw nodes list --json""", + TimeSpan.FromSeconds(15), env, ct); + + var parsed = ApprovalRequestHelper.TryReadPendingRequestIds(nodeList.Stdout.Trim()); + if (!parsed.Success) + { + if (nodeList.ExitCode != 0) + return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim()); + + return StepResult.Fail($"Could not parse pending node approvals: {parsed.Error}"); + } + + if (parsed.RequestIds.Count == 0) + break; + + foreach (var requestId in parsed.RequestIds) + { + ctx.Logger.Info($"Draining pending node approval: {requestId}"); + var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId); + var approve = await ctx.Commands.RunInWslAsync( + distro, + $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", + TimeSpan.FromSeconds(15), approvalEnv, ct); + + if (approve.ExitCode != 0) + return StepResult.Fail($"Node approval drain failed for {requestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); + } + + if (i == maxDrainIterations - 1) + return StepResult.Fail("Node approval drain reached its iteration limit; pending approvals may remain"); + } + + return StepResult.Ok("Pending approvals drained"); + } + + private static void WriteSettingsJson(SetupContext ctx) + { + var settingsPath = Path.Combine(ctx.DataDir, "settings.json"); + ctx.Config.Settings.MergeIntoSettingsFile(settingsPath); + ctx.Logger.Info($"Wrote settings.json: EnableNodeMode={ctx.Config.Settings.EnableNodeMode}"); + } + + private static void ClearPersistedBootstrapCredentials(SetupContext ctx) + { + if (string.IsNullOrWhiteSpace(ctx.GatewayRecordId)) + return; + + var registry = new GatewayRegistry(ctx.DataDir); + registry.Load(); + var record = registry.GetById(ctx.GatewayRecordId); + if (record is null) + return; + + if (string.IsNullOrWhiteSpace(record.BootstrapToken)) + { + return; + } + + registry.AddOrUpdate(record with + { + BootstrapToken = null + }); + registry.Save(); + ctx.Logger.Info("Cleared persisted bootstrap gateway credential after device pairing"); + } + + /// + /// Final operator connect using device token — establishes operator/cli/cli as the + /// device's "current metadata" so the tray can connect without metadata-upgrade. + /// + private static async Task FinalizeOperatorForTray( + SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct) + { + var identity = new DeviceIdentity(identityPath); + identity.Initialize(); + var deviceToken = identity.DeviceToken; + + if (string.IsNullOrEmpty(deviceToken)) + return StepResult.Fail("No device token available for operator finalization"); + + // Wait for grace period to expire so this connect is treated as a real metadata change + ctx.Logger.Info("Waiting for grace period before final operator handshake..."); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + + var client = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath); + client.UseV2Signature = true; + + try + { + var result = await PairOperatorStep.WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (result == PairOperatorStep.ConnectionOutcome.Connected) + { + ctx.Logger.Info("Final operator handshake succeeded — tray will connect seamlessly"); + return StepResult.Ok("Operator finalized"); + } + + if (result == PairOperatorStep.ConnectionOutcome.PairingRequired) + { + ctx.Logger.Info("Metadata-upgrade detected — auto-approving for tray"); + await client.DisconnectAsync(); + client.Dispose(); + client = null; + + var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, ct); + if (!approveResult.IsSuccess) + return StepResult.Fail($"Operator finalization approval failed: {approveResult.Message}"); + + await Task.Delay(2000, ct); + + // After approval, the gateway rotates the device token. The old one is invalid. + // Clear the stale DeviceToken from the identity file so the client doesn't + // try to use it (OpenClawGatewayClient prefers stored DeviceToken over constructor token). + ctx.Logger.Info("Clearing stale operator device token from identity file"); + DeviceIdentity.TryClearDeviceToken(identityPath); + + // Reconnect with the SHARED GATEWAY TOKEN to get a fresh device token. + ctx.Logger.Info("Reconnecting with shared token to get fresh device token after approval"); + client = new OpenClawGatewayClient(gatewayUrl, ctx.SharedGatewayToken!, logger: wsLogger, identityPath: identityPath); + client.UseV2Signature = true; + var confirmResult = await PairOperatorStep.WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct); + + if (confirmResult == PairOperatorStep.ConnectionOutcome.Connected) + { + ctx.Logger.Info("Operator finalization approved — fresh device token stored, tray will connect seamlessly"); + return StepResult.Ok("Operator finalized after approval"); + } + + return StepResult.Fail($"Operator finalization failed after approval: {confirmResult}"); + } + + return StepResult.Fail($"Operator finalization failed: {result}"); + } + finally + { + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + private static async Task WriteSetupStateAsync(SetupContext ctx, CancellationToken ct) + { + var stateDir = ctx.LocalDataDir; + Directory.CreateDirectory(stateDir); + + var statePath = Path.Combine(stateDir, "setup-state.json"); + // Phase and Status must be integers matching the tray's LocalGatewaySetupPhase/Status enums. + // Phase.Complete = 13, Status.Complete = 7 + var state = new + { + SchemaVersion = 1, + RunId = Guid.NewGuid().ToString("N"), + InstallId = GetStableInstallId(ctx), + Phase = 13, + Status = 7, + DistroName = ctx.DistroName, + GatewayUrl = ctx.GatewayUrl, + IsLocalOnly = true, + FailureCode = (string?)null, + UserMessage = (string?)null, + CreatedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + Issues = Array.Empty(), + History = Array.Empty() + }; + + var json = System.Text.Json.JsonSerializer.Serialize(state, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + await AtomicFile.WriteAllTextAsync(statePath, json, ct); + ctx.Logger.Info($"Wrote setup-state.json: DistroName={ctx.DistroName}"); + } + + private static string GetStableInstallId(SetupContext ctx) + => !string.IsNullOrWhiteSpace(ctx.GatewayRecordId) + ? $"gateway:{ctx.GatewayRecordId}" + : $"distro:{ctx.DistroName}"; +} + +// ─── Step 16: Start WSL Keepalive ─── + +public sealed class StartKeepaliveStep : SetupStep +{ + public override string Id => "start-keepalive"; + public override string DisplayName => "Start WSL keepalive"; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName!; + ctx.Logger.Info($"Launching persistent keepalive for distro: {distro}"); + + var markerPath = GetKeepaliveMarkerPath(ctx); + if (TryGetExistingKeepalive(markerPath, distro, out var existingPid)) + { + ctx.Logger.Info($"Keepalive already running for distro '{distro}' (PID {existingPid})"); + return Task.FromResult(StepResult.Ok("Keepalive already running")); + } + + if (File.Exists(markerPath)) + { + try { File.Delete(markerPath); } catch { } + } + + // Launch detached keepalive process — keeps the distro alive so port forwarding + // remains stable until the tray starts its own keepalive. + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = WslConstants.WslExePath, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(distro); + psi.ArgumentList.Add("--"); + psi.ArgumentList.Add("sleep"); + psi.ArgumentList.Add("infinity"); + + var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) + { + ctx.Logger.Warn("Failed to start keepalive process — tray will start its own"); + return Task.FromResult(StepResult.Ok()); + } + + ctx.Logger.Info($"Keepalive process started (PID {proc.Id}), distro will stay alive for tray launch"); + + // Write keepalive marker so tray doesn't spawn a duplicate + WriteKeepaliveMarker(ctx, markerPath, proc.Id); + + return Task.FromResult(StepResult.Ok()); + } + + private static void WriteKeepaliveMarker(SetupContext ctx, string markerPath, int pid) + { + var marker = new + { + DistroName = ctx.DistroName, + Pid = pid, + StartTimeUtc = DateTimeOffset.UtcNow, + ProcessName = "wsl" + }; + var json = System.Text.Json.JsonSerializer.Serialize(marker, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + AtomicFile.WriteAllText(markerPath, json); + ctx.Logger.Info($"Wrote keepalive marker: {markerPath}"); + } + + internal static string GetKeepaliveMarkerPath(SetupContext ctx) + => Path.Combine( + ctx.LocalDataDir, "wsl-keepalive", $"{ctx.DistroName}.json"); + + internal static bool TryGetExistingKeepalive(string markerPath, string distro, out int pid) + { + pid = 0; + if (!File.Exists(markerPath)) + return false; + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(markerPath)); + if (!doc.RootElement.TryGetProperty("Pid", out var pidElement) || !pidElement.TryGetInt32(out pid)) + return false; + + var process = System.Diagnostics.Process.GetProcessById(pid); + using (process) + { + if (process.HasExited) + return false; + + return IsKeepaliveCommandLine(GetProcessCommandLine(pid), distro); + } + } + catch + { + pid = 0; + return false; + } + } + + public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) + { + var distro = ctx.DistroName; + if (string.IsNullOrEmpty(distro)) + { + ctx.Logger.Info("[Uninstall] No distro name — skipping keepalive cleanup"); + return; + } + + // Kill keepalive wsl.exe processes for this distro. + // Pattern: wsl.exe -d -- sleep infinity + try + { + var procs = System.Diagnostics.Process.GetProcessesByName("wsl") + .Concat(System.Diagnostics.Process.GetProcessesByName("wsl.exe")); + + foreach (var proc in procs) + { + try + { + // Read command line via WMI/CIM + var cmdLine = GetProcessCommandLine(proc.Id); + if (IsKeepaliveCommandLine(cmdLine, distro)) + { + proc.Kill(entireProcessTree: true); + proc.WaitForExit(5000); + ctx.Logger.Info($"[Uninstall] Killed keepalive process tree PID {proc.Id}"); + } + } + catch { /* process may have exited */ } + finally { proc.Dispose(); } + } + } + catch (Exception ex) + { + ctx.Logger.Warn($"[Uninstall] Error enumerating keepalive processes: {ex.Message}"); + } + + // Delete keepalive marker file + var markerPath = GetKeepaliveMarkerPath(ctx); + var markerDir = Path.GetDirectoryName(markerPath)!; + + if (File.Exists(markerPath)) + { + File.Delete(markerPath); + ctx.Logger.Info($"[Uninstall] Deleted keepalive marker: {markerPath}"); + } + + // Clean up empty marker directory + if (Directory.Exists(markerDir) && !Directory.EnumerateFileSystemEntries(markerDir).Any()) + { + Directory.Delete(markerDir); + ctx.Logger.Info("[Uninstall] Deleted empty wsl-keepalive directory"); + } + + await Task.CompletedTask; + } + + internal static bool IsKeepaliveCommandLine(string? commandLine, string distro) + { + if (string.IsNullOrWhiteSpace(commandLine) || string.IsNullOrWhiteSpace(distro)) + return false; + + return commandLine.Contains(distro, StringComparison.OrdinalIgnoreCase) + && commandLine.Contains("sleep", StringComparison.OrdinalIgnoreCase) + && commandLine.Contains("infinity", StringComparison.OrdinalIgnoreCase); + } + + private static string? GetProcessCommandLine(int pid) + { + try + { + // Use WMI to get the command line + var result = new System.Diagnostics.Process(); + var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe", + $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = System.Diagnostics.Process.Start(psi); + if (p == null) return null; + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(5000); + return output.Trim(); + } + catch { return null; } + } +} + +public sealed class RunGatewayWizardStep : SetupStep +{ + public override string Id => "run-wizard"; + public override string DisplayName => "Run gateway wizard"; + public override bool CanRetry => false; + + public override bool CanSkip(SetupContext ctx) => ctx.Config.SkipWizard; + + public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct) + { + var runner = new SetupWizardRunner(ctx); + return runner.RunAsync(ct); + } +} diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs new file mode 100644 index 000000000..0e45820fd --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -0,0 +1,589 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + +public sealed class SetupWizardRunner +{ + private const int MaxWizardSteps = 50; + private const int MaxSameStepVisits = 3; + private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled); + private readonly SetupContext _ctx; + + public SetupWizardRunner(SetupContext ctx) + { + _ctx = ctx; + } + + public async Task RunAsync(CancellationToken ct) + { + var registry = new GatewayRegistry(_ctx.DataDir); + registry.Load(); + + var record = !string.IsNullOrWhiteSpace(_ctx.GatewayRecordId) + ? registry.GetById(_ctx.GatewayRecordId) + : registry.GetActive(); + + if (record == null) + return StepResult.Fail("Cannot run gateway wizard because no active gateway record was found."); + + var identityPath = registry.GetIdentityDirectory(record.Id); + var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(identityPath, new SetupOpenClawLogger(_ctx.Logger)); + var credential = storedDeviceToken + ?? _ctx.SharedGatewayToken + ?? record.SharedGatewayToken + ?? _ctx.BootstrapToken + ?? record.BootstrapToken; + + if (string.IsNullOrWhiteSpace(credential)) + return StepResult.Fail("Cannot run gateway wizard because no operator credential is available."); + + _ctx.SharedGatewayToken ??= record.SharedGatewayToken; + _ctx.BootstrapToken ??= record.BootstrapToken; + + if (string.IsNullOrWhiteSpace(storedDeviceToken) + && !string.IsNullOrWhiteSpace(record.SharedGatewayToken) + && string.Equals(credential, record.SharedGatewayToken, StringComparison.Ordinal)) + identityPath = Path.Combine(identityPath, "setup-wizard"); + + var wsLogger = new SetupOpenClawLogger(_ctx.Logger); + OpenClawGatewayClient? client = null; + + var sessionId = ""; + var wizardStarted = false; + var wizardCompleted = false; + var discoveredSteps = new List(); + + try + { + client = CreateWizardClient(credential, identityPath, wsLogger); + var connection = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(20), ct); + if (connection == PairOperatorStep.ConnectionOutcome.PairingRequired && _ctx.Config.AutoApprovePairing) + { + _ctx.Logger.Info("Wizard operator pairing required — auto-approving"); + await client.DisconnectAsync(); + client.Dispose(); + + var approval = await PairOperatorStep.AutoApprovePairing(_ctx, ct); + if (!approval.IsSuccess) + return approval; + + await Task.Delay(2000, ct); + client = CreateWizardClient(credential, identityPath, wsLogger); + connection = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(20), ct); + } + + if (connection != PairOperatorStep.ConnectionOutcome.Connected) + 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); + wizardStarted = true; + + var visits = new Dictionary(StringComparer.OrdinalIgnoreCase); + var restartAttempts = 0; + for (var i = 0; i < MaxWizardSteps; i++) + { + ct.ThrowIfCancellationRequested(); + + var parsed = WizardPayload.Parse(payload); + if (parsed.IsDone) + { + if (!string.IsNullOrWhiteSpace(parsed.Error)) + { + if (IsKnownGatewayFinalizationPromptBug(parsed.Error)) + { + wizardCompleted = true; + _ctx.Logger.Warn($"Gateway wizard ended after applying setup but hit known finalization prompt bug: {parsed.Error}"); + return StepResult.Ok("Gateway wizard completed with non-fatal finalization prompt warning"); + } + + return StepResult.Fail($"Gateway wizard failed: {parsed.Error}"); + } + + if (discoveredSteps.Count > 0) + WriteAnswerTemplate(discoveredSteps, missingStep: null); + + wizardCompleted = true; + _ctx.Logger.Info("Gateway wizard completed"); + return StepResult.Ok("Gateway wizard completed"); + } + + if (!string.IsNullOrWhiteSpace(parsed.Error)) + return StepResult.Fail($"Gateway wizard returned an invalid step: {parsed.Error}"); + + if (!string.IsNullOrWhiteSpace(parsed.SessionId)) + sessionId = parsed.SessionId; + + if (string.IsNullOrWhiteSpace(sessionId)) + return StepResult.Fail("Gateway wizard did not provide a session id."); + + if (string.IsNullOrWhiteSpace(parsed.StepId)) + return StepResult.Fail("Gateway wizard step is missing an id."); + + var visitKey = $"{parsed.StepId}:{parsed.StepIndex}"; + visits.TryGetValue(visitKey, out var visitCount); + visits[visitKey] = visitCount + 1; + if (visits[visitKey] > MaxSameStepVisits) + { + var templatePath = WriteAnswerTemplate(discoveredSteps, parsed); + return StepResult.Fail($"Gateway wizard repeated step '{parsed.StepId}' too many times. A wizard answer template was written to: {templatePath}"); + } + + discoveredSteps.Add(WizardTemplateStep.From(parsed)); + var answerResult = ResolveAnswer(parsed, _ctx.Config.WizardAnswers); + if (!answerResult.Success) + { + var templatePath = WriteAnswerTemplate(discoveredSteps, parsed); + return StepResult.Fail($"{answerResult.Error} A wizard answer template was written to: {templatePath}"); + } + + _ctx.Logger.Info(answerResult.HasAnswer + ? $"Wizard step '{parsed.StepId}' ({parsed.StepType}, key={StableAnswerKey(parsed.Title, parsed.Message, parsed.StepId)}) answered with {(parsed.Sensitive ? "[sensitive]" : $"'{answerResult.Answer}'")}" + : $"Wizard step '{parsed.StepId}' ({parsed.StepType}) continuing without explicit answer"); + + var parameters = answerResult.HasAnswer + ? new + { + sessionId, + answer = new + { + stepId = parsed.StepId, + value = AnswerValueForWire(parsed, answerResult.Answer) + } + } + : (object)new { sessionId }; + + try + { + payload = await client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutFor(parsed)); + } + catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2) + { + restartAttempts++; + _ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}"); + + try { await client.DisconnectAsync(); } catch { } + client.Dispose(); + + await Task.Delay(TimeSpan.FromSeconds(3), ct); + client = CreateWizardClient(credential, identityPath, wsLogger); + var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct); + if (reconnect != PairOperatorStep.ConnectionOutcome.Connected) + return StepResult.Fail($"Gateway wizard reconnect failed after restart: {reconnect}"); + + sessionId = ""; + visits.Clear(); + discoveredSteps.Clear(); + payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000); + } + } + + return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps."); + } + catch (OperationCanceledException) + { + if (client is not null && wizardStarted && !string.IsNullOrWhiteSpace(sessionId)) + await TryCancelWizardAsync(client, sessionId); + throw; + } + catch (Exception ex) + { + return StepResult.Fail($"Gateway wizard failed: {ex.Message}", ex); + } + finally + { + if (client is not null && wizardStarted && !wizardCompleted && !string.IsNullOrWhiteSpace(sessionId)) + await TryCancelWizardAsync(client, sessionId); + + if (wizardStarted) + await TryResetReloadModeAsync(); + + if (client != null) + { + await client.DisconnectAsync(); + client.Dispose(); + } + } + } + + private OpenClawGatewayClient CreateWizardClient(string credential, string identityPath, IOpenClawLogger wsLogger) + { + return new OpenClawGatewayClient(_ctx.GatewayUrl!, credential, logger: wsLogger, identityPath: identityPath) + { + UseV2Signature = true + }; + } + + private async Task TryCancelWizardAsync(OpenClawGatewayClient client, string sessionId) + { + try + { + _ctx.Logger.Warn("Cancelling gateway wizard session"); + await client.SendWizardRequestAsync("wizard.cancel", new { sessionId }, timeoutMs: 10_000); + } + catch (Exception ex) + { + _ctx.Logger.Warn($"Failed to cancel gateway wizard session: {ex.Message}"); + } + } + + private async Task TryResetReloadModeAsync() + { + try + { + var result = await _ctx.Commands.RunInWslAsync( + _ctx.DistroName!, + $"{_ctx.WslPathPrefix} && openclaw config set gateway.reload.mode hybrid", + 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()}"); + } + catch (Exception ex) + { + _ctx.Logger.Warn($"Failed to reset gateway.reload.mode after wizard: {ex.Message}"); + } + } + + private string WriteAnswerTemplate(IReadOnlyList discoveredSteps, WizardPayload? missingStep) + { + var logPath = _ctx.Config.LogPath; + var basePath = !string.IsNullOrWhiteSpace(logPath) + ? Path.ChangeExtension(logPath, ".wizard-answers.template.json") + : Path.Combine(_ctx.DataDir, "Logs", "Setup", $"setup-engine-{_ctx.Logger.RunId}.wizard-answers.template.json"); + + Directory.CreateDirectory(Path.GetDirectoryName(basePath)!); + + var answers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var step in discoveredSteps) + { + var key = StableAnswerKey(step.Title, step.Message, step.StepId); + if (string.IsNullOrWhiteSpace(key)) + continue; + + answers.TryAdd(key, missingStep != null && step.StepId == missingStep.StepId + ? AnswerPlaceholderFor(step) + : step.SuggestedAnswer ?? AnswerPlaceholderFor(step)); + } + + var template = new + { + _instructions = "Copy WizardAnswers into your setup config, fill required values, then rerun setup.", + WizardAnswers = answers, + Steps = discoveredSteps + }; + + var json = JsonSerializer.Serialize(template, new JsonSerializerOptions { WriteIndented = true }); + AtomicFile.WriteAllText(basePath, json); + _ctx.Logger.Info($"Wizard answer template written: {basePath}"); + return basePath; + } + + private static AnswerResolution ResolveAnswer(WizardPayload step, Dictionary? configuredAnswers) + { + if (TryGetConfiguredAnswer(step, configuredAnswers, out var configured)) + return ValidateAnswer(step, configured, configuredAnswer: true); + + var inferred = step.StepType switch + { + "note" => "true", + "confirm" => InferConfirmAnswer(step), + "select" => InferOptionAnswer(step), + "multiselect" => InferOptionAnswer(step), + "text" => InferTextAnswer(step), + _ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null + }; + + if (inferred == null) + { + return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a text answer."); + } + + return ValidateAnswer(step, inferred, configuredAnswer: false); + } + + private static string? InferOptionAnswer(WizardPayload step) + { + if (!string.IsNullOrWhiteSpace(step.InitialValue)) + return step.InitialValue; + + var preferred = new[] { "__skip__", "skip", "__keep__", "keep" }; + foreach (var value in preferred) + { + if (step.Options.Any(o => string.Equals(o.Value, value, StringComparison.Ordinal))) + return value; + } + + return step.Options.FirstOrDefault()?.Value; + } + + private static string InferConfirmAnswer(WizardPayload step) + { + var text = $"{step.Title} {step.Message}"; + if (text.Contains("skill", StringComparison.OrdinalIgnoreCase) + || text.Contains("API_KEY", StringComparison.OrdinalIgnoreCase) + || text.Contains("API key", StringComparison.OrdinalIgnoreCase)) + return "false"; + + return "true"; + } + + private static object AnswerValueForWire(WizardPayload step, string answer) + { + if (step.StepType == "confirm" && bool.TryParse(answer, out var booleanAnswer)) + return booleanAnswer; + + if (step.StepType == "multiselect") + { + if (string.Equals(answer, "__skip__", StringComparison.Ordinal)) + return new[] { "__skip__" }; + + return SplitMultiSelect(answer); + } + + return answer; + } + + private static string? InferTextAnswer(WizardPayload step) + { + if (!string.IsNullOrWhiteSpace(step.InitialValue)) + return step.InitialValue; + + if (step.Sensitive && step.Message.Contains("API_KEY", StringComparison.OrdinalIgnoreCase)) + return ""; + + return null; + } + + private static AnswerResolution ValidateAnswer(WizardPayload step, string answer, bool configuredAnswer) + { + if (step.StepType == "select") + { + if (!step.Options.Any(o => string.Equals(o.Value, answer, StringComparison.Ordinal))) + { + var source = configuredAnswer ? "configured" : "default"; + return AnswerResolution.Fail($"The {source} answer for wizard step '{step.StepId}' is not one of the gateway-provided options."); + } + } + else if (step.StepType == "multiselect") + { + var values = SplitMultiSelect(answer); + if (values.Length == 0) + return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' requires at least one selected value."); + + var validValues = step.Options.Select(o => o.Value).ToHashSet(StringComparer.Ordinal); + var invalid = values.FirstOrDefault(v => !validValues.Contains(v)); + if (invalid != null) + return AnswerResolution.Fail($"Wizard step '{step.StepId}' has invalid selected value '{invalid}'."); + } + + return AnswerResolution.Ok(answer); + } + + private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary? answers, out string answer) + { + answer = ""; + if (answers is not { Count: > 0 }) + return false; + + var keys = new[] + { + step.StepId, + NormalizeKey(step.StepId), + step.Title, + NormalizeKey(step.Title), + step.Message, + NormalizeKey(step.Message) + }.Where(k => !string.IsNullOrWhiteSpace(k)).Distinct(StringComparer.OrdinalIgnoreCase); + + foreach (var key in keys) + { + foreach (var (configuredKey, configuredValue) in answers) + { + if (string.Equals(configuredKey, key, StringComparison.OrdinalIgnoreCase)) + { + answer = configuredValue; + return true; + } + } + } + + return false; + } + + private static int TimeoutFor(WizardPayload step) + { + var text = $"{step.Title} {step.Message}"; + return text.Contains("device", StringComparison.OrdinalIgnoreCase) + || text.Contains("authorize", StringComparison.OrdinalIgnoreCase) + || text.Contains("login", StringComparison.OrdinalIgnoreCase) + || text.Contains("sign in", StringComparison.OrdinalIgnoreCase) + || text.Contains("oauth", StringComparison.OrdinalIgnoreCase) + ? 300_000 + : 30_000; + } + + private static bool IsRestartLikeWizardDisconnect(Exception ex) + { + return ex.Message.Contains("connection lost", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("gateway restarting", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("service restart", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsKnownGatewayFinalizationPromptBug(string error) + { + return error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase); + } + + private static string AnswerPlaceholderFor(WizardTemplateStep step) + { + return step.Type switch + { + "select" => step.Options.FirstOrDefault()?.Value ?? "