diff --git a/.gitattributes b/.gitattributes index c1965c216..dfa6e37ca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,6 @@ -.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file +.github/workflows/*.lock.yml linguist-generated=true merge=ours + +# Shell scripts that run inside WSL/Linux MUST keep LF line endings even +# when checked out on Windows. CRLF breaks "set -euo pipefail" and friends +# (the trailing \r becomes part of the token, bash rejects it). +*.sh text eol=lf diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md index e2e2b2b58..cd376c5b1 100644 --- a/.github/agents/agentic-workflows.agent.md +++ b/.github/agents/agentic-workflows.agent.md @@ -19,6 +19,7 @@ This is a **dispatcher agent** that routes your request to the appropriate speci - **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt - **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes - **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs +- **CLI commands and triggering workflows**: Routes to `cli-commands` guide — consult this whenever the user asks how to run, compile, debug, or manage workflows from the command line, or when they need the MCP tool equivalent of a `gh aw` command Workflows may optionally include: @@ -30,7 +31,7 @@ Workflows may optionally include: - Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` - Workflow lock files: `.github/workflows/*.lock.yml` - Shared components: `.github/workflows/shared/*.md` -- Configuration: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/github-agentic-workflows.md +- Configuration: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/github-agentic-workflows.md ## Problems This Solves @@ -52,7 +53,7 @@ When you interact with this agent, it will: ### Create New Workflow **Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/create-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/create-agentic-workflow.md **Use cases**: - "Create a workflow that triages issues" @@ -62,7 +63,7 @@ When you interact with this agent, it will: ### Update Existing Workflow **Load when**: User wants to modify, improve, or refactor an existing workflow -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/update-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/update-agentic-workflow.md **Use cases**: - "Add web-fetch tool to the issue-classifier workflow" @@ -72,7 +73,7 @@ When you interact with this agent, it will: ### Debug Workflow **Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/debug-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/debug-agentic-workflow.md **Use cases**: - "Why is this workflow failing?" @@ -82,7 +83,7 @@ When you interact with this agent, it will: ### Upgrade Agentic Workflows **Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/upgrade-agentic-workflows.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/upgrade-agentic-workflows.md **Use cases**: - "Upgrade all workflows to the latest version" @@ -92,7 +93,7 @@ When you interact with this agent, it will: ### Create a Report-Generating Workflow **Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/report.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/report.md **Use cases**: - "Create a weekly CI health report" @@ -102,7 +103,7 @@ When you interact with this agent, it will: ### Create Shared Agentic Workflow **Load when**: User wants to create a reusable workflow component or wrap an MCP server -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/create-shared-agentic-workflow.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/create-shared-agentic-workflow.md **Use cases**: - "Create a shared component for Notion integration" @@ -112,7 +113,7 @@ When you interact with this agent, it will: ### Fix Dependabot PRs **Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`) -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/dependabot.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/dependabot.md **Use cases**: - "Fix the open Dependabot PRs for npm dependencies" @@ -122,13 +123,24 @@ When you interact with this agent, it will: ### Analyze Test Coverage **Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy. -**Prompt file**: https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/test-coverage.md +**Prompt file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/test-coverage.md **Use cases**: - "Create a workflow that comments coverage on PRs" - "Analyze coverage trends over time" - "Add a coverage gate that blocks PRs below a threshold" +### CLI Commands Reference +**Load when**: The user asks how to run, compile, debug, or manage workflows from the command line; needs the MCP tool equivalent of a `gh aw` command; or is in a restricted environment (e.g., Copilot Cloud) without direct CLI access. + +**Reference file**: https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/cli-commands.md + +**Use cases**: +- "How do I trigger workflow X on the main branch?" +- "What's the MCP equivalent of `gh aw logs`?" +- "I'm in Copilot Cloud — how do I compile a workflow?" +- "Show me all available gh aw commands" + ## Instructions When a user interacts with you: @@ -147,6 +159,10 @@ gh aw init # Generate the lock file for a workflow gh aw compile [workflow-name] +# Trigger a workflow on demand (preferred over gh workflow run) +gh aw run # interactive input collection +gh aw run --ref main # run on a specific branch + # Debug workflow runs gh aw logs [workflow-name] gh aw audit @@ -169,10 +185,12 @@ gh aw compile --validate ## Important Notes -- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/github-agentic-workflows.md for complete documentation +- Always reference the instructions file at https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/github-agentic-workflows.md for complete documentation - Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud - Workflows must be compiled to `.lock.yml` files before running in GitHub Actions - **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF - Follow security best practices: minimal permissions, explicit network access, no template injection -- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.68.3/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns. +- **Network configuration**: Use ecosystem identifiers (`node`, `python`, `go`, etc.) or explicit FQDNs in `network.allowed`. Bare shorthands like `npm` or `pypi` are **not** valid. See https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/network.md for the full list of valid ecosystem identifiers and domain patterns. - **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself. +- **Triggering runs**: Always use `gh aw run ` to trigger a workflow on demand — not `gh workflow run .lock.yml`. `gh aw run` handles workflow resolution by short name, input parsing and validation, and correct run-tracking for agentic workflows. Use `--ref ` to run on a specific branch. +- **CLI commands reference**: For a complete guide on all `gh aw` commands and their MCP tool equivalents (for restricted environments), see https://github.com/github/gh-aw/blob/v0.72.1/.github/aw/cli-commands.md diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json index 365b6d5d0..cf1ec6f48 100644 --- a/.github/aw/actions-lock.json +++ b/.github/aw/actions-lock.json @@ -1,14 +1,34 @@ { "entries": { - "github/gh-aw-actions/setup@v0.68.3": { - "repo": "github/gh-aw-actions/setup", - "version": "v0.68.3", - "sha": "ba90f2186d7ad780ec640f364005fa24e797b360" + "actions/checkout@v6.0.2": { + "repo": "actions/checkout", + "version": "v6.0.2", + "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" + }, + "actions/download-artifact@v8.0.1": { + "repo": "actions/download-artifact", + "version": "v8.0.1", + "sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" + }, + "actions/github-script@v9.0.0": { + "repo": "actions/github-script", + "version": "v9.0.0", + "sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3" + }, + "actions/setup-node@v6.4.0": { + "repo": "actions/setup-node", + "version": "v6.4.0", + "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e" + }, + "actions/upload-artifact@v7.0.1": { + "repo": "actions/upload-artifact", + "version": "v7.0.1", + "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" }, - "github/gh-aw-actions/setup@v0.68.7": { + "github/gh-aw-actions/setup@v0.72.1": { "repo": "github/gh-aw-actions/setup", - "version": "v0.68.7", - "sha": "69af89ae134d818caa7743b23ad966ce03914a27" + "version": "v0.72.1", + "sha": "bc56a0cad2f450c562810785ef38649c04db812a" } }, "containers": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad1bbae94..383387975 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,33 @@ on: branches: [ master, main ] jobs: + repo-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Ensure .squad stays untracked + shell: bash + run: | + tracked_squad="$(git ls-files .squad)" + if [ -n "$tracked_squad" ]; then + echo "::error::.squad files must not be tracked by Git. Keep squad state local and ignored." + echo "$tracked_squad" + exit 1 + fi + test: + needs: repo-hygiene + if: ${{ !cancelled() }} runs-on: windows-latest 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 @@ -24,7 +48,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - name: Install GitVersion @@ -163,7 +187,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - name: Restore WinUI Tray App @@ -175,6 +199,14 @@ 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: Disable NuGet source mapping for signing + if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' + shell: pwsh + run: | + if (Test-Path NuGet.Config) { + Rename-Item NuGet.Config NuGet.Config.signing.bak -Force + } + - name: Azure Login for Signing if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' uses: azure/login@v3 @@ -183,7 +215,7 @@ jobs: - name: Sign Executable if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - uses: azure/trusted-signing-action@v1 + uses: azure/trusted-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -237,7 +269,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - name: Setup MSBuild @@ -246,15 +278,21 @@ jobs: - name: Restore run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} - - name: Patch MSIX manifest version + - name: Patch MSIX manifest metadata shell: pwsh run: | $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" } $manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest" [xml]$xml = Get-Content $manifest + $xml.Package.Identity.Name = $identityName $xml.Package.Identity.Version = $version + $xml.Package.Properties.DisplayName = $displayName + $xml.Package.Applications.Application.VisualElements.DisplayName = $displayName $xml.Save((Resolve-Path $manifest)) - Write-Host "Patched MSIX manifest version to $version" + Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" - name: Build MSIX Package run: > @@ -283,29 +321,6 @@ jobs: echo "msix_path=$($msix.FullName)" >> $env:GITHUB_OUTPUT echo "msix_name=$($msix.Name)" >> $env:GITHUB_OUTPUT - - name: Sign MSIX - if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - uses: azure/login@v3 - with: - creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - - - name: Sign MSIX Package - if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - uses: azure/trusted-signing-action@v1 - with: - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - endpoint: https://wus2.codesigning.azure.net/ - signing-account-name: hanselman - certificate-profile-name: WindowsEdgeLight - files-folder: src/OpenClaw.Tray.WinUI/AppPackages - files-folder-filter: msix - files-folder-depth: 3 - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - name: Upload MSIX Artifact uses: actions/upload-artifact@v7 with: @@ -331,7 +346,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} restore-keys: nuget-${{ runner.os }}- - name: Restore Command Palette Extension @@ -347,8 +362,8 @@ jobs: path: src/OpenClaw.CommandPalette/bin/${{ matrix.platform }}/Debug/ release: - needs: [test, build, build-msix, build-extension] - if: startsWith(github.ref, 'refs/tags/v') && !cancelled() + 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() runs-on: windows-latest permissions: contents: write @@ -400,19 +415,27 @@ jobs: if: steps.msix-x64.outcome == 'success' || steps.msix-arm64.outcome == 'success' shell: pwsh run: | + $assetVersion = "${{ needs.test.outputs.semVer }}" $x64 = Get-ChildItem -Path artifacts/msix-x64 -Filter "*.msix" | Select-Object -First 1 $arm64 = Get-ChildItem -Path artifacts/msix-arm64 -Filter "*.msix" | Select-Object -First 1 - if ($x64) { Copy-Item $x64.FullName "OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.msix" } - if ($arm64) { Copy-Item $arm64.FullName "OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.msix" } + if ($x64) { Copy-Item $x64.FullName "OpenClawCompanion-$assetVersion-win-x64.msix" } + if ($arm64) { Copy-Item $arm64.FullName "OpenClawCompanion-$assetVersion-win-arm64.msix" } - # Sign ARM64 artifacts on x64 runner (ARM64 runner can't run the signing dlib) - - name: Azure Login for ARM64 Signing + - name: Disable NuGet source mapping for signing + shell: pwsh + run: | + if (Test-Path NuGet.Config) { + Rename-Item NuGet.Config NuGet.Config.signing.bak -Force + } + + # Sign release artifacts on the x64 runner (ARM64 runner can't run the signing dlib). + - name: Azure Login for Release Signing uses: azure/login@v3 with: creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - name: Sign ARM64 Executables - uses: azure/trusted-signing-action@v1 + uses: azure/trusted-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -426,9 +449,9 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - - name: Sign ARM64 MSIX - if: steps.msix-arm64.outcome == 'success' - uses: azure/trusted-signing-action@v1 + - name: Sign Release MSIX Packages + if: steps.msix-x64.outcome == 'success' || steps.msix-arm64.outcome == 'success' + uses: azure/trusted-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -436,9 +459,9 @@ jobs: endpoint: https://wus2.codesigning.azure.net/ signing-account-name: hanselman certificate-profile-name: WindowsEdgeLight - files-folder: artifacts/msix-arm64 + files-folder: . files-folder-filter: msix - files-folder-depth: 3 + files-folder-depth: 1 file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 @@ -446,8 +469,8 @@ jobs: # Create ZIP files for Updatum auto-update (needs "win-x64" in filename) - name: Create Release ZIPs run: | - Compress-Archive -Path artifacts/tray-win-x64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.zip - Compress-Archive -Path artifacts/tray-win-arm64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.zip + Compress-Archive -Path artifacts/tray-win-x64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip + Compress-Archive -Path artifacts/tray-win-arm64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip # Inno Setup installer for x64 - name: Install Inno Setup @@ -485,7 +508,7 @@ jobs: creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - name: Sign Installer - uses: azure/trusted-signing-action@v1 + uses: azure/trusted-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} @@ -506,20 +529,22 @@ jobs: files: | Output/OpenClawTray-Setup-x64.exe Output/OpenClawTray-Setup-arm64.exe - OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.zip - OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.zip - OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.msix - OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.msix + OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip + OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip + OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix + OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | ## OpenClaw Windows Hub ${{ github.ref_name }} ### Downloads - **Installer (x64)**: `OpenClawTray-Setup-x64.exe` - Intel/AMD 64-bit - **Installer (ARM64)**: `OpenClawTray-Setup-arm64.exe` - Windows on ARM (Surface, etc.) - - **Portable x64**: `OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.zip` - - **Portable ARM64**: `OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.zip` - - **MSIX x64**: `OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-x64.msix` - Packaged (camera/mic consent) - - **MSIX ARM64**: `OpenClawTray-${{ needs.test.outputs.majorMinorPatch }}-win-arm64.msix` - Packaged (camera/mic consent) + - **Portable x64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip` + - **Portable ARM64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip` + - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` - Packaged (camera/mic consent) + - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` - Packaged (camera/mic consent) ### Features - 🦞 System tray integration with gateway status diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index cde4be102..0ae42f6f5 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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup-cli@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: - version: v0.68.1 + version: v0.72.1 diff --git a/.github/workflows/gateway-compat-spike.yml b/.github/workflows/gateway-compat-spike.yml new file mode 100644 index 000000000..d623737dc --- /dev/null +++ b/.github/workflows/gateway-compat-spike.yml @@ -0,0 +1,175 @@ +name: Gateway Compat Spike (W0) + +# Throwaway diagnostic workflow that proves the WSL + openclaw + fake-LLM +# pipeline works on a hosted Windows runner before we invest in a full +# gateway-compat harness. Manual dispatch only. Outputs cold-start timings, +# `openclaw config validate` results, and a fake-LLM round-trip so we can +# size the real CI plan. Delete this file (or fold into gateway-compat.yml) +# once the findings are baked into the harness. + +on: + workflow_dispatch: + inputs: + gateway_version: + description: openclaw npm version or dist-tag to install + required: false + default: latest + type: string + +permissions: + contents: read + +jobs: + spike: + name: WSL + openclaw + fake LLM + runs-on: windows-2025 + timeout-minutes: 45 + env: + OPENCLAW_GATEWAY_VERSION: ${{ inputs.gateway_version }} + FAKE_LLM_PORT: "18888" + steps: + - uses: actions/checkout@v6 + + - name: Setup Node 24 + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Register WSL path helper (pwsh) + shell: pwsh + # PowerShell function that converts C:\foo\bar -> /mnt/c/foo/bar. Used by + # every subsequent step that needs to hand a Windows path to a WSL command. + run: | + $helper = @' + function global:ConvertTo-WslPath([string]$path) { + $p = ($path -replace '\\','/').TrimEnd('/') + if ($p -match '^([A-Za-z]):/(.*)$') { + return "/mnt/" + $matches[1].ToLower() + "/" + $matches[2] + } + return $p + } + '@ + $profileDir = Split-Path -Parent $PROFILE.CurrentUserAllHosts + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + $helper | Out-File -FilePath $PROFILE.CurrentUserAllHosts -Encoding utf8 -Append + Write-Host "Wrote ConvertTo-WslPath helper to $($PROFILE.CurrentUserAllHosts)" + + - name: WSL host diagnostics + shell: pwsh + # Diagnostic step only; never fail the job here. WSL commands that + # return non-zero (e.g. `wsl --list --verbose` when no distros are + # registered) are expected on a fresh runner and are themselves data. + continue-on-error: true + run: | + # wsl.exe emits UTF-16 LE; decode it so the log is readable. + [Console]::OutputEncoding = [System.Text.Encoding]::Unicode + $sw = [System.Diagnostics.Stopwatch]::StartNew() + function Run-Diagnostic($label, [ScriptBlock]$body) { + Write-Host "::group::$label" + try { & $body | Out-Host } catch { Write-Host "(error: $($_.Exception.Message))" } + Write-Host "(exit=$LASTEXITCODE)" + $global:LASTEXITCODE = 0 + Write-Host "::endgroup::" + } + Run-Diagnostic "wsl --version" { wsl --version } + Run-Diagnostic "wsl --status" { wsl --status } + Run-Diagnostic "wsl --list --verbose" { wsl --list --verbose } + Run-Diagnostic "wsl --list --online" { wsl --list --online } + $sw.Stop() + "wsl_diagnostics_seconds=$([math]::Round($sw.Elapsed.TotalSeconds,1))" | Out-File -Append $env:GITHUB_OUTPUT + $global:LASTEXITCODE = 0 + id: diag + + - name: Install Ubuntu-24.04 distro + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::Unicode + $sw = [System.Diagnostics.Stopwatch]::StartNew() + # Match the production install command used by LocalGatewaySetup: + # wsl --install Ubuntu-24.04 --name OpenClawGateway --no-launch --version 2 + # Use default name 'Ubuntu-24.04' for the spike to avoid the rename codepath. + wsl --install Ubuntu-24.04 --no-launch --version 2 + if ($LASTEXITCODE -ne 0) { throw "wsl --install failed with $LASTEXITCODE" } + $sw.Stop() + Write-Host "Distro install: $([math]::Round($sw.Elapsed.TotalSeconds,1))s" + wsl --list --verbose + + - name: Provision openclaw user inside distro + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $script = ConvertTo-WslPath (Join-Path $env:GITHUB_WORKSPACE "tools/spike/provision.sh") + wsl -d Ubuntu-24.04 -u root -- bash -lc "bash $script" + if ($LASTEXITCODE -ne 0) { throw "openclaw user provision failed with $LASTEXITCODE" } + + - name: Install openclaw gateway (mirrors LocalGatewaySetup) + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $version = $env:OPENCLAW_GATEWAY_VERSION + if (-not $version) { $version = "latest" } + Write-Host "Installing openclaw@$version via install-cli.sh" + # Inline command is safe (no embedded scripts, just shell flags). + $cmd = "curl -fsSL --proto '=https' --tlsv1.2 'https://openclaw.ai/install-cli.sh' | " + + "OPENCLAW_PREFIX='/opt/openclaw' OPENCLAW_INSTALL_METHOD='npm' OPENCLAW_VERSION='$version' " + + "SHARP_IGNORE_GLOBAL_LIBVIPS=1 bash -s -- --json --prefix '/opt/openclaw' --version '$version' --no-onboard" + wsl -d Ubuntu-24.04 -u openclaw -- bash -lc $cmd + if ($LASTEXITCODE -ne 0) { throw "openclaw install failed with $LASTEXITCODE" } + $sw.Stop() + Write-Host "Gateway install: $([math]::Round($sw.Elapsed.TotalSeconds,1))s" + + - name: Verify openclaw CLI + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + wsl -d Ubuntu-24.04 -u openclaw -- /opt/openclaw/bin/openclaw --version + if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with $LASTEXITCODE" } + wsl -d Ubuntu-24.04 -u openclaw -- /opt/openclaw/bin/openclaw --help | Select-Object -First 40 + + - name: Start fake LLM server inside WSL + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $script = ConvertTo-WslPath (Join-Path $env:GITHUB_WORKSPACE "tools/spike/start-fake-llm.sh") + $repoWsl = ConvertTo-WslPath $env:GITHUB_WORKSPACE + $port = $env:FAKE_LLM_PORT + wsl -d Ubuntu-24.04 -u openclaw -- bash -lc "REPO_WSL_PATH='$repoWsl' FAKE_LLM_PORT='$port' bash $script" + if ($LASTEXITCODE -ne 0) { throw "fake-llm bring-up failed with $LASTEXITCODE" } + + - name: Configure openclaw to use fake LLM and validate + shell: pwsh + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $script = ConvertTo-WslPath (Join-Path $env:GITHUB_WORKSPACE "tools/spike/config-probe.sh") + $port = $env:FAKE_LLM_PORT + wsl -d Ubuntu-24.04 -u openclaw -- bash -lc "FAKE_LLM_PORT='$port' bash $script" | Tee-Object -FilePath openclaw-config-spike.log + # We do NOT fail the job here even if validate exits non-zero; the whole point + # of the spike is to record the actual config shape so we can correct the + # production harness before W3 work begins. + + - name: Collect server log + if: always() + shell: pwsh + run: | + Write-Host "::group::fake-llm server.log" + wsl -d Ubuntu-24.04 -u openclaw -- cat /home/openclaw/fake-llm/server.log + Write-Host "::endgroup::" + + - name: Upload spike artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: gateway-compat-spike + path: | + openclaw-config-spike.log + openclaw-schema.json + if-no-files-found: warn + + - name: Cleanup distro + if: always() + shell: pwsh + continue-on-error: true + run: | + wsl --shutdown + wsl --unregister Ubuntu-24.04 diff --git a/.github/workflows/gateway-compat.yml b/.github/workflows/gateway-compat.yml new file mode 100644 index 000000000..59c2ffac6 --- /dev/null +++ b/.github/workflows/gateway-compat.yml @@ -0,0 +1,285 @@ +name: Gateway Compat + +# Validates the tray <-> openclaw gateway integration so a gateway release +# can't silently break us. +# +# Layout (informed by W0 spike findings, run 26138294682): +# PR / push -> Smoke job only. Builds the E2E tray (with test hooks), +# runs the harness Smoke tier. Fast (~3 min). Merge-gating. +# Nightly -> Smoke + Gateway tier. Installs WSL + Ubuntu-24.04 + +# openclaw + fake-LLM, runs all scenarios. Matrix tests both +# the pinned LKG version and the current "latest" published +# gateway, so we get early warning when upstream breaks us. +# Failures on "latest" do NOT gate merges (alert-only). + +on: + push: + branches: [ master, main ] + paths: + - "src/OpenClaw.Tray.WinUI/Services/TestHooks/**" + - "src/OpenClaw.Tray.WinUI/Services/NodeService.cs" + - "src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/**" + - "tests/OpenClaw.GatewayCompat.E2ETests/**" + - "tools/fake-llm-server/**" + - "tools/spike/**" + - "gateway-lkg.json" + - "src/OpenClaw.Shared/GatewayLkg.cs" + - ".github/workflows/gateway-compat.yml" + pull_request: + paths: + - "src/OpenClaw.Tray.WinUI/Services/TestHooks/**" + - "src/OpenClaw.Tray.WinUI/Services/NodeService.cs" + - "src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/**" + - "tests/OpenClaw.GatewayCompat.E2ETests/**" + - "tools/fake-llm-server/**" + - "tools/spike/**" + - "gateway-lkg.json" + - "src/OpenClaw.Shared/GatewayLkg.cs" + - ".github/workflows/gateway-compat.yml" + schedule: + # Nightly at 07:00 UTC (midnight Pacific). Runs the full matrix + # (LKG + latest) to catch upstream breakage early. + - cron: "0 7 * * *" + workflow_dispatch: + inputs: + gateway_version: + description: openclaw npm version or dist-tag to install for the gateway tier + required: false + default: "" + type: string + run_gateway_tier: + description: Run the full gateway tier (WSL + openclaw + fake LLM) + required: false + default: false + type: boolean + workflow_call: + inputs: + gateway_version: + description: openclaw npm version or dist-tag to install for the gateway tier + required: false + default: "" + type: string + run_gateway_tier: + description: Run the full gateway tier + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: gateway-compat-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + smoke: + name: Harness smoke (no WSL) + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - 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 + run: dotnet restore + + - name: Build E2E tray (with test hooks) + # Production tray binaries must NEVER have this flag (enforced by + # OpenClaw.Tray.Tests.ReleaseBuildExcludesTestHooksTests). This is a + # dedicated E2E build that lives in its own bin subtree and is used + # ONLY by this job. + # Omit --no-restore so the RID-targeted restore generates the right + # win-x64 assets for the dependent WinUI projects (matches existing + # ci.yml pattern for "Build Tray App (WinUI)"). + run: dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 -p:OpenClawEnableTestHooks=true + + - name: Build gateway-compat harness + run: dotnet build tests/OpenClaw.GatewayCompat.E2ETests -c Debug --no-restore + + - name: Run Smoke tier + run: > + dotnet test tests/OpenClaw.GatewayCompat.E2ETests + --no-build + -c Debug + --filter "Tier=Smoke" + --verbosity normal + --logger "trx;LogFileName=GatewayCompat.Smoke.trx" + --results-directory TestResults/Smoke + + - name: Upload smoke results + if: always() + uses: actions/upload-artifact@v7 + with: + name: gateway-compat-smoke-results + path: TestResults/Smoke + if-no-files-found: warn + + gateway: + name: Gateway tier (WSL + openclaw@${{ matrix.gateway_version }}) + # PR/push: LKG matrix cell runs as merge gate (~3 min added latency, + # catches gateway regressions before merge). + # Nightly: full matrix (LKG + latest); "latest" failures alert-only. + # workflow_dispatch: only when run_gateway_tier=true. + if: > + github.event_name == 'pull_request' || + github.event_name == 'push' || + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.run_gateway_tier == true) + needs: smoke + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + gateway_version: ${{ fromJson((github.event_name == 'pull_request' || github.event_name == 'push') && '["lkg"]' || '["lkg","latest"]') }} + include: + - gateway_version: lkg + failure_is_blocking: true + - gateway_version: latest + failure_is_blocking: false + continue-on-error: ${{ !matrix.failure_is_blocking }} + env: + FAKE_LLM_PORT: "18888" + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Setup Node 24 + uses: actions/setup-node@v5 + with: + node-version: "24" + + - name: Resolve gateway version + id: ver + shell: pwsh + run: | + # LKG version comes from gateway-lkg.json (source of truth for tooling). + # Dispatch override wins; matrix "lkg" -> read JSON; "latest" -> "latest". + $override = "${{ inputs.gateway_version }}" + $resolved = $override + if (-not $resolved) { + if ('${{ matrix.gateway_version }}' -eq 'lkg') { + $lkg = (Get-Content gateway-lkg.json -Raw | ConvertFrom-Json).version + $resolved = $lkg + } else { + $resolved = 'latest' + } + } + Write-Host "Gateway version under test: $resolved" + "version=$resolved" | Out-File -Append $env:GITHUB_OUTPUT + + - name: Restore + run: dotnet restore + + - name: Build E2E tray (with test hooks) + # Omit --no-restore to trigger RID-targeted restore for WinUI deps. + run: dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64 -p:OpenClawEnableTestHooks=true + + - name: Build gateway-compat harness + run: dotnet build tests/OpenClaw.GatewayCompat.E2ETests -c Debug --no-restore + + - name: Register WSL path helper (pwsh) + # Same helper the spike uses. Converts C:\foo\bar -> /mnt/c/foo/bar + # so workflow steps can hand Windows paths to bash inside WSL. + # Plan A: install + fake-LLM bring-up are now driven from inside the + # gateway-compat scenarios themselves (via tray.testhook.localSetup.start + # + GatewayCollectionFixture). This step + the diagnostics one below + # remain to make CI logs useful when the fixture fails. + shell: pwsh + run: | + $helper = @' + function global:ConvertTo-WslPath([string]$path) { + $p = ($path -replace '\\','/').TrimEnd('/') + if ($p -match '^([A-Za-z]):/(.*)$') { + return "/mnt/" + $matches[1].ToLower() + "/" + $matches[2] + } + return $p + } + '@ + $profileDir = Split-Path -Parent $PROFILE.CurrentUserAllHosts + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + $helper | Out-File -FilePath $PROFILE.CurrentUserAllHosts -Encoding utf8 -Append + + - name: WSL host diagnostics + # Plan A: the scenarios install WSL + openclaw themselves via the + # production tray.testhook.localSetup.start path. We log the host's + # WSL state so failures in that flow are diagnosable from CI logs. + shell: pwsh + continue-on-error: true + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + wsl --version + wsl --status + wsl --list --verbose + wsl --list --online + + - name: Run Gateway tier + env: + OPENCLAW_RUN_GATEWAY_COMPAT: "1" + OPENCLAW_GATEWAY_VERSION: ${{ steps.ver.outputs.version }} + FAKE_LLM_PORT: ${{ env.FAKE_LLM_PORT }} + # Tell the GatewayCompatFixture where to dump per-tray data dirs + # (including openclaw-tray.log) before tearing them down. Uploaded + # as part of the gateway-tier results artifact. + GATEWAY_COMPAT_LOG_DIR: ${{ github.workspace }}\TestResults\Gateway-${{ matrix.gateway_version }}\tray-data + run: > + dotnet test tests/OpenClaw.GatewayCompat.E2ETests + --no-build + -c Debug + --filter "Tier=Gateway|Tier=Smoke" + --verbosity normal + --logger "trx;LogFileName=GatewayCompat.Gateway.${{ matrix.gateway_version }}.trx" + --results-directory TestResults/Gateway-${{ matrix.gateway_version }} + + - name: Collect WSL gateway log + # The collection fixture provisions the OpenClawGateway distro + # (production default) via tray.testhook.localSetup.start, then + # bootstraps fake-LLM inside it. Log paths mirror the production + # install layout. + if: always() + shell: pwsh + continue-on-error: true + run: | + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $logDir = "TestResults/Gateway-${{ matrix.gateway_version }}/logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + wsl --list --verbose | Out-File -Encoding utf8 "$logDir/wsl-list.log" + wsl -d OpenClawGateway -u openclaw -- cat /home/openclaw/fake-llm/server.log > "$logDir/fake-llm.log" 2>&1 + wsl -d OpenClawGateway -u openclaw -- bash -lc "ls -la /home/openclaw /opt/openclaw /home/openclaw/.openclaw 2>&1 || true" > "$logDir/distro-layout.log" 2>&1 + wsl -d OpenClawGateway -u openclaw -- bash -lc "find /home/openclaw/.openclaw -name '*.log' 2>/dev/null | head -20 | xargs -I{} sh -c 'echo === {} ===; tail -200 {}'" > "$logDir/openclaw-service.log" 2>&1 + wsl -d OpenClawGateway -u openclaw -- bash -lc "ps auxww 2>&1 || true" > "$logDir/distro-processes.log" 2>&1 + wsl -d OpenClawGateway -u openclaw -- bash -lc "ss -tlnp 2>&1 || netstat -tlnp 2>&1 || true" > "$logDir/distro-listeners.log" 2>&1 + + - name: Upload gateway-tier results + if: always() + uses: actions/upload-artifact@v7 + with: + name: gateway-compat-${{ matrix.gateway_version }}-results + path: TestResults/Gateway-${{ matrix.gateway_version }} + if-no-files-found: warn + + - name: Cleanup WSL distro + if: always() + continue-on-error: true + shell: pwsh + run: | + wsl --shutdown + # Production distro name; the scenarios install (and we tear down) + # the same one a real user would see. + wsl --unregister OpenClawGateway diff --git a/.github/workflows/gateway-lkg-bump.yml b/.github/workflows/gateway-lkg-bump.yml new file mode 100644 index 000000000..c9288475f --- /dev/null +++ b/.github/workflows/gateway-lkg-bump.yml @@ -0,0 +1,206 @@ +name: Gateway LKG Bump + +# Polls the `openclaw` npm package for a newer release than what's pinned +# in gateway-lkg.json. When a newer release exists, runs the full +# gateway-compat suite against the candidate. If green, opens (or updates) +# a PR that bumps both gateway-lkg.json AND src/OpenClaw.Shared/GatewayLkg.cs +# in lockstep so OpenClaw.Shared.Tests.GatewayLkgTests stays green. +# +# Hard rules (rubber-duck critique findings): +# - NEVER auto-merges. CODEOWNER review is required. +# - PR body records package version, tarball shasum, npm publish time, +# and a link to the green compat run. +# - Pre-releases (alpha/beta/rc/insiders/dev) are ignored. +# - Privileged secrets are not exposed to the candidate test job. + +on: + schedule: + # Every 6 hours. Cron is UTC. + - cron: "17 */6 * * *" + workflow_dispatch: + inputs: + force_version: + description: Force a specific candidate version (overrides npm dist-tag check) + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: gateway-lkg-bump + cancel-in-progress: false + +jobs: + discover: + name: Find candidate version + runs-on: ubuntu-24.04 + outputs: + should_run: ${{ steps.compare.outputs.should_run }} + current: ${{ steps.compare.outputs.current }} + candidate: ${{ steps.compare.outputs.candidate }} + shasum: ${{ steps.compare.outputs.shasum }} + published_at: ${{ steps.compare.outputs.published_at }} + steps: + - uses: actions/checkout@v6 + + - name: Compare current LKG vs npm latest + id: compare + shell: bash + env: + FORCE_VERSION: ${{ inputs.force_version }} + run: | + set -euo pipefail + current="$(jq -r .version gateway-lkg.json)" + if [ -n "${FORCE_VERSION:-}" ]; then + candidate="$FORCE_VERSION" + else + candidate="$(curl -fsSL https://registry.npmjs.org/openclaw | jq -r '."dist-tags".latest')" + fi + echo "Current LKG: $current" + echo "Candidate: $candidate" + + # Refuse pre-releases unless explicitly forced. + if [ -z "${FORCE_VERSION:-}" ] && echo "$candidate" | grep -Eqi '(alpha|beta|rc|insiders|dev|next|preview)'; then + echo "Candidate '$candidate' is a pre-release; skipping (set force_version to override)." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$current" = "$candidate" ]; then + echo "Already on latest. Nothing to do." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Capture shasum + publish time so the bump PR has provenance. + meta="$(curl -fsSL "https://registry.npmjs.org/openclaw/$candidate")" + shasum="$(echo "$meta" | jq -r .dist.shasum)" + published_at="$(curl -fsSL https://registry.npmjs.org/openclaw | jq -r ".time[\"$candidate\"]")" + + { + echo "should_run=true" + echo "current=$current" + echo "candidate=$candidate" + echo "shasum=$shasum" + echo "published_at=$published_at" + } >> "$GITHUB_OUTPUT" + + validate: + name: Validate openclaw@${{ needs.discover.outputs.candidate }} + needs: discover + if: needs.discover.outputs.should_run == 'true' + uses: ./.github/workflows/gateway-compat.yml + # Calling the gateway-compat workflow as a reusable workflow. This + # requires gateway-compat.yml to support workflow_call; if it does not + # yet, the bump workflow will be inert until that's added — a deliberate + # safety property: no PR opens without a green compat run. + # The actual test version selection happens inside that workflow via + # the workflow_dispatch input plumbing. + with: + gateway_version: ${{ needs.discover.outputs.candidate }} + run_gateway_tier: true + + open_pr: + name: Open or update LKG bump PR + needs: [ discover, validate ] + if: needs.discover.outputs.should_run == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + # Need full history so the PR branch can rebase cleanly. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Bump gateway-lkg.json + GatewayLkg.cs in lockstep + id: bump + shell: bash + env: + CURRENT: ${{ needs.discover.outputs.current }} + CANDIDATE: ${{ needs.discover.outputs.candidate }} + SHASUM: ${{ needs.discover.outputs.shasum }} + PUBLISHED_AT: ${{ needs.discover.outputs.published_at }} + GITHUB_SHA_SHORT: ${{ github.sha }} + run: | + set -euo pipefail + today="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + short_sha="${GITHUB_SHA_SHORT:0:7}" + + # Update gateway-lkg.json + tmp="$(mktemp)" + jq --arg v "$CANDIDATE" --arg at "$today" --arg ref "$short_sha" \ + '.version=$v | .verifiedAt=$at | .verifiedTrayRef=$ref' \ + gateway-lkg.json > "$tmp" + mv "$tmp" gateway-lkg.json + + # Update GatewayLkg.cs (constants kept in lockstep per + # OpenClaw.Shared.Tests.GatewayLkgTests). + sed -i \ + -e "s/public const string Version = \"[^\"]*\";/public const string Version = \"$CANDIDATE\";/" \ + -e "s/public const string VerifiedAt = \"[^\"]*\";/public const string VerifiedAt = \"$today\";/" \ + -e "s/public const string VerifiedTrayRef = \"[^\"]*\";/public const string VerifiedTrayRef = \"$short_sha\";/" \ + src/OpenClaw.Shared/GatewayLkg.cs + + { + echo "PR_TITLE=chore(lkg): bump gateway LKG to $CANDIDATE" + echo "BRANCH=auto/lkg-bump/$CANDIDATE" + } >> "$GITHUB_OUTPUT" + + - name: Create or update PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT: ${{ needs.discover.outputs.current }} + CANDIDATE: ${{ needs.discover.outputs.candidate }} + SHASUM: ${{ needs.discover.outputs.shasum }} + PUBLISHED_AT: ${{ needs.discover.outputs.published_at }} + BRANCH: ${{ steps.bump.outputs.BRANCH }} + PR_TITLE: ${{ steps.bump.outputs.PR_TITLE }} + run: | + set -euo pipefail + git config user.name "openclaw-lkg-bot" + git config user.email "openclaw-lkg-bot@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add gateway-lkg.json src/OpenClaw.Shared/GatewayLkg.cs + git commit -m "$PR_TITLE + + - npm version: $CANDIDATE + - tarball shasum: $SHASUM + - npm publish time: $PUBLISHED_AT + - compat suite: green vs $CANDIDATE (see linked workflow run) + + Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" + git push --force-with-lease origin "$BRANCH" + + body="$(cat </dev/null 2>&1; then + gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$body" + else + gh pr create --title "$PR_TITLE" --body "$body" --base master --head "$BRANCH" --label "lkg-bump,gateway-compat" + fi diff --git a/.github/workflows/localization-audit.lock.yml b/.github/workflows/localization-audit.lock.yml new file mode 100644 index 000000000..43a8dd2a2 --- /dev/null +++ b/.github/workflows/localization-audit.lock.yml @@ -0,0 +1,1424 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"b17598accf3973e9fd573f64130923a6aff77efeb008ed34db469a16b2078a0c","compiler_version":"v0.72.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"bc56a0cad2f450c562810785ef38649c04db812a","version":"v0.72.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.41"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.41"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6","digest":"sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c"},{"image":"ghcr.io/github/github-mcp-server:v1.0.3","digest":"sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.72.1). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Audits OpenClaw localization coverage and creates a draft pull request when user-facing strings bypass the Resources.resw localization pattern. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - 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 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.25.41 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.41 +# - ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c +# - ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 +# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + +name: "Localization Audit" +"on": + schedule: + - cron: "22 4 * * 1" + - cron: "22 4 * * 4" + workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Localization Audit" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + comment_id: "" + comment_repo: "" + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Localization Audit" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/localization-audit.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.40" + GH_AW_INFO_AGENT_VERSION: "1.0.40" + GH_AW_INFO_CLI_VERSION: "v0.72.1" + GH_AW_INFO_WORKFLOW_NAME: "Localization Audit" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","dotnet"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.41" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + .claude + .codex + .crush + .gemini + .opencode + .pi + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "localization-audit.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.72.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_bf8bf7b7d49727b0_EOF' + + GH_AW_PROMPT_bf8bf7b7d49727b0_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_bf8bf7b7d49727b0_EOF' + + Tools: create_issue, create_pull_request, missing_tool, missing_data, noop + GH_AW_PROMPT_bf8bf7b7d49727b0_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_bf8bf7b7d49727b0_EOF' + + GH_AW_PROMPT_bf8bf7b7d49727b0_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_bf8bf7b7d49727b0_EOF' + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + - **checkouts**: The following repositories have been checked out and are available in the workspace: + - `$GITHUB_WORKSPACE` → `__GH_AW_GITHUB_REPOSITORY__` (cwd) [full history, all branches available as remote-tracking refs] + - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). + + + GH_AW_PROMPT_bf8bf7b7d49727b0_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_bf8bf7b7d49727b0_EOF' + + {{#runtime-import .github/workflows/localization-audit.md}} + GH_AW_PROMPT_bf8bf7b7d49727b0_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools' + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: read-all + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: localizationaudit + outputs: + agentic_engine_timeout: ${{ steps.detect-copilot-errors.outputs.agentic_engine_timeout || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-copilot-errors.outputs.inference_access_error || 'false' }} + mcp_policy_error: ${{ steps.detect-copilot-errors.outputs.mcp_policy_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-copilot-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Localization Audit" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/localization-audit.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.40 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.41 + - name: Parse integrity filter lists + id: parse-guard-vars + env: + GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }} + GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }} + GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} + run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh" + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_EXT: ".agent.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.41 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 ghcr.io/github/gh-aw-firewall/squid:0.25.41 ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + - name: Generate Safe Outputs Config + run: | + mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_200f0ab23b127267_EOF' + {"create_issue":{"close_older_issues":true,"expires":336,"labels":["automation","localization"],"max":1,"title_prefix":"[Localization Audit] "},"create_pull_request":{"draft":true,"labels":["automation","localization"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"fallback-to-issue","title_prefix":"[Localization Audit] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_200f0ab23b127267_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[Localization Audit] \". Labels [\"automation\" \"localization\"] will be automatically added.", + "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[Localization Audit] \". Labels [\"automation\" \"localization\"] will be automatically added. PRs will be created as drafts." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "create_pull_request": { + "defaultMax": 1, + "fields": { + "base": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.6' + + mkdir -p /home/runner/.copilot + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_db65bfad6b3d6aff_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v1.0.3", + "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "all" + }, + "guard-policies": { + "allow-only": { + "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }}, + "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }}, + "min-integrity": "none", + "repos": "all", + "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }} + } + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_db65bfad6b3d6aff_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 60 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["*.vsblob.vsassets.io","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.nuget.org","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","builds.dotnet.microsoft.com","ci.dot.net","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","dc.services.visualstudio.com","dist.nuget.org","dot.net","dotnet.microsoft.com","dotnetcli.blob.core.windows.net","github.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","nuget.org","nuget.pkg.github.com","nugetregistryv2prod.blob.core.windows.net","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","oneocsp.microsoft.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","pkgs.dev.azure.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","telemetry.enterprise.githubcopilot.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com","www.microsoft.com"]},"apiProxy":{"enabled":true,"models":{"auto":["large"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"]}},"container":{"imageTag":"0.25.41"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.72.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect Copilot errors + id: detect-copilot-errors + if: always() + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_copilot_errors.cjs" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dist.nuget.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/proxy-logs/ + !/tmp/gh-aw/proxy-logs/proxy-tls/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-localization-audit" + cancel-in-progress: false + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Localization Audit" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/localization-audit.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Localization Audit" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Log detection run + id: detection_runs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Localization Audit" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Localization Audit" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Localization Audit" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Localization Audit" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "localization-audit" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by {workflow_name}; see [workflow run]({run_url}).\",\"runStarted\":\"{workflow_name} is auditing localization; see [workflow run]({run_url}).\",\"runSuccess\":\"{workflow_name} completed; see [workflow run]({run_url}).\",\"runFailure\":\"{workflow_name} encountered {status}; see [workflow run]({run_url}).\"}" + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "60" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + detection: + needs: + - activation + - agent + if: > + always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true') + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_reason: ${{ steps.detection_conclusion.outputs.reason }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Localization Audit" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/localization-audit.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository for patch context + if: needs.agent.outputs.has_patch == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # --- Threat Detection --- + - name: Clean stale firewall files from agent artifact + run: | + rm -rf /tmp/gh-aw/sandbox/firewall/logs + rm -rf /tmp/gh-aw/sandbox/firewall/audit + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.41 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 ghcr.io/github/gh-aw-firewall/squid:0.25.41 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP Config for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + for f in /tmp/gh-aw/aw-*.bundle; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + WORKFLOW_NAME: "Localization Audit" + WORKFLOW_DESCRIPTION: "Audits OpenClaw localization coverage and creates a draft pull request when user-facing strings bypass the Resources.resw localization pattern." + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.40 + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.41 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.41"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.72.1 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" + with: + script: | + try { + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + } catch (loadErr) { + const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; + const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); + core.error(msg); + core.setOutput('reason', 'parse_error'); + if (continueOnError) { + core.warning('\u26A0\uFE0F ' + msg); + core.setOutput('conclusion', 'warning'); + core.setOutput('success', 'false'); + } else { + core.setOutput('conclusion', 'failure'); + core.setOutput('success', 'false'); + core.setFailed(msg); + } + } + + safe_outputs: + needs: + - activation + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/localization-audit" + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.40" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by {workflow_name}; see [workflow run]({run_url}).\",\"runStarted\":\"{workflow_name} is auditing localization; see [workflow run]({run_url}).\",\"runSuccess\":\"{workflow_name} completed; see [workflow run]({run_url}).\",\"runFailure\":\"{workflow_name} encountered {status}; see [workflow run]({run_url}).\"}" + GH_AW_WORKFLOW_ID: "localization-audit" + GH_AW_WORKFLOW_NAME: "Localization Audit" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} + created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Localization Audit" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/localization-audit.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Extract base branch from agent output + id: extract-base-branch + if: steps.download-agent-output.outcome == 'success' + shell: bash + run: | + if [ -f "/tmp/gh-aw/agent_output.json" ]; then + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + BASE_BRANCH=$("$GH_AW_NODE" -e " + try { + const data = JSON.parse(require('fs').readFileSync('/tmp/gh-aw/agent_output.json', 'utf8')); + const item = (data.items || []).find(i => + (i.type === 'create_pull_request' || i.type === 'push_to_pull_request_branch') && + i.base_branch + ); + if (item) process.stdout.write(item.base_branch); + } catch(e) {} + " 2>/dev/null || true) + # Validate: only allow safe git branch name characters + if [[ "$BASE_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]] && [ ${#BASE_BRANCH} -le 255 ]; then + printf 'base-branch=%s\n' "$BASE_BRANCH" >> "$GITHUB_OUTPUT" + echo "Extracted base branch from safe output: $BASE_BRANCH" + fi + fi + - name: Checkout repository + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "*.vsblob.vsassets.io,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.nuget.org,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,builds.dotnet.microsoft.com,ci.dot.net,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,dist.nuget.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pkgs.dev.azure.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.microsoft.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"expires\":336,\"labels\":[\"automation\",\"localization\"],\"max\":1,\"title_prefix\":\"[Localization Audit] \"},\"create_pull_request\":{\"draft\":true,\"labels\":[\"automation\",\"localization\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"fallback-to-issue\",\"title_prefix\":\"[Localization Audit] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + if-no-files-found: ignore + diff --git a/.github/workflows/localization-audit.md b/.github/workflows/localization-audit.md new file mode 100644 index 000000000..65b90aa1d --- /dev/null +++ b/.github/workflows/localization-audit.md @@ -0,0 +1,92 @@ +--- +description: | + Audits OpenClaw localization coverage and creates a draft pull request when user-facing strings bypass the Resources.resw localization pattern. + +on: + schedule: + - cron: "22 4 * * 1" + - cron: "22 4 * * 4" + workflow_dispatch: + +timeout-minutes: 60 + +permissions: read-all + +network: + allowed: + - defaults + - dotnet + +checkout: + fetch-depth: 0 + +tools: + github: + toolsets: [all] + min-integrity: none + bash: true + +safe-outputs: + messages: + footer: "> Generated by {workflow_name}; see [workflow run]({run_url})." + run-started: "{workflow_name} is auditing localization; see [workflow run]({run_url})." + run-success: "{workflow_name} completed; see [workflow run]({run_url})." + run-failure: "{workflow_name} encountered {status}; see [workflow run]({run_url})." + create-pull-request: + draft: true + title-prefix: "[Localization Audit] " + labels: [automation, localization] + protected-files: fallback-to-issue + max: 1 + create-issue: + title-prefix: "[Localization Audit] " + labels: [automation, localization] + close-older-issues: true + expires: 14 + max: 1 + +engine: copilot +--- + +# Localization Audit + +You are an automated localization auditor for `${{ github.repository }}`. + +## Goal + +Keep user-facing OpenClaw WinUI strings localizable. The app's expected pattern is: + +- Static XAML text uses `x:Uid`. +- Matching resources live in every `src\OpenClaw.Tray.WinUI\Strings\\Resources.resw`. +- Runtime UI strings use `LocalizationHelper.GetString(...)` or `LocalizationHelper.Format(...)`. +- Non-localizable values are limited to protocol identifiers, URLs, model names, command names, and brand names. + +## Required audit + +1. Read `docs\LOCALIZATION_CHECK.md`. +2. Run `pwsh .\scripts\Test-Localization.ps1`. +3. Treat command failures as required fixes. Treat "candidate hard-coded XAML string" warnings as audit findings: fix a focused, safe subset when possible, but do not churn unrelated surfaces. +4. If the check finds issues, inspect the reported files and fix only localization-related problems: + - Add missing `x:Uid` values to XAML controls with user-facing text. + - Add corresponding keys to all locale `Resources.resw` files. + - Prefer existing translated resources when equivalent strings already exist. + - Preserve `{0}`-style placeholders in every locale. + - Keep protocol tokens, URLs, command names, and model identifiers unchanged. +5. Re-run `pwsh .\scripts\Test-Localization.ps1`. +6. If you changed code or resources, run the validation commands from `AGENTS.md`. + +## Output + +Create at most one draft pull request with a concise title and summary when you make fixes. If the audit finds localization debt that is too broad or risky to fix safely, create one issue instead with: + +### Summary + +Describe the failed check and the affected surfaces. + +### Findings + +Use bullets with file paths and the missing localization pattern. + +### Recommended fix + +Describe how to add `x:Uid`, resource keys, and runtime `LocalizationHelper` usage. diff --git a/.github/workflows/repo-assist.lock.yml b/.github/workflows/repo-assist.lock.yml index 134d922bc..ea90453fa 100644 --- a/.github/workflows/repo-assist.lock.yml +++ b/.github/workflows/repo-assist.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"71ec5a41f5ef53c2fc6a66f5c62b136203f81f6729575f9ac294f99952da577a","compiler_version":"v0.68.3","strict":true,"agent_id":"copilot"} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba90f2186d7ad780ec640f364005fa24e797b360","version":"v0.68.3"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.20","digest":"sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20","digest":"sha256:6971639e381e82e45134bcd333181f456df3a52cd6f818a3e3d6de068ff91519","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20@sha256:6971639e381e82e45134bcd333181f456df3a52cd6f818a3e3d6de068ff91519"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.20","digest":"sha256:5411d903f73ee597e6a084971c2adef3eb0bd405910df3ed7bf5e3d6bd58a236","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.25.20@sha256:5411d903f73ee597e6a084971c2adef3eb0bd405910df3ed7bf5e3d6bd58a236"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.2.19","digest":"sha256:44d4d8de7e6c37aaea484eba489940c52df6a0b54078ddcbc9327592d5b3c3dd","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.2.19@sha256:44d4d8de7e6c37aaea484eba489940c52df6a0b54078ddcbc9327592d5b3c3dd"},{"image":"ghcr.io/github/github-mcp-server:v0.32.0","digest":"sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28","pinned_image":"ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"71ec5a41f5ef53c2fc6a66f5c62b136203f81f6729575f9ac294f99952da577a","compiler_version":"v0.72.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"bc56a0cad2f450c562810785ef38649c04db812a","version":"v0.72.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.41"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.41"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.6","digest":"sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c"},{"image":"ghcr.io/github/github-mcp-server:v1.0.3","digest":"sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) # | |_| | __ _ ___ _ __ | |_ _ ___ @@ -14,7 +14,7 @@ # \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ # \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ # -# This file was automatically generated by gh-aw (v0.68.3). DO NOT EDIT. +# This file was automatically generated by gh-aw (v0.72.1). DO NOT EDIT. # # To update this file, edit githubnext/agentics/workflows/repo-assist.md@97143ac59cb3a13ef2a77581f929f06719c7402a and run: # gh aw compile @@ -47,16 +47,17 @@ # Custom actions used: # - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 -# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 +# - 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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 +# - github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 # # Container images used: -# - ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682 -# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20@sha256:6971639e381e82e45134bcd333181f456df3a52cd6f818a3e3d6de068ff91519 -# - ghcr.io/github/gh-aw-firewall/squid:0.25.20@sha256:5411d903f73ee597e6a084971c2adef3eb0bd405910df3ed7bf5e3d6bd58a236 -# - ghcr.io/github/gh-aw-mcpg:v0.2.19@sha256:44d4d8de7e6c37aaea484eba489940c52df6a0b54078ddcbc9327592d5b3c3dd -# - ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28 +# - ghcr.io/github/gh-aw-firewall/agent:0.25.41 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.41 +# - ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c +# - ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 # - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f name: "Repo Assist" @@ -120,6 +121,7 @@ jobs: comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} @@ -131,31 +133,35 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Generate agentic run info id: generate_aw_info env: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.21" - GH_AW_INFO_AGENT_VERSION: "1.0.21" - GH_AW_INFO_CLI_VERSION: "v0.68.3" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.40" + GH_AW_INFO_AGENT_VERSION: "1.0.40" + GH_AW_INFO_CLI_VERSION: "v0.72.1" GH_AW_INFO_WORKFLOW_NAME: "Repo Assist" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" GH_AW_INFO_STAGED: "false" GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","dotnet","node","python","rust","java"]' GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.25.20" + GH_AW_INFO_AWF_VERSION: "v0.25.41" GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -164,8 +170,8 @@ jobs: await main(core, context); - name: Add eyes reaction for immediate feedback id: react - if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id || github.event_name == 'pull_request_review' && github.event.pull_request.head.repo.id == github.repository_id + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_REACTION: "eyes" with: @@ -187,11 +193,23 @@ jobs: sparse-checkout: | .github .agents + .claude + .codex + .crush + .gemini + .opencode + .pi sparse-checkout-cone-mode: true fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" - name: Check workflow lock file id: check-lock-file - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_WORKFLOW_FILE: "repo-assist.lock.yml" GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" @@ -202,9 +220,9 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); - name: Check compile-agentic version - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - GH_AW_COMPILED_VERSION: "v0.68.3" + GH_AW_COMPILED_VERSION: "v0.72.1" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -213,7 +231,9 @@ jobs: await main(); - name: Compute current body text id: sanitized - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -222,8 +242,8 @@ jobs: await main(); - name: Add comment with workflow run link id: add-comment - if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id || github.event_name == 'pull_request_review' && github.event.pull_request.head.repo.id == github.repository_id + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_WORKFLOW_NAME: "Repo Assist" GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by 🌈 {workflow_name}, see [workflow run]({run_url}). [Learn more](https://github.com/githubnext/agentics/blob/main/docs/repo-assist.md).\",\"runStarted\":\"{workflow_name} is processing {event_type}, see [workflow run]({run_url})...\",\"runSuccess\":\"✓ {workflow_name} completed successfully, see [workflow run]({run_url}).\",\"runFailure\":\"✗ {workflow_name} encountered {status}, see [workflow run]({run_url}).\"}" @@ -269,6 +289,9 @@ jobs: cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" cat << 'GH_AW_PROMPT_0b7a82d8a513bd25_EOF' + GH_AW_PROMPT_0b7a82d8a513bd25_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_0b7a82d8a513bd25_EOF' The following GitHub context information is available for this workflow: {{#if __GH_AW_GITHUB_ACTOR__ }} @@ -314,9 +337,10 @@ jobs: GH_AW_PROMPT_0b7a82d8a513bd25_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }} @@ -328,7 +352,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); await main(); - name: Substitute placeholders - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_GITHUB_ACTOR: ${{ github.actor }} @@ -341,6 +365,7 @@ jobs: GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }} GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} GH_AW_IS_PR_COMMENT: ${{ github.event.issue.pull_request && 'true' || '' }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools' GH_AW_MEMORY_BRANCH_NAME: 'memory/repo-assist' GH_AW_MEMORY_CONSTRAINTS: "\n\n**Constraints:**\n- **Max File Size**: 10240 bytes (0.01 MB) per file\n- **Max File Count**: 100 files per commit\n- **Max Patch Size**: 10240 bytes (10 KB) total per push (max: 100 KB)\n" GH_AW_MEMORY_DESCRIPTION: '' @@ -371,6 +396,7 @@ jobs: GH_AW_GITHUB_SERVER_URL: process.env.GH_AW_GITHUB_SERVER_URL, GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, GH_AW_IS_PR_COMMENT: process.env.GH_AW_IS_PR_COMMENT, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST, GH_AW_MEMORY_BRANCH_NAME: process.env.GH_AW_MEMORY_BRANCH_NAME, GH_AW_MEMORY_CONSTRAINTS: process.env.GH_AW_MEMORY_CONSTRAINTS, GH_AW_MEMORY_DESCRIPTION: process.env.GH_AW_MEMORY_DESCRIPTION, @@ -397,10 +423,15 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: activation + include-hidden-files: true path: | /tmp/gh-aw/aw_info.json /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents if-no-files-found: ignore retention-days: 1 @@ -430,11 +461,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Set runtime paths id: set-runtime-paths run: | @@ -460,22 +495,110 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" env: GH_TOKEN: ${{ github.token }} - - name: Start DIFC proxy for pre-agent gh calls + - name: Start DIFC Proxy env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_SERVER_URL: ${{ github.server_url }} DIFC_PROXY_POLICY: '{"allow-only":{"min-integrity":"none","repos":"all"}}' - DIFC_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.2.19' + DIFC_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.6' run: | bash "${RUNNER_TEMP}/gh-aw/actions/start_difc_proxy.sh" - - name: Set GH_REPO for proxied steps + - name: Fetch repo data for task weighting run: | - echo "GH_REPO=${GITHUB_REPOSITORY}" >> "$GITHUB_ENV" - - env: - GH_TOKEN: ${{ github.token }} - name: Fetch repo data for task weighting - run: "mkdir -p /tmp/gh-aw\n\n# Fetch open issues with labels (up to 500)\ngh issue list --state open --limit 500 --json number,labels > /tmp/gh-aw/issues.json\n\n# Fetch open PRs with titles (up to 200)\ngh pr list --state open --limit 200 --json number,title > /tmp/gh-aw/prs.json\n\n# Compute task weights and select two tasks for this run\npython3 - << 'EOF'\nimport json, random, os\n\nwith open('/tmp/gh-aw/issues.json') as f:\n issues = json.load(f)\nwith open('/tmp/gh-aw/prs.json') as f:\n prs = json.load(f)\n\nopen_issues = len(issues)\nunlabelled = sum(1 for i in issues if not i.get('labels'))\nrepo_assist_prs = sum(1 for p in prs if p['title'].startswith('[Repo Assist]'))\nother_prs = sum(1 for p in prs if not p['title'].startswith('[Repo Assist]'))\n\ntask_names = {\n 1: 'Issue Labelling',\n 2: 'Issue Investigation and Comment',\n 3: 'Issue Investigation and Fix',\n 4: 'Engineering Investments',\n 5: 'Coding Improvements',\n 6: 'Maintain Repo Assist PRs',\n 7: 'Stale PR Nudges',\n 8: 'Performance Improvements',\n 9: 'Testing Improvements',\n 10: 'Take the Repository Forward',\n}\n\nweights = {\n 1: 1 + 3 * unlabelled,\n 2: 3 + 1 * open_issues,\n 3: 3 + 0.7 * open_issues,\n 4: 5 + 0.2 * open_issues,\n 5: 5 + 0.1 * open_issues,\n 6: float(repo_assist_prs),\n 7: 0.1 * other_prs,\n 8: 3 + 0.05 * open_issues,\n 9: 3 + 0.05 * open_issues,\n 10: 3 + 0.05 * open_issues,\n}\n\n# Seed with run ID for reproducibility within a run\nrun_id = int(os.environ.get('GITHUB_RUN_ID', '0'))\nrng = random.Random(run_id)\n\ntask_ids = list(weights.keys())\ntask_weights = [weights[t] for t in task_ids]\n\n# Weighted sample without replacement (pick 2 distinct tasks)\nchosen, seen = [], set()\nfor t in rng.choices(task_ids, weights=task_weights, k=30):\n if t not in seen:\n seen.add(t)\n chosen.append(t)\n if len(chosen) == 2:\n break\n\nprint('=== Repo Assist Task Selection ===')\nprint(f'Open issues : {open_issues}')\nprint(f'Unlabelled issues : {unlabelled}')\nprint(f'Repo Assist PRs : {repo_assist_prs}')\nprint(f'Other open PRs : {other_prs}')\nprint()\nprint('Task weights:')\nfor t, w in weights.items():\n tag = ' <-- SELECTED' if t in chosen else ''\n print(f' Task {t:2d} ({task_names[t]}): weight {w:6.1f}{tag}')\nprint()\nprint(f'Selected tasks for this run: Task {chosen[0]} ({task_names[chosen[0]]}) and Task {chosen[1]} ({task_names[chosen[1]]})')\n\nresult = {\n 'open_issues': open_issues, 'unlabelled_issues': unlabelled,\n 'repo_assist_prs': repo_assist_prs, 'other_prs': other_prs,\n 'task_names': task_names,\n 'weights': {str(k): round(v, 2) for k, v in weights.items()},\n 'selected_tasks': chosen,\n}\nwith open('/tmp/gh-aw/task_selection.json', 'w') as f:\n json.dump(result, f, indent=2)\nEOF\n" + mkdir -p /tmp/gh-aw + + # Fetch open issues with labels (up to 500) + gh issue list --state open --limit 500 --json number,labels > /tmp/gh-aw/issues.json + + # Fetch open PRs with titles (up to 200) + gh pr list --state open --limit 200 --json number,title > /tmp/gh-aw/prs.json + + # Compute task weights and select two tasks for this run + python3 - << 'EOF' + import json, random, os + + with open('/tmp/gh-aw/issues.json') as f: + issues = json.load(f) + with open('/tmp/gh-aw/prs.json') as f: + prs = json.load(f) + + open_issues = len(issues) + unlabelled = sum(1 for i in issues if not i.get('labels')) + repo_assist_prs = sum(1 for p in prs if p['title'].startswith('[Repo Assist]')) + other_prs = sum(1 for p in prs if not p['title'].startswith('[Repo Assist]')) + + task_names = { + 1: 'Issue Labelling', + 2: 'Issue Investigation and Comment', + 3: 'Issue Investigation and Fix', + 4: 'Engineering Investments', + 5: 'Coding Improvements', + 6: 'Maintain Repo Assist PRs', + 7: 'Stale PR Nudges', + 8: 'Performance Improvements', + 9: 'Testing Improvements', + 10: 'Take the Repository Forward', + } + + weights = { + 1: 1 + 3 * unlabelled, + 2: 3 + 1 * open_issues, + 3: 3 + 0.7 * open_issues, + 4: 5 + 0.2 * open_issues, + 5: 5 + 0.1 * open_issues, + 6: float(repo_assist_prs), + 7: 0.1 * other_prs, + 8: 3 + 0.05 * open_issues, + 9: 3 + 0.05 * open_issues, + 10: 3 + 0.05 * open_issues, + } + # Seed with run ID for reproducibility within a run + run_id = int(os.environ.get('GITHUB_RUN_ID', '0')) + rng = random.Random(run_id) + + task_ids = list(weights.keys()) + task_weights = [weights[t] for t in task_ids] + + # Weighted sample without replacement (pick 2 distinct tasks) + chosen, seen = [], set() + for t in rng.choices(task_ids, weights=task_weights, k=30): + if t not in seen: + seen.add(t) + chosen.append(t) + if len(chosen) == 2: + break + + print('=== Repo Assist Task Selection ===') + print(f'Open issues : {open_issues}') + print(f'Unlabelled issues : {unlabelled}') + print(f'Repo Assist PRs : {repo_assist_prs}') + print(f'Other open PRs : {other_prs}') + print() + print('Task weights:') + for t, w in weights.items(): + tag = ' <-- SELECTED' if t in chosen else '' + print(f' Task {t:2d} ({task_names[t]}): weight {w:6.1f}{tag}') + print() + print(f'Selected tasks for this run: Task {chosen[0]} ({task_names[chosen[0]]}) and Task {chosen[1]} ({task_names[chosen[1]]})') + + result = { + 'open_issues': open_issues, 'unlabelled_issues': unlabelled, + 'repo_assist_prs': repo_assist_prs, 'other_prs': other_prs, + 'task_names': task_names, + 'weights': {str(k): round(v, 2) for k, v in weights.items()}, + 'selected_tasks': chosen, + } + with open('/tmp/gh-aw/task_selection.json', 'w') as f: + json.dump(result, f, indent=2) + EOF + env: + GH_HOST: localhost:18443 + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + GITHUB_API_URL: https://localhost:18443/api/v3 + GITHUB_GRAPHQL_URL: https://localhost:18443/api/graphql + NODE_EXTRA_CA_CERTS: /tmp/gh-aw/proxy-logs/proxy-tls/ca.crt # Repo memory git-based storage configuration from frontmatter processed below - name: Clone repo-memory branch (default) env: @@ -503,7 +626,7 @@ jobs: id: checkout-pr if: | github.event.pull_request || github.event.issue.pull_request - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} with: @@ -514,11 +637,11 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.40 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.20 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.41 - name: Parse integrity filter lists id: parse-guard-vars env: @@ -526,21 +649,37 @@ jobs: GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }} GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }} run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh" - - name: Stop DIFC proxy + - name: Stop DIFC Proxy if: always() continue-on-error: true run: bash "${RUNNER_TEMP}/gh-aw/actions/stop_difc_proxy.sh" + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Restore agent config folders from base branch + if: steps.checkout-pr.outcome == 'success' + env: + GH_AW_AGENT_FOLDERS: ".agents .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" + - name: Restore inline sub-agents from activation artifact + env: + GH_AW_SUB_AGENT_DIR: ".github/agents" + GH_AW_SUB_AGENT_EXT: ".agent.md" + run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh" - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20@sha256:6971639e381e82e45134bcd333181f456df3a52cd6f818a3e3d6de068ff91519 ghcr.io/github/gh-aw-firewall/squid:0.25.20@sha256:5411d903f73ee597e6a084971c2adef3eb0bd405910df3ed7bf5e3d6bd58a236 ghcr.io/github/gh-aw-mcpg:v0.2.19@sha256:44d4d8de7e6c37aaea484eba489940c52df6a0b54078ddcbc9327592d5b3c3dd ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f - - name: Write Safe Outputs Config + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.41 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 ghcr.io/github/gh-aw-firewall/squid:0.25.41 ghcr.io/github/gh-aw-mcpg:v0.3.6@sha256:2bb8eef86006a4c5963c55616a9c51c32f27bfdecb023b8aa6f91f6718d9171c ghcr.io/github/github-mcp-server:v1.0.3@sha256:2ac27ef03461ef2b877031b838a7d1fd7f12b12d4ace7796d8cad91446d55959 node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + - name: Generate Safe Outputs Config run: | mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_9cd51a02c66d080b_EOF' - {"add_comment":{"hide_older_comments":true,"max":10,"target":"*"},"add_labels":{"allowed":["bug","enhancement","help wanted","good first issue","spam","off topic","documentation","question","duplicate","wontfix","needs triage","needs investigation","breaking change","performance","security","refactor"],"max":30,"target":"*"},"create_issue":{"labels":["automation","repo-assist"],"max":4,"title_prefix":"[Repo Assist] "},"create_pull_request":{"draft":true,"labels":["automation","repo-assist"],"max":4,"max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_files_policy":"fallback-to-issue","protected_path_prefixes":[".github/",".agents/"],"title_prefix":"[Repo Assist] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":10240,"max_patch_size":10240}]},"push_to_pull_request_branch":{"if_no_changes":"warn","max":4,"max_patch_size":1024,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS"],"protected_files_policy":"fallback-to-issue","protected_path_prefixes":[".github/",".agents/"],"target":"*","title_prefix":"[Repo Assist] "},"remove_labels":{"allowed":["bug","enhancement","help wanted","good first issue","spam","off topic","documentation","question","duplicate","wontfix","needs triage","needs investigation","breaking change","performance","security","refactor"],"max":5,"target":"*"},"report_incomplete":{},"update_issue":{"allow_body":true,"max":1,"target":"*","title_prefix":"[Repo Assist] "}} + {"add_comment":{"hide_older_comments":true,"max":10,"target":"*"},"add_labels":{"allowed":["bug","enhancement","help wanted","good first issue","spam","off topic","documentation","question","duplicate","wontfix","needs triage","needs investigation","breaking change","performance","security","refactor"],"max":30,"target":"*"},"create_issue":{"labels":["automation","repo-assist"],"max":4,"title_prefix":"[Repo Assist] "},"create_pull_request":{"draft":true,"labels":["automation","repo-assist"],"max":4,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"fallback-to-issue","title_prefix":"[Repo Assist] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":10240,"max_patch_size":10240}]},"push_to_pull_request_branch":{"if_no_changes":"warn","max":4,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"fallback-to-issue","target":"*","title_prefix":"[Repo Assist] "},"remove_labels":{"allowed":["bug","enhancement","help wanted","good first issue","spam","off topic","documentation","question","duplicate","wontfix","needs triage","needs investigation","breaking change","performance","security","refactor"],"max":5,"target":"*"},"report_incomplete":{},"update_issue":{"allow_body":true,"max":1,"target":"*","title_prefix":"[Repo Assist] "}} GH_AW_SAFE_OUTPUTS_CONFIG_9cd51a02c66d080b_EOF - - name: Write Safe Outputs Tools + - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | { @@ -635,6 +774,11 @@ jobs: "create_pull_request": { "defaultMax": 1, "fields": { + "base": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, "body": { "required": true, "type": "string", @@ -835,7 +979,7 @@ jobs: "customValidation": "requiresOneOf:status,title,body" } } - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -891,11 +1035,12 @@ jobs: GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} run: | set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_PORT="8080" export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') echo "::add-mask::${MCP_GATEWAY_API_KEY}" export MCP_GATEWAY_API_KEY @@ -905,15 +1050,19 @@ jobs: export DEBUG="*" export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.19' + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.6' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_053a14d20f10db76_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh" + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_053a14d20f10db76_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.32.0", + "container": "ghcr.io/github/github-mcp-server:v1.0.3", "env": { "GITHUB_HOST": "\${GITHUB_SERVER_URL}", "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", @@ -953,14 +1102,27 @@ jobs: } } GH_AW_MCP_CONFIG_053a14d20f10db76_EOF - - name: Download activation artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: - name: activation - path: /tmp/gh-aw - - name: Clean git credentials + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials continue-on-error: true run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): @@ -968,21 +1130,27 @@ jobs: run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["*.gradle-enterprise.cloud","*.pythonhosted.org","*.vsblob.vsassets.io","adoptium.net","anaconda.org","api.adoptium.net","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.foojay.io","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.npms.io","api.nuget.org","api.snapcraft.io","archive.apache.org","archive.ubuntu.com","azure.archive.ubuntu.com","azuresearch-usnc.nuget.org","azuresearch-ussc.nuget.org","binstar.org","bootstrap.pypa.io","builds.dotnet.microsoft.com","bun.sh","cdn.azul.com","cdn.jsdelivr.net","central.sonatype.com","ci.dot.net","conda.anaconda.org","conda.binstar.org","crates.io","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","dc.services.visualstudio.com","deb.nodesource.com","deno.land","develocity.apache.org","dist.nuget.org","dl.google.com","dlcdn.apache.org","dot.net","dotnet.microsoft.com","dotnetcli.blob.core.windows.net","download.eclipse.org","download.java.net","download.oracle.com","downloads.gradle-dn.com","esm.sh","files.pythonhosted.org","ge.spockframework.org","get.pnpm.io","github.com","googleapis.deno.dev","googlechromelabs.github.io","gradle.org","host.docker.internal","index.crates.io","jcenter.bintray.com","jdk.java.net","json-schema.org","json.schemastore.org","jsr.io","keyserver.ubuntu.com","maven-central.storage-download.googleapis.com","maven.apache.org","maven.google.com","maven.oracle.com","maven.pkg.github.com","nodejs.org","npm.pkg.github.com","npmjs.com","npmjs.org","nuget.org","nuget.pkg.github.com","nugetregistryv2prod.blob.core.windows.net","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","oneocsp.microsoft.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","pip.pypa.io","pkgs.dev.azure.com","plugins-artifacts.gradle.org","plugins.gradle.org","ppa.launchpad.net","pypi.org","pypi.python.org","raw.githubusercontent.com","registry.bower.io","registry.npmjs.com","registry.npmjs.org","registry.yarnpkg.com","repo.anaconda.com","repo.continuum.io","repo.gradle.org","repo.grails.org","repo.maven.apache.org","repo.spring.io","repo.yarnpkg.com","repo1.maven.org","repository.apache.org","s.symcb.com","s.symcd.com","scans-in.gradle.com","security.ubuntu.com","services.gradle.org","sh.rustup.rs","skimdb.npmjs.com","static.crates.io","static.rust-lang.org","storage.googleapis.com","telemetry.enterprise.githubcopilot.com","telemetry.vercel.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com","www.java.com","www.microsoft.com","www.npmjs.com","www.npmjs.org","yarnpkg.com"]},"apiProxy":{"enabled":true,"models":{"auto":["large"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash"],"opus":["copilot/*opus*","anthropic/*opus*"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"]}},"container":{"imageTag":"0.25.41"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 - sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.20 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: + AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.68.3 + GH_AW_VERSION: v0.72.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ github.ref_name }} @@ -1027,7 +1195,7 @@ jobs: bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" - name: Redact secrets in logs if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -1053,7 +1221,7 @@ jobs: - name: Ingest agent output id: collect_output if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" @@ -1068,7 +1236,7 @@ jobs: await main(); - name: Parse agent logs for step summary if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ with: @@ -1080,7 +1248,7 @@ jobs: - name: Parse MCP Gateway logs for step summary if: always() id: parse-mcp-gateway - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); @@ -1093,9 +1261,9 @@ jobs: env: AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) if command -v awf &> /dev/null; then awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" @@ -1105,13 +1273,23 @@ jobs: - name: Parse token usage for step summary if: always() continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); - name: Write agent output placeholder if missing if: always() run: | @@ -1119,6 +1297,12 @@ jobs: echo '{"items":[]}' > /tmp/gh-aw/agent_output.json fi # Upload repo memory as artifacts for push job + - name: Sanitize repo-memory filenames (default) + if: always() + continue-on-error: true + env: + MEMORY_DIR: /tmp/gh-aw/repo-memory/default + run: bash "${RUNNER_TEMP}/gh-aw/actions/sanitize_repo_memory_filenames.sh" - name: Upload repo-memory artifact (default) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1142,14 +1326,17 @@ jobs: !/tmp/gh-aw/proxy-logs/proxy-tls/ /tmp/gh-aw/agent_usage.json /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt /tmp/gh-aw/agent/ /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/safeoutputs.jsonl /tmp/gh-aw/agent_output.json /tmp/gh-aw/aw-*.patch /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json /tmp/gh-aw/sandbox/firewall/logs/ /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json if-no-files-found: ignore conclusion: @@ -1179,11 +1366,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1200,7 +1391,7 @@ jobs: echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" - name: Process no-op messages id: noop - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_NOOP_MAX: "1" @@ -1219,7 +1410,7 @@ jobs: await main(); - name: Log detection run id: detection_runs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Repo Assist" @@ -1237,7 +1428,7 @@ jobs: await main(); - name: Record missing tool id: missing_tool - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" @@ -1253,7 +1444,7 @@ jobs: await main(); - name: Record incomplete id: report_incomplete - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" @@ -1270,7 +1461,7 @@ jobs: - name: Handle agent failure id: handle_agent_failure if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Repo Assist" @@ -1279,6 +1470,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_WORKFLOW_ID: "repo-assist" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" GH_AW_ENGINE_ID: "copilot" GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} @@ -1286,6 +1478,7 @@ jobs: GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} @@ -1297,6 +1490,8 @@ jobs: GH_AW_REPO_MEMORY_PATCH_SIZE_EXCEEDED_default: ${{ needs.push_repo_memory.outputs.patch_size_exceeded_default }} GH_AW_GROUP_REPORTS: "false" GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" GH_AW_TIMEOUT_MINUTES: "60" with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -1307,7 +1502,7 @@ jobs: await main(); - name: Update reaction comment with completion status id: conclusion - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} @@ -1315,6 +1510,7 @@ jobs: GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} GH_AW_WORKFLOW_NAME: "Repo Assist" GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_SAFE_OUTPUTS_RESULT: ${{ needs.safe_outputs.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }} GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by 🌈 {workflow_name}, see [workflow run]({run_url}). [Learn more](https://github.com/githubnext/agentics/blob/main/docs/repo-assist.md).\",\"runStarted\":\"{workflow_name} is processing {event_type}, see [workflow run]({run_url})...\",\"runSuccess\":\"✓ {workflow_name} completed successfully, see [workflow run]({run_url}).\",\"runFailure\":\"✗ {workflow_name} encountered {status}, see [workflow run]({run_url}).\"}" @@ -1342,11 +1538,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1372,7 +1572,7 @@ jobs: rm -rf /tmp/gh-aw/sandbox/firewall/logs rm -rf /tmp/gh-aw/sandbox/firewall/audit - name: Download container images - run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.20@sha256:6971639e381e82e45134bcd333181f456df3a52cd6f818a3e3d6de068ff91519 ghcr.io/github/gh-aw-firewall/squid:0.25.20@sha256:5411d903f73ee597e6a084971c2adef3eb0bd405910df3ed7bf5e3d6bd58a236 + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.41 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.41 ghcr.io/github/gh-aw-firewall/squid:0.25.41 - name: Check if detection needed id: detection_guard if: always() @@ -1387,10 +1587,10 @@ jobs: echo "run_detection=false" >> "$GITHUB_OUTPUT" echo "Detection skipped: no agent outputs or patches to analyze" fi - - name: Clear MCP configuration for detection + - name: Clear MCP Config for detection if: always() && steps.detection_guard.outputs.run_detection == 'true' run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json" rm -f /home/runner/.copilot/mcp-config.json rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - name: Prepare threat detection files @@ -1409,7 +1609,7 @@ jobs: ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - name: Setup threat detection if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: WORKFLOW_NAME: "Repo Assist" WORKFLOW_DESCRIPTION: "A friendly repository assistant that runs 2 times a day to support contributors and maintainers.\nCan also be triggered on-demand via '/repo-assist ' to perform specific tasks.\n- Labels and triages open issues\n- Comments helpfully on open issues to unblock contributors and onboard newcomers\n- Identifies issues that can be fixed and creates draft pull requests with fixes\n- Improves performance, testing, and code quality via PRs\n- Makes engineering investments: dependency updates, CI improvements, tooling\n- Updates its own PRs when CI fails or merge conflicts arise\n- Nudges stale PRs waiting for author response\n- Takes the repository forward with proactive improvements\n- Maintains a persistent memory of work done and what remains\nAlways polite, constructive, and mindful of the project's goals." @@ -1425,33 +1625,45 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + package-manager-cache: false - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.40 env: GH_HOST: github.com - name: Install AWF binary - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.20 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.41 - name: Execute GitHub Copilot CLI if: always() && steps.detection_guard.outputs.run_detection == 'true' + continue-on-error: true id: detection_agentic_execution # Copilot CLI tool arguments (sorted): timeout-minutes: 20 run: | set -o pipefail touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.41/awf-config.schema.json","network":{"allowDomains":["api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","github.com","host.docker.internal","telemetry.enterprise.githubcopilot.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.41"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 - sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.20 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'node ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: + AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_API_KEY: dummy-byok-key-for-offline-mode COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || 'claude-sonnet-4.6' }} GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.68.3 + GH_AW_VERSION: v0.72.1 GITHUB_API_URL: ${{ github.api_url }} GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows GITHUB_HEAD_REF: ${{ github.head_ref }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_SERVER_URL: ${{ github.server_url }} @@ -1472,19 +1684,36 @@ jobs: - name: Parse and conclude threat detection id: detection_conclusion if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} GH_AW_DETECTION_CONTINUE_ON_ERROR: "true" with: script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); + try { + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + } catch (loadErr) { + const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false'; + const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr)); + core.error(msg); + core.setOutput('reason', 'parse_error'); + if (continueOnError) { + core.warning('\u26A0\uFE0F ' + msg); + core.setOutput('conclusion', 'warning'); + core.setOutput('success', 'false'); + } else { + core.setOutput('conclusion', 'failure'); + core.setOutput('success', 'false'); + core.setFailed(msg); + } + } pre_activation: - if: "(github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && (github.event_name == 'issues' && (startsWith(github.event.issue.body, '/repo-assist ') || startsWith(github.event.issue.body, '/repo-assist\n') || github.event.issue.body == '/repo-assist') || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') && github.event.issue.pull_request == null || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') && github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/repo-assist ') || startsWith(github.event.pull_request.body, '/repo-assist\n') || github.event.pull_request.body == '/repo-assist') || github.event_name == 'discussion' && (startsWith(github.event.discussion.body, '/repo-assist ') || startsWith(github.event.discussion.body, '/repo-assist\n') || github.event.discussion.body == '/repo-assist') || github.event_name == 'discussion_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist')) || (!(github.event_name == 'issues')) && (!(github.event_name == 'issue_comment')) && (!(github.event_name == 'pull_request')) && (!(github.event_name == 'pull_request_review_comment')) && (!(github.event_name == 'discussion')) && (!(github.event_name == 'discussion_comment'))" + if: "(github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' || contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.comment.author_association)) && ((github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment') && (github.event_name == 'issues' && (startsWith(github.event.issue.body, '/repo-assist ') || startsWith(github.event.issue.body, '/repo-assist\n') || github.event.issue.body == '/repo-assist') || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') && github.event.issue.pull_request == null || github.event_name == 'issue_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') && github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist') || github.event_name == 'pull_request' && (startsWith(github.event.pull_request.body, '/repo-assist ') || startsWith(github.event.pull_request.body, '/repo-assist\n') || github.event.pull_request.body == '/repo-assist') || github.event_name == 'discussion' && (startsWith(github.event.discussion.body, '/repo-assist ') || startsWith(github.event.discussion.body, '/repo-assist\n') || github.event.discussion.body == '/repo-assist') || github.event_name == 'discussion_comment' && (startsWith(github.event.comment.body, '/repo-assist ') || startsWith(github.event.comment.body, '/repo-assist\n') || github.event.comment.body == '/repo-assist')) || (!(github.event_name == 'issues')) && (!(github.event_name == 'issue_comment')) && (!(github.event_name == 'pull_request')) && (!(github.event_name == 'pull_request_review_comment')) && (!(github.event_name == 'discussion')) && (!(github.event_name == 'discussion_comment')))" runs-on: ubuntu-slim outputs: activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_command_position.outputs.command_position_ok == 'true' }} @@ -1493,13 +1722,17 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Check team membership for command workflow id: check_membership - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_REQUIRED_ROLES: "admin,maintainer,write" with: @@ -1511,7 +1744,7 @@ jobs: await main(); - name: Check command position id: check_command_position - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_COMMANDS: "[\"repo-assist\"]" with: @@ -1528,7 +1761,7 @@ jobs: - detection if: > always() && (!cancelled()) && (needs.detection.result == 'success' || needs.detection.result == 'skipped') && - needs.agent.result != 'skipped' + needs.agent.result == 'success' runs-on: ubuntu-slim permissions: contents: write @@ -1542,11 +1775,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -1574,7 +1811,7 @@ jobs: - name: Push repo-memory changes (default) id: push_repo_memory_default if: always() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_TOKEN: ${{ github.token }} GITHUB_RUN_ID: ${{ github.run_id }} @@ -1614,6 +1851,7 @@ jobs: GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} GH_AW_ENGINE_ID: "copilot" GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.40" GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e Generated by 🌈 {workflow_name}, see [workflow run]({run_url}). [Learn more](https://github.com/githubnext/agentics/blob/main/docs/repo-assist.md).\",\"runStarted\":\"{workflow_name} is processing {event_type}, see [workflow run]({run_url})...\",\"runSuccess\":\"✓ {workflow_name} completed successfully, see [workflow run]({run_url}).\",\"runFailure\":\"✗ {workflow_name} encountered {status}, see [workflow run]({run_url}).\"}" GH_AW_WORKFLOW_ID: "repo-assist" GH_AW_WORKFLOW_NAME: "Repo Assist" @@ -1637,11 +1875,15 @@ jobs: steps: - name: Setup Scripts id: setup - uses: github/gh-aw-actions/setup@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3 + uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1 with: destination: ${{ runner.temp }}/gh-aw/actions job-name: ${{ github.job }} trace-id: ${{ needs.activation.outputs.setup-trace-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Repo Assist" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/repo-assist.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.40" - name: Download agent output artifact id: download-agent-output continue-on-error: true @@ -1662,14 +1904,37 @@ jobs: with: name: agent path: /tmp/gh-aw/ + - name: Extract base branch from agent output + id: extract-base-branch + if: steps.download-agent-output.outcome == 'success' + shell: bash + run: | + if [ -f "/tmp/gh-aw/agent_output.json" ]; then + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + BASE_BRANCH=$("$GH_AW_NODE" -e " + try { + const data = JSON.parse(require('fs').readFileSync('/tmp/gh-aw/agent_output.json', 'utf8')); + const item = (data.items || []).find(i => + (i.type === 'create_pull_request' || i.type === 'push_to_pull_request_branch') && + i.base_branch + ); + if (item) process.stdout.write(item.base_branch); + } catch(e) {} + " 2>/dev/null || true) + # Validate: only allow safe git branch name characters + if [[ "$BASE_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]] && [ ${#BASE_BRANCH} -le 255 ]; then + printf 'base-branch=%s\n' "$BASE_BRANCH" >> "$GITHUB_OUTPUT" + echo "Extracted base branch from safe output: $BASE_BRANCH" + fi + fi - name: Checkout repository if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') || (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch') uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} persist-credentials: false - fetch-depth: 1 + fetch-depth: 0 - name: Configure Git credentials if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') || (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch') env: @@ -1695,13 +1960,13 @@ jobs: echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" - name: Process Safe Outputs id: process_safe_outputs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,googleapis.deno.dev,googlechromelabs.github.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\"},\"add_labels\":{\"allowed\":[\"bug\",\"enhancement\",\"help wanted\",\"good first issue\",\"spam\",\"off topic\",\"documentation\",\"question\",\"duplicate\",\"wontfix\",\"needs triage\",\"needs investigation\",\"breaking change\",\"performance\",\"security\",\"refactor\"],\"max\":30,\"target\":\"*\"},\"create_issue\":{\"labels\":[\"automation\",\"repo-assist\"],\"max\":4,\"title_prefix\":\"[Repo Assist] \"},\"create_pull_request\":{\"draft\":true,\"labels\":[\"automation\",\"repo-assist\"],\"max\":4,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_files_policy\":\"fallback-to-issue\",\"protected_path_prefixes\":[\".github/\",\".agents/\"],\"title_prefix\":\"[Repo Assist] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max\":4,\"max_patch_size\":1024,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"AGENTS.md\"],\"protected_files_policy\":\"fallback-to-issue\",\"protected_path_prefixes\":[\".github/\",\".agents/\"],\"target\":\"*\",\"title_prefix\":\"[Repo Assist] \"},\"remove_labels\":{\"allowed\":[\"bug\",\"enhancement\",\"help wanted\",\"good first issue\",\"spam\",\"off topic\",\"documentation\",\"question\",\"duplicate\",\"wontfix\",\"needs triage\",\"needs investigation\",\"breaking change\",\"performance\",\"security\",\"refactor\"],\"max\":5,\"target\":\"*\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":1,\"target\":\"*\",\"title_prefix\":\"[Repo Assist] \"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":10,\"target\":\"*\"},\"add_labels\":{\"allowed\":[\"bug\",\"enhancement\",\"help wanted\",\"good first issue\",\"spam\",\"off topic\",\"documentation\",\"question\",\"duplicate\",\"wontfix\",\"needs triage\",\"needs investigation\",\"breaking change\",\"performance\",\"security\",\"refactor\"],\"max\":30,\"target\":\"*\"},\"create_issue\":{\"labels\":[\"automation\",\"repo-assist\"],\"max\":4,\"title_prefix\":\"[Repo Assist] \"},\"create_pull_request\":{\"draft\":true,\"labels\":[\"automation\",\"repo-assist\"],\"max\":4,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"fallback-to-issue\",\"title_prefix\":\"[Repo Assist] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max\":4,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"fallback-to-issue\",\"target\":\"*\",\"title_prefix\":\"[Repo Assist] \"},\"remove_labels\":{\"allowed\":[\"bug\",\"enhancement\",\"help wanted\",\"good first issue\",\"spam\",\"off topic\",\"documentation\",\"question\",\"duplicate\",\"wontfix\",\"needs triage\",\"needs investigation\",\"breaking change\",\"performance\",\"security\",\"refactor\"],\"max\":5,\"target\":\"*\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":1,\"target\":\"*\",\"title_prefix\":\"[Repo Assist] \"}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7a60a7fa4..4cc76c6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -349,3 +349,11 @@ test_ws.py # Local visual test output visual-test-output/ + +# Local squad agent state +.squad/ + +# MSIX and packaging test output +msix-validation-evidence/ +packaging-test-output/ +uninstall-validation-output/ diff --git a/AGENTS.md b/AGENTS.md index ff78ede90..55582c908 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,5 +22,28 @@ If a command fails: Notes: - If a build/test is blocked by an environmental lock (for example running executable locking output assemblies), stop/close the locking process and rerun. +- **First-run gotcha**: `dotnet test --no-restore` silently no-ops in a fresh worktree where the test `bin/` doesn't exist yet (reports "Build succeeded in 0.5s" then exits 0 with no tests run). For first-run validation, either omit `--no-restore` OR run `dotnet build` on the test project first. Subsequent reruns honor `--no-restore` correctly. +- In linked git worktrees, set `OPENCLAW_REPO_ROOT` to the worktree path before running tests that discover the repository root, for example: + - `$env:OPENCLAW_REPO_ROOT='D:\github\moltbot-windows-hub.'` - Tray tests must isolate `SettingsManager` from real user settings. Do not use `new SettingsManager()` in tests unless the test intentionally reads `%APPDATA%\OpenClawTray\settings.json`; pass a temp settings directory or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts. +- Prefer isolated worktrees for PR validation. Use `git-wt` for worktree workflows; `wt.exe` may resolve to WorkTrunk instead of Windows Terminal, so use the full Windows Terminal path when explicitly launching Terminal. - Do not claim completion without reporting validation results. + +## Architecture Context for New Agents + +Start with these docs before changing connection, pairing, node, MCP, or tray UX behavior: + +- `docs/CONNECTION_ARCHITECTURE.md` - current gateway registry, connection manager, credential precedence, migration, MCP-only, and tray action behavior. +- `docs/MCP_MODE.md` - local MCP server mode and the `EnableNodeMode` / `EnableMcpServer` matrix. +- `docs/WINDOWS_NODE_TESTING.md` - Windows node capabilities, manual smokes, and gateway-dependent behavior. +- `docs/ONBOARDING_WIZARD.md` - first-run setup flow, setup-code/bootstrap pairing, and test isolation. + +Important current facts: + +- Gateway credentials are no longer stored in `SettingsData.Token` / `SettingsData.BootstrapToken`. `SettingsManager` may read legacy JSON fields only for one-time migration; new writes must go through `GatewayRegistry`. +- Active gateway records live in `%APPDATA%\OpenClawTray\gateways.json`; per-gateway identity files live under `%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json`. +- Credential precedence is device token, then shared gateway token, then bootstrap token. Do not downgrade a paired device from its stored device token back to a bootstrap/shared token. +- `GatewayConnectionManager` owns operator/node connection state. UI surfaces should observe it or call its reconnect/disconnect APIs instead of constructing parallel gateway clients. +- Chat/canvas/tray actions must visibly route users to Connection settings when pairing is incomplete or credentials are missing; avoid silent no-ops. +- MCP-only mode (`EnableMcpServer=true`, `EnableNodeMode=false`) must start local `NodeService` without requiring a gateway credential. +- The default `openclaw` gateway version the tray installs is pinned at the repo root in `gateway-lkg.json` and surfaced as `OpenClaw.Shared.GatewayLkg.Version` (compile-time `const`). `GatewayLkgTests` enforces that the JSON and the C# constants stay in sync. The pin is bumped by `.github/workflows/gateway-lkg-bump.yml` after the gateway-compat suite passes against a newer published version; the bump PR is never auto-merged. To pin a different version locally or in CI without rebuilding the installer, set `OPENCLAW_GATEWAY_VERSION` before launching the tray. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 01774e616..e70126015 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,7 +34,7 @@ A comprehensive guide for building, running, and contributing to the OpenClaw Wi ## Project Structure -This monorepo contains three projects: +This monorepo contains these main projects: ``` openclaw-windows-hub/ @@ -44,6 +44,13 @@ openclaw-windows-hub/ │ │ ├── Models.cs # Data models (SessionInfo, ChannelHealth, etc.) │ │ └── IOpenClawLogger.cs # Logging interface │ │ +│ ├── OpenClaw.Chat/ # Native chat model and reducer +│ │ ├── ChatModels.cs # Threads, entries, events, provider contract +│ │ └── ChatTimelineReducer.cs # Timeline state transitions +│ │ +│ ├── OpenClawTray.FunctionalUI/ # Small in-repo declarative WinUI helper +│ │ └── FunctionalUI.cs # Components, hooks, elements, host control +│ │ │ ├── OpenClaw.Tray.WinUI/ # WinUI 3 system tray application (primary) │ │ ├── App.xaml.cs # Main application, tray icon, gateway connection │ │ ├── Services/ # Settings, logging, hotkeys, deep links @@ -220,6 +227,47 @@ This creates a standalone executable with all dependencies bundled. ## Architecture Overview +### Native chat surface (FunctionalUI + OpenClaw.Chat) + +The Hub Chat tab (`src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml`) and the +tray ChatWindow popup (`src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml`) +render their conversations with native WinUI 3 controls via the in-repo +`OpenClawTray.FunctionalUI` helper and `OpenClaw.Chat` model/reducer code. +The standard WebView2-hosted gateway web client remains available as a +settings-controlled fallback. + +**Layering:** + +``` +src/OpenClaw.Tray.WinUI/Chat/ OpenClawChatTimeline · OpenClawComposer · OpenClawSessionHeader + OpenClawChatDataProvider (adapts OpenClawGatewayClient → IChatDataProvider) + OpenClawChatRoot (FunctionalUI component composing the chat surface) + FunctionalChatHostExtensions (mounts FunctionalUI into a XAML ) + IChatGatewayBridge (testability seam over OpenClawGatewayClient) + ▲ depends on +src/OpenClaw.Chat/ ChatThread · ChatTimelineState · IChatDataProvider · ChatTimelineReducer + ▲ rendered by +src/OpenClawTray.FunctionalUI/ Component · RenderContext · FunctionalHostControl · WinUI elements +``` + +**Lifecycle:** + +- One `OpenClawChatDataProvider` instance lives on `App` (`App.ChatProvider`), + created in `InitializeGatewayClient` and disposed inside + `UnsubscribeGatewayEvents`. Both the Hub Chat tab and the tray ChatWindow + consume the same provider — opening either surface shows identical state. +- Each XAML host (`ChatPage`, `ChatWindow`) mounts its own `FunctionalHostControl` + with `ContentTarget` pointing at a ``. The + surrounding chrome (NavigationView, popup header) stays XAML. +- Provider events fire on the WebSocket-receive thread; the provider + marshals `Changed` / `NotificationRequested` callbacks through a + dispatcher post delegate (`DispatcherQueue.AsPost()`), so FunctionalUI + components observe state on the UI thread. + +**Adding new chat behavior:** model new events in `OpenClaw.Chat`'s +`ChatEvent` discriminated union, handle them in `ChatTimelineReducer`, and +emit them from `OpenClawChatDataProvider` in response to gateway signals. + ### Gateway WebSocket Connection The `OpenClawGatewayClient` manages the connection to the OpenClaw gateway: diff --git a/README.md b/README.md index ea677b44e..f51fae84f 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,13 @@ A Windows companion suite for [OpenClaw](https://openclaw.ai) - the AI-powered p *Made with 🦞 love by Scott Hanselman and Molty* -![Molty - Windows Tray App](docs/molty1.png) +![OpenClaw Windows Hub tray menu](docs/images/openclawwindows1.png) -![Molty - Command Palette](docs/molty2.png) +![OpenClaw Windows Hub command center](docs/images/openclawwindows2.png) + +![OpenClaw Windows Hub pairing and connection settings](docs/images/openclawwindows3.png) + +![OpenClaw Windows Hub activity and diagnostics](docs/images/openclawwindows4.png) ## Projects @@ -177,6 +181,7 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t | **Canvas** | `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.push`, `canvas.a2ui.pushJSONL`, `canvas.a2ui.reset` | Display and control a WebView2 window | | **Screen** | `screen.snapshot`, `screen.record` | Capture screenshots and fixed-duration MP4 screen recordings | | **Camera** | `camera.list`, `camera.snap`, `camera.clip` | Enumerate cameras and capture still photos or short video clips | +| **Speech-to-text** | `stt.transcribe` | Capture audio from the default microphone for a bounded duration and return transcribed text. Default-off; opt-in via Settings. When enabled, advertised to both gateway callers (subject to gateway allowlist) and local MCP clients (subject to bearer token). | | **Location** | `location.get` | Return Windows geolocation when permission is available | | **Device** | `device.info`, `device.status` | Return Windows host/app metadata and lightweight status | | **Text-to-speech** | `tts.speak` | Speak text aloud through Windows speech synthesis, or ElevenLabs when configured | @@ -392,7 +397,7 @@ openclaw-windows-node/ │ ├── OpenClaw.Shared.Tests/ # Shared library tests │ └── OpenClaw.Tray.Tests/ # Tray app helper tests ├── docs/ -│ └── molty1.png # Screenshot +│ └── images/ # Screenshots ├── openclaw-windows-node.slnx # Solution file ├── README.md ├── LICENSE diff --git a/build.ps1 b/build.ps1 index e71743319..8d422ddcf 100644 --- a/build.ps1 +++ b/build.ps1 @@ -185,6 +185,18 @@ function Build-Project($name, $path, $useRid = $false) { } } +function Get-ProjectTargetFramework($path) { + if (-not (Test-Path $path)) { + return $null + } + + [xml]$projectXml = Get-Content $path -Raw + return $projectXml.Project.PropertyGroup | + ForEach-Object { $_.TargetFramework } | + Where-Object { $_ } | + Select-Object -First 1 +} + $projects = @{ "Shared" = @{ Path = "src/OpenClaw.Shared/OpenClaw.Shared.csproj"; UseRid = $false } "Cli" = @{ Path = "src/OpenClaw.Cli/OpenClaw.Cli.csproj"; UseRid = $false } @@ -230,8 +242,16 @@ if ($failCount -eq 0) { Write-Host "🦞 All builds succeeded!" -ForegroundColor Green Write-Host "`nTo run:" -ForegroundColor Cyan - if ($buildResults.ContainsKey("WinUI") -or $buildResults.ContainsKey("All")) { - Write-Host " WinUI: .\src\OpenClaw.Tray.WinUI\bin\$Configuration\net10.0-windows10.0.19041.0\$rid\OpenClaw.Tray.WinUI.exe" -ForegroundColor White + if (($buildResults.ContainsKey("WinUI") -and $buildResults["WinUI"]) -or ($buildResults.ContainsKey("Tray") -and $buildResults["Tray"])) { + $winUIProjectPath = $projects["WinUI"].Path + $winUITargetFramework = Get-ProjectTargetFramework $winUIProjectPath + $winUIProjectDirectory = (Split-Path -Parent $winUIProjectPath).Replace("/", "\") + + if ($winUITargetFramework) { + Write-Host " WinUI: .\$winUIProjectDirectory\bin\$Configuration\$winUITargetFramework\$rid\OpenClaw.Tray.WinUI.exe" -ForegroundColor White + } else { + Write-Warning "Unable to determine WinUI target framework from $winUIProjectPath" + } } } else { Write-Host "❌ $failCount build(s) failed" -ForegroundColor Red diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md new file mode 100644 index 000000000..e00e26c04 --- /dev/null +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -0,0 +1,230 @@ +# Connection Architecture + +This document describes the gateway connection system — how the tray app discovers, authenticates with, and maintains connections to OpenClaw gateways. + +## Project structure + +Connection management lives in three layers: + +``` +OpenClaw.Shared (net10.0) — WebSocket transport, gateway protocol, device identity + ↑ +OpenClaw.Connection (net10.0) — connection lifecycle, registry, credentials, state machine + ↑ +OpenClaw.Tray.WinUI (net10.0-windows) — UI app, tray icon, pages, windows +``` + +**OpenClaw.Shared** owns the low-level gateway clients (`OpenClawGatewayClient`, `WindowsNodeClient`, `WebSocketClientBase`), device identity/signing (`DeviceIdentity`), protocol models, and the `IOperatorGatewayClient` interface. + +**OpenClaw.Connection** owns all connection management: `GatewayConnectionManager`, `GatewayRegistry`, `CredentialResolver`, `ConnectionStateMachine`, `NodeConnector`, `SshTunnelService/Manager`, `SetupCodeDecoder`, and all connection interfaces/DTOs/enums. This project has zero WinUI dependencies and is independently testable. + +**OpenClaw.Tray.WinUI** consumes the connection layer through interfaces. It never creates gateway clients directly — `GatewayConnectionManager` owns that entirely. + +## Consumer API + +The tray app interacts with three main objects: + +### `IGatewayConnectionManager` — connection lifecycle + +```csharp +// Lifecycle +ConnectAsync(gatewayId?) // connect to active or specified gateway +DisconnectAsync() // tear down all connections +ReconnectAsync() // disconnect + connect +SwitchGatewayAsync(gatewayId) // switch to different gateway (stops tunnel, resets state) +ApplySetupCodeAsync(setupCode) // decode QR/setup code → register → connect + +// State +CurrentSnapshot // immutable GatewayConnectionSnapshot +OperatorClient // IOperatorGatewayClient for sending gateway requests +ActiveGatewayUrl // which gateway we're connected to +Diagnostics // ring buffer of connection events + +// Events +StateChanged // snapshot updated → UI refreshes tray icon, status +OperatorClientChanged // client swapped → rewire data event handlers +DiagnosticEvent // timeline entry for Connection Status window +``` + +### `GatewayRegistry` — gateway catalog + +```csharp +GetAll() / GetById(id) / GetActive() // read configured gateways +AddOrUpdate(record) // create or update a gateway record +SetActive(id) // switch which gateway is active +FindByUrl(url) // lookup by URL (deduplication) +Save() / Load() // persist to gateways.json +GetIdentityDirectory(id) // per-gateway identity directory path +MigrateFromSettings(...) // one-time legacy migration +``` + +### `IOperatorGatewayClient` — gateway API (via `OperatorClientChanged`) + +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. + +## Startup wiring (App.xaml.cs) + +``` +1. Create GatewayRegistry(dataDir) +2. Create CredentialResolver(identityReader) +3. Create GatewayClientFactory() +4. Create NodeConnector(logger) +5. Create SshTunnelManager(tunnelService, logger) +6. Create GatewayConnectionManager(resolver, factory, registry, ..., + nodeConnector, tunnelManager) +7. Subscribe to StateChanged → update tray icon + hub window +8. Subscribe to OperatorClientChanged → wire/unwire 25+ data event handlers +9. Subscribe to NodeConnector.ClientCreated → NodeService.AttachClient +10. Call ConnectAsync() → connects to active gateway +``` + +Settings changes are classified by `SettingsChangeClassifier.Classify()` which compares `ConnectionSettingsSnapshot` before/after to determine the minimum reconnect action: + +| Impact | Action | +|--------|--------| +| `NoOp` | Nothing | +| `UiOnly` | Nothing (UI preferences only) | +| `CapabilityReload` | Reload node capabilities | +| `NodeReconnectRequired` | Reconnect node only | +| `OperatorReconnectRequired` | Reconnect operator (SSH tunnel changed) | +| `FullReconnectRequired` | Full tear down and reconnect (gateway URL changed) | + +## Connection state machine + +`ConnectionStateMachine` (internal) drives state transitions for both operator and node roles: + +``` +Idle → Connecting → Connected + → PairingRequired → (approved) → Connected + → Error → (reconnect) → Connecting + → RateLimited +``` + +`OverallConnectionState` is derived from both roles: + +| Operator | Node | Overall | +|----------|------|---------| +| Error | * | Error | +| PairingRequired | * | PairingRequired | +| Connected | Connected | Ready | +| Connected | Error/Rejected | Degraded | +| Connected | PairingRequired | PairingRequired | +| Connected | Connecting | Connecting | +| Connected | Disabled/Off | Connected | + +## Gateway registry and persistence + +`GatewayRegistry` is the source of truth for configured gateways: + +``` +%APPDATA%\OpenClawTray\gateways.json — gateway records +%APPDATA%\OpenClawTray\gateways\\ — per-gateway identity directory +%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json — keypair + tokens +``` + +Each `GatewayRecord` contains: `Id`, `Url`, `FriendlyName`, `SharedGatewayToken`, `BootstrapToken`, `LastConnected`, `SshTunnel` config, and an `IdentityDirName`. + +`SettingsManager` still owns general tray settings (node mode, MCP mode, SSH tunnel toggles, notifications, UI preferences). It may read legacy `Token` / `BootstrapToken` JSON fields into memory for migration, but save must not write those legacy credential fields back. + +## Credential precedence + +Credential resolution order is intentionally strict: + +1. **Stored device token** in the per-gateway identity directory. +2. **`GatewayRecord.SharedGatewayToken`** — shared token for HTTP/chat surfaces. +3. **`GatewayRecord.BootstrapToken`** — one-time setup, limited scopes. +4. **No credential** — caller logs and skips client init. + +The invariant is that a paired device token always wins. Do not downgrade a paired operator or node to a shared/bootstrap token, because that can reduce scopes or trigger unnecessary re-pairing. + +**`CredentialResolver`** implements the precedence for WebSocket connections (operator and node roles). + +**`InteractiveGatewayCredentialResolver`** resolves credentials for HTTP surfaces (chat URL `?token=` auth). It **prefers SharedGatewayToken** over DeviceToken because HTTP endpoints expect the shared token, not the per-device WebSocket token. + +## Client instance lifecycle + +**Operator client** (`OpenClawGatewayClient`): Single instance at a time, owned by `GatewayConnectionManager`. Created via `GatewayClientFactory.Create()`. Old instance disposed before creating new one. `OperatorClientChanged` event notifies consumers of swaps. + +**Node client** (`WindowsNodeClient`): Two mutually exclusive creation paths: +- **Normal**: `NodeConnector` creates it → fires `ClientCreated` → `NodeService.AttachClient()` receives it (no new client created) +- **Local setup**: `NodeService.ConnectAsync()` creates its own client (used only during WSL local gateway setup) + +Both paths dispose old clients before creating new ones. + +## Setup-code and pairing flow + +Setup codes (from QR scan or paste) decode to `{ url, bootstrapToken }` via `SetupCodeDecoder`. The flow: + +1. `ApplySetupCodeAsync(code)` decodes and validates +2. Creates/updates a `GatewayRecord` with the bootstrap token +3. Clears stored device tokens (fresh pairing) +4. Connects to the new gateway +5. Gateway returns `hello-ok.auth.deviceToken` after pairing +6. Connection manager persists the device token to the identity file + +**Auto-approval**: When the node requires pairing and the operator has `operator.admin` or `operator.pairing` scope, `GatewayConnectionManager` automatically approves the node pairing request, waits 1 second, then reconnects the node. + +## SSH tunnel integration + +`SshTunnelService` manages an SSH local port-forward process. `SshTunnelManager` wraps it behind `ISshTunnelManager` for the connection manager. + +When a `GatewayRecord` has `SshTunnel` config, the connection manager starts the tunnel before connecting the WebSocket client to `ws://localhost:`. + +`SshTunnelSnapshot` provides a read-only point-in-time view of tunnel state for UI consumption (avoids coupling UI to the mutable service). + +## MCP-only mode + +`EnableMcpServer` and `EnableNodeMode` are independent: + +| EnableNodeMode | EnableMcpServer | Behavior | +|---|---|---| +| false | false | Operator-only tray app | +| false | true | Local MCP server only; no gateway required | +| true | false | Gateway node only | +| true | true | Gateway node plus local MCP server | + +The `EnableMcpServer=true`, `EnableNodeMode=false` path creates a local-only `NodeService` without requiring a gateway credential. + +## Tray action UX + +Tray actions should never silently no-op on common pairing/configuration issues: + +- Chat resolves credentials from the active registry record and per-gateway identity. If no usable credential exists, it opens Connection settings instead. +- Canvas opens only when the Windows node is initialized and paired; otherwise it opens Connection settings. +- Quick Send uses the live operator client and surfaces scope/pairing errors from gateway calls. + +## Legacy migration + +On first startup with a `GatewayRegistry`, if no active gateway record exists, the app migrates legacy settings credentials: + +- `LegacyToken` → `GatewayRecord.SharedGatewayToken` +- `LegacyBootstrapToken` → `GatewayRecord.BootstrapToken` +- Old identity file copied into per-gateway identity directory + +Migration is idempotent and deduplicates by URL. + +## Signature protocol + +The connect handshake uses Ed25519 signatures with v3→v2 fallback: +- Client tries v3 signature first (includes platform and device family) +- If gateway rejects v3, falls back to v2 and remembers for the session +- The `_gatewayNeedsV2Signature` flag persists across reconnects within the same `GatewayConnectionManager` lifetime + +## Tests + +Connection tests live in `tests/OpenClaw.Connection.Tests/` (215 tests): + +- `ConnectionStateMachineTests` — FSM transitions, derived overall state +- `CredentialResolverTests` — credential precedence for operator and node +- `GatewayConnectionManagerTests` — connect/disconnect/switch, diagnostics, handshake +- `GatewayRegistryTests` / `GatewayRegistryMigrationTests` — persistence, migration +- `InteractiveGatewayCredentialResolverTests` — HTTP credential resolution +- `NodeConnectorTests` — node client lifecycle +- `PairingFlowTests` / `NodePairAutoApproveTests` — pairing lifecycle, auto-approve +- `SetupCodeFlowTests` / `SetupCodeDecoderTests` — QR code → connect flow +- `StaleEventGuardTests` — generation-guarded event handling +- `SettingsChangeImpactTests` — settings change classification +- `RetryPolicyTests` — backoff policy +- `ConnectionDiagnosticsTests` — ring buffer diagnostics + +The heaviest remaining gap is Windows shell UI behavior (tray clicks, tooltip visibility, WinUI menu routing). Cover pure decision logic in unit tests; use manual or integration smoke tests for shell behavior. diff --git a/docs/DATA_FLOW_ARCHITECTURE.md b/docs/DATA_FLOW_ARCHITECTURE.md new file mode 100644 index 000000000..42cedf07e --- /dev/null +++ b/docs/DATA_FLOW_ARCHITECTURE.md @@ -0,0 +1,196 @@ +# Data Flow Architecture + +This document describes how gateway data flows from the WebSocket connection to the UI — the observable application model, event handling, and page update patterns. + +## Overview + +The tray app uses a single observable model (`AppState`) as the source of truth for all gateway-cached state. A dedicated event handler service (`GatewayService`) owns all 27 WebSocket event subscriptions and dispatches updates to `AppState` on the UI thread. Pages subscribe to `AppState.PropertyChanged` for live updates. + +```mermaid +flowchart TD + GW["Gateway WebSocket"] -->|27 events| GS["GatewayService"] + GS -->|EnqueueModelUpdate| AS["AppState (INPC)"] + AS -->|PropertyChanged| P1["ConnectionPage"] + AS -->|PropertyChanged| P2["SessionsPage"] + AS -->|PropertyChanged| P3["UsagePage"] + AS -->|PropertyChanged| P4["...14 pages total"] + AS -->|PropertyChanged| HW["HubWindow\n(title bar, nav sidebar)"] + AS -->|PropertyChanged| APP["App.xaml.cs\n(tray icon, tray menu)"] + GS -->|4 re-raised events| APP + APP -->|reads| AS + + style AS fill:#2d6a4f,color:#fff + style GS fill:#1b4332,color:#fff +``` + +## Key components + +### AppState (`Services/AppState.cs`) + +Single source of truth for all gateway-cached data. Implements `INotifyPropertyChanged`. + +``` +src/OpenClaw.Tray.WinUI/Services/AppState.cs +``` + +**Properties** (24+): `Status`, `CurrentActivity`, `Sessions`, `Channels`, `Nodes`, `Usage`, `UsageCost`, `UsageStatus`, `GatewaySelf`, `NodePairList`, `DevicePairList`, `ModelsList`, `Presence`, `AgentsList`, `Config`, `ConfigSchema`, `SkillsData`, `CronList`, `CronStatus`, `CronRuns`, `AgentFilesList`, `AgentFileContent`, `AuthFailureMessage`, `UpdateInfo`, `LastCheckTime` + +**Threading model**: All property writes must happen on the UI thread (enforced by `Debug.Assert` in `SetField`). This guarantees `PropertyChanged` fires on the UI thread, so pages can update UI directly without `DispatcherQueue.TryEnqueue`. + +**Lifetime**: Created once in `App.OnLaunched`, lives for the app's lifetime. Accessible globally via `((App)Application.Current).AppState`. Connections come and go; pages always have a single stable view of the data. + +**Key methods**: +- `ClearCachedData()` — resets all gateway data fields on disconnect (does NOT reset `Status` — that's managed by `OnManagerStateChanged`) +- `AddAgentEvent(evt)` — ring buffer, newest-first, capped at 400 +- `GetAgentIds()` — computed from `AgentsList` JSON +- `SetSessionPreview/GetSessionPreview/PruneSessionPreviews` — thread-safe via lock + +### GatewayService (`Services/GatewayService.cs`) + +Owns all 27 operator gateway client event subscriptions. Moved from App.xaml.cs to separate concerns. + +``` +src/OpenClaw.Tray.WinUI/Services/GatewayService.cs +``` + +**Event categories**: + +| Category | Count | Pattern | Examples | +|----------|-------|---------|----------| +| A: Simple data | 15 | `EnqueueModelUpdate(() => _state.X = value)` | Usage, Config, Models, Presence | +| B: Complex data | 8 | Update AppState + side effects (logging, dedup, throttling) | Channels, Sessions, Nodes, Activity | +| C: Re-raised | 4 | Update AppState + re-raise event for App | StatusChanged, AuthFailed, SessionCommand, Notification | + +**Stale-event safety**: Uses a generation counter (`_clientGeneration`) that increments on every `AttachClient` call. The `EnqueueModelUpdate` helper captures the generation and re-checks inside the dispatcher callback, preventing stale events from an old client from mutating state after a client swap. + +```csharp +private void EnqueueModelUpdate(Action update) +{ + var gen = _clientGeneration; + _dispatcher.TryEnqueue(() => + { + if (gen == _clientGeneration) update(); + }); +} +``` + +**Client lifecycle**: `AttachClient(newClient, oldClient)` unsubscribes from old, increments generation, clears service-level caches (channel signature, session activities, display state), subscribes to new. + +### App.xaml.cs — orchestration layer + +App creates `AppState` and `GatewayService` in `OnLaunched` and wires them together: + +```mermaid +sequenceDiagram + participant CM as ConnectionManager + participant App as App.xaml.cs + participant GS as GatewayService + participant AS as AppState + participant HW as HubWindow + participant Page as Active Page + + CM->>App: OperatorClientChanged + App->>GS: AttachClient(new, old) + Note over GS: Unsubscribe old, subscribe new + + CM->>App: StateChanged (connect/disconnect) + App->>AS: Status = mapped (on UI thread) + + Note over GS: Gateway event arrives (BG thread) + GS->>AS: EnqueueModelUpdate (UI thread) + AS->>Page: PropertyChanged + AS->>HW: PropertyChanged (title bar) + AS->>App: PropertyChanged (tray icon/menu) +``` + +**What stays in App**: +- `OnManagerStateChanged` — maps `GatewayConnectionSnapshot` to `ConnectionStatus`, writes `AppState.Status` +- Node service handlers — `OnNodeStatusChanged`, `OnPairingStatusChanged`, etc. +- Toast/notification display (via `ToastService`) +- Window management — `ShowHub`, `ShowChatWindow`, `ShowVoiceOverlay` +- Tray icon/menu updates (subscribes to `AppState.PropertyChanged`) +- `IAppCommands` implementation + +**What moved out**: +- 27 gateway event handlers → `GatewayService` +- 20+ `_last*` cached fields → `AppState` properties +- Toast dedup state → `ToastService` +- Diagnostic clipboard methods → `DiagnosticsClipboardService` + +### Pages — direct observation + +Pages access `AppState` globally and subscribe to `PropertyChanged` for live updates. They no longer depend on `HubWindow` for data. + +``` +// Every page that observes gateway data: +private static App CurrentApp => (App)Application.Current; +private AppState? _appState; + +public void Initialize() +{ + _appState = CurrentApp.AppState; + _appState.PropertyChanged += OnAppStateChanged; +} + +private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e) +{ + switch (e.PropertyName) + { + case nameof(AppState.Sessions): + // UI update — already on UI thread + break; + } +} +``` + +**Page observation map**: + +| Page | Observes | +|------|----------| +| ConnectionPage | Status, NodePairList, DevicePairList, Channels, UsageCost, Sessions, GatewaySelf | +| SessionsPage | Sessions, ModelsList | +| ChannelsPage | Channels | +| UsagePage | Usage, UsageCost, UsageStatus | +| InstancesPage | Nodes, Presence | +| ConfigPage | Config, ConfigSchema | +| BindingsPage | Config | +| CronPage | CronList, CronStatus, CronRuns | +| SkillsPage | SkillsData | +| WorkspacePage | AgentFilesList, AgentFileContent | +| AgentEventsPage | AgentEventAdded (separate event) | +| AboutPage | GatewaySelf | + +Pages that don't observe AppState: ActivityPage, ChatPage, SettingsPage, SandboxPage, VoiceSettingsPage. + +### HubWindow — minimal role + +HubWindow's role is now limited to: +- **Title bar** — subscribes to `AppState.Status` and `AppState.GatewaySelf` for status/version display +- **Navigation sidebar** — subscribes to `AppState.AgentsList` to rebuild agent nav items +- **Page lifecycle** — `InitializeCurrentPage()` calls `page.Initialize()` when the user navigates + +HubWindow no longer caches gateway data or forwards updates to pages. + +## Service file map + +``` +src/OpenClaw.Tray.WinUI/Services/ +├── AppState.cs — Observable model (INPC, 24+ properties) +├── GatewayService.cs — 27 event subscriptions, UI dispatch +├── IAppCommands.cs — Page → App command interface +├── ToastService.cs — Toast display, dedup, sound config +├── DiagnosticsClipboardService.cs — Copy* diagnostic clipboard methods +├── AppStateSnapshot.cs — Frozen snapshot for CommandCenter +├── TrayStateSnapshot.cs — Frozen snapshot for tray tooltip +├── TrayMenuSnapshot.cs — Frozen snapshot for tray menu builder +├── TrayMenuStateBuilder.cs — Builds tray popup menu UI +└── TrayTooltipBuilder.cs — Builds tray tooltip string +``` + +## Threading rules + +1. **AppState writes**: UI thread only. `SetField` asserts `DispatcherQueue.HasThreadAccess`. +2. **GatewayService handlers**: Run on WebSocket background threads. Use `EnqueueModelUpdate` to dispatch to UI thread before writing to AppState. +3. **PropertyChanged handlers**: Fire on UI thread (guaranteed by rule 1). Pages can update UI directly — no `TryEnqueue` needed. +4. **Session previews**: Thread-safe via `lock` (read from any thread, write from any thread). +5. **Tray menu refresh**: Debounced via `DispatcherQueuePriority.Low` to coalesce rapid AppState changes. diff --git a/docs/GATEWAY_COMPAT_TESTING.md b/docs/GATEWAY_COMPAT_TESTING.md new file mode 100644 index 000000000..7f9d7fd2a --- /dev/null +++ b/docs/GATEWAY_COMPAT_TESTING.md @@ -0,0 +1,156 @@ +# Gateway Compatibility Testing + +> Goal: catch openclaw gateway regressions before they hit Windows tray +> users, and pin the tray installer to a Last-Known-Good (LKG) gateway +> version that we have verified end-to-end. + +This document is the operator-facing companion to the implementation plan +in `C:\repos\copilotdocs\gateway-compat-ci-plan.md`. The plan covers +**why**; this document covers **how to use** the resulting CI. + +## Pieces + +| File / surface | Purpose | +|---------------------------------------------------------|---------| +| `gateway-lkg.json` | Source of truth for the pinned gateway version (tooling) | +| `src/OpenClaw.Shared/GatewayLkg.cs` | Compile-time constants mirrored from the JSON (binary) | +| `OpenClaw.Shared.Tests.GatewayLkgTests` | Build-time enforcement that JSON and constants agree | +| `tools/fake-llm-server/` | Minimal OpenAI-compatible HTTP mock used by compat tests | +| `.github/workflows/gateway-compat-spike.yml` | One-shot diagnostic for WSL/openclaw/provider-config shape on real CI (W0) | +| `.github/workflows/gateway-compat.yml` *(planned, W5)* | Full compat suite (PR: subset vs LKG; nightly: LKG + latest) | +| `.github/workflows/gateway-lkg-bump.yml` *(planned, W5)*| Scheduled poll of `openclaw` npm; opens auto-PR bumping the LKG | +| `src/OpenClaw.Tray.WinUI/Services/TestHooks/` (`#if OPENCLAW_E2E_HOOKS`) | `tray.testhook.*` MCP tools that drive setup/pairing/chat without UI | +| `OpenClaw.Tray.Tests.ReleaseBuildExcludesTestHooksTests`| Asserts shipped tray binary contains no test-hook types | + +## Bumping the LKG + +Normal flow is fully automated; humans only review the PR. + +1. `.github/workflows/gateway-lkg-bump.yml` polls + `https://registry.npmjs.org/openclaw` every 6 hours. +2. If `dist-tags.latest` differs from `gateway-lkg.json` `version`, + the workflow runs the full `gateway-compat` suite against the + candidate version. +3. On green, it opens a PR that updates `gateway-lkg.json` **and** + `src/OpenClaw.Shared/GatewayLkg.cs` together (the unit test + enforces drift = build failure). +4. PR body includes: + - candidate version + tarball shasum + npm publish time + - changelog link + - link to the green compat run +5. **PRs are never auto-merged.** A CODEOWNER reviews the candidate + changelog for behavior changes, then merges. + +### Bumping manually + +```powershell +$env:OPENCLAW_GATEWAY_VERSION = "2026.6.0" # candidate +gh workflow run gateway-compat.yml -F gateway_version=$env:OPENCLAW_GATEWAY_VERSION +# Wait for green, then update gateway-lkg.json + GatewayLkg.cs and PR. +``` + +### Failure triage + +When `gateway-lkg-bump.yml` fails: +- The PR is **not** opened; the old LKG stays pinned (tray users are + unaffected). +- A tracking issue is opened (or updated) describing which scenario + regressed. File a downstream issue against the gateway repo if + upstream broke us; otherwise patch the tray. + +## Running locally + +### Quick sanity + +```powershell +./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 +``` + +### Drive a different gateway version + +```powershell +$env:OPENCLAW_GATEWAY_VERSION = "latest" # or e.g. "2026.5.18" +./src/OpenClaw.Tray.WinUI/bin/Debug/net10.0-windows10.0.22621.0/win-arm64/OpenClaw.Tray.WinUI.exe +``` + +The env var is honored in any build (Debug or Release) to support CI +matrices and hands-on validation; the shipped default is the LKG. + +### Build the tray with test hooks enabled + +> ⚠ **For local development only. Never ship this binary.** + +```powershell +dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-arm64 -p:OpenClawEnableTestHooks=true +``` + +A Release-build smoke test +(`OpenClaw.Tray.Tests.ReleaseBuildExcludesTestHooksTests`) fails loudly +if a build with `-p:OpenClawEnableTestHooks=true` is run against the +production-shape verification. + +### Run the fake LLM standalone + +```powershell +node tools/fake-llm-server/server.mjs # listens on 127.0.0.1:18888 +``` + +Point any openai-compatible provider at `http://127.0.0.1:18888/v1` +(any API key works). + +## Adding a new compat scenario + +1. Decide which `tray.testhook.*` tool you need. If it doesn't exist, + add it under `src/OpenClaw.Tray.WinUI/Services/TestHooks/` inside the + `#if OPENCLAW_E2E_HOOKS` block. Add a unit test for the tool itself. +2. Add the new test under `tests/OpenClaw.GatewayCompat.E2ETests/` (one + file per scenario). The fixture handles tray spawn + isolated AppData + + fake-LLM lifecycle. +3. Update `.github/workflows/gateway-compat.yml`: + - if the scenario is fast and stable, add it to the PR subset; + - otherwise keep it in the nightly-only lane. +4. Document the scenario here. + +### Same-path-as-user rule (mandatory) + +When you add or modify a `tray.testhook.*` tool, the tool MUST invoke +the same method the matching UI click handler invokes. If the UI handler +does the work inline, extract it into a shared service method first and +have BOTH the handler and the test hook call that method. + +This rule exists because gateway-compat's whole purpose is catching +regressions in the production code path a real user hits. A test that +passes against a parallel implementation tells us nothing. + +Concrete examples: + +| Test hook | Shared method (called by both UI and hook) | UI caller | +|---|---|---| +| `tray.testhook.localSetup.start` | `App.CreateLocalGatewaySetupEngine(...).RunLocalOnlyAsync(...)` | `LocalSetupProgressPage` "Set up locally" handler | +| `tray.testhook.chat.send` | `OpenClawChatDataProvider.SendMessageAsync(...)` | `ChatWindow.OnSendClicked` | +| `tray.testhook.pairing.reset` | `GatewayRegistry.Reset(...)` + per-gateway key wipe (one helper) | Settings page "Reset pairing" button | +| `tray.testhook.connection.waitFor` | `GatewayConnectionManager` state observer (read, no write) | observed by every UI surface that shows connection state | + +When you write a new hook, **include a code comment naming the UI +caller and the shared method**. The matching unit test should assert +behavior, not implementation, so a future refactor that consolidates +two handlers into one still passes. + +## Extending the fake LLM + +The mock at `tools/fake-llm-server/server.mjs` starts intentionally tiny +(one OpenAI-completions endpoint, non-streaming, echoes the user +message). Extend only when a scenario demands it: + +- **Streaming**: add `text/event-stream` handling on the same endpoint + when a scenario asserts streaming UX. +- **Tool calls**: when a `node.invoke` scenario needs the LLM to emit + tool calls, gate the synthetic call on a sentinel prompt + (`"call "`) so existing scenarios stay deterministic. +- **Anthropic `/v1/messages`**: only add if a routing test specifically + requires it. Most scenarios are served by the openai-compatible path. + +Every extension should preserve `/__assert/last-request` so the +harness can keep asserting on what the gateway sent. diff --git a/docs/LOCALIZATION_CHECK.md b/docs/LOCALIZATION_CHECK.md new file mode 100644 index 000000000..c17fbb5d0 --- /dev/null +++ b/docs/LOCALIZATION_CHECK.md @@ -0,0 +1,25 @@ +# Localization check + +OpenClaw WinUI strings should stay in `src\OpenClaw.Tray.WinUI\Strings\\Resources.resw`. XAML uses `x:Uid` for static UI text, and runtime strings should go through `LocalizationHelper.GetString(...)` or `LocalizationHelper.Format(...)`. + +Run the regular check before changing UI copy: + +```powershell +.\scripts\Test-Localization.ps1 +``` + +For a stricter audit that also fails on candidate hard-coded XAML text: + +```powershell +.\scripts\Test-Localization.ps1 -StrictHardcodedXaml +``` + +When adding or changing user-facing text: + +1. Add an `x:Uid` to the XAML element that owns the visible text. +2. Add matching keys to every `Resources.resw` file, for example `MyControl.Text` or `MyButton.Content`. +3. Preserve format placeholders like `{0}` in every locale. +4. Keep only true identifiers, URLs, model names, and brand names hard-coded. +5. Re-run the localization check and the tray tests. + +The `localization-audit` agentic workflow runs this audit on a schedule and can open a draft PR when it finds localization drift. diff --git a/docs/ONBOARDING_V2.md b/docs/ONBOARDING_V2.md new file mode 100644 index 000000000..abfbeb07b --- /dev/null +++ b/docs/ONBOARDING_V2.md @@ -0,0 +1,162 @@ +# OpenClaw Setup — V2 (redesigned onboarding flow) + +Status: **the only setup UI shell in the app**. Setup is now scoped to +installing a new app-owned local WSL gateway. Existing and remote gateway +management lives in the tray app's Connections tab. + +## Where the V2 code lives + +| Project / file | Role | +| --- | --- | +| `src/OpenClawTray.OnboardingV2/` | New class library: state, app shell, page components, animations, V2Strings. Builds against `OpenClawTray.FunctionalUI` (the "minimal Reactor"). | +| `src/OpenClawTray.OnboardingV2/Pages/` | One file per page: `WelcomePage.cs`, `LocalSetupProgressPage.cs`, `GatewayWelcomePage.cs`, `PermissionsPage.cs`, `AllSetPage.cs`. | +| `src/OpenClawTray.OnboardingV2/Animations.cs` | Composition-animation helpers (`WithEntranceFadeIn`, `WithEntrancePopIn`, `WithSlideInFromBelow`, `WithBreathe`). All gated on `ShouldAnimate` (false in capture mode and when `UISettings.AnimationsEnabled == false`). | +| `src/OpenClawTray.OnboardingV2/V2Strings.cs` | Resource-key dictionary + settable `Resolver` delegate. `Get(key)` falls back to the bundled English values when the resolver returns null/empty/echoes the key. | +| `src/OpenClawTray.OnboardingV2/OnboardingV2State.cs` | Mutable shared state with property setters that raise `StateChanged` (the V2 app subscribes and bumps a render tick). Includes cutover-staged shape: `GatewayUrl`, `GatewayHealthy`, `LaunchAtStartup`, `Permissions` (`IReadOnlyList?`), `PermissionRowSnapshot`, `PermissionSeverity`. | +| `src/OpenClawTray.OnboardingV2/OnboardingV2App.cs` | Root `Component`. Owns the page area + nav bar (Back / Next-or-Finish + dot indicator). Welcome has no chrome by design. | +| `src/OpenClaw.SetupPreview/` | Standalone WinUI 3 unpackaged exe used for the inner-loop. Mounts the V2 tree against a fake `OnboardingV2State`. Reads env vars to switch page / scenario / locale and to enter capture mode (`OPENCLAW_PREVIEW_CAPTURE=1`). | +| `tools/v2_visual_diff.py` | Renders side-by-side `expected | actual` PNGs by spawning the SetupPreview exe in capture mode for each page scenario. The agent then *views* those PNGs and judges visual parity. | +| `tools/v2-design-refs/Dialog{,-1..-6}.png` | Designer source of truth (committed). | + +## How the inner loop works + +``` +edit V2 page → python tools/v2_visual_diff.py --pages welcome + → view out/v2-visual/welcome/diff.png + → iterate +``` + +`v2_visual_diff.py` does an incremental `dotnet build` of +`OpenClaw.SetupPreview` once per invocation (cached so `--all` only +builds once). Each page render takes ~2-3 s on a warm tree. + +Page scenarios baked into `PAGES` in `tools/v2_visual_diff.py`: + +| Key | Page | Notes | +| --- | --- | --- | +| `welcome` | Welcome | `Dialog.png` — no chrome, lobster + CTA + Advanced setup. | +| `progress-running` | LocalSetupProgress (in-flight) | `Dialog-1.png` — running stage card. | +| `progress-failed` | LocalSetupProgress (failure) | `Dialog-6.png` — pink Try-again card slides in. | +| `gateway` | GatewayWelcome | `Dialog-2.png` — gateway URL + health checkmark. | +| `permissions` | Permissions | `Dialog-5.png` — 5 capability rows + Refresh status. | +| `allset` | AllSet (node-mode active) | `Dialog-4.png` — amber Node-Mode card + Launch toggle. | +| `allset-no-node` | AllSet (node-mode off) | Variant — no amber card. | + +## Cutover + +The V2 flow is mounted in the live app via `OnboardingWindow`. The old +standalone/fallback v1 setup shell and pages have been removed. The +provider/model setup experience is hosted by the explicit tray-side +`GatewayWizardPage` / `GatewayWizardState` pair and embedded inside V2's +`GatewayWelcomePage`. + +Service wiring is centralised in +[`OnboardingV2Bridge`](../src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs): + +| Real service | V2 state field | Notes | +| --- | --- | --- | +| `LocalGatewaySetupEngine.StateChanged` | `LocalSetupRows`, `LocalSetupErrorMessage` | Re-uses `LocalSetupProgressStageMap` so setup stage behavior stays consistent. | +| `PermissionChecker.CheckAllAsync` + `SubscribeToAccessChanges` | `Permissions` | Snapshot list of `PermissionRowSnapshot`. Marshals back to UI thread. | +| `SettingsManager.GetEffectiveGatewayUrl` | `GatewayUrl` | Flips `ws://` → `http://` for the browser-launch link. | +| `SettingsManager.AutoStart` ↔ `LaunchAtStartup` | `LaunchAtStartup` | Two-way: initial value from settings; toggle change calls `_settings.Save()`. | +| `Settings.EnableNodeMode` | `NodeModeActive` | Seeded once at construction. | +| `GatewayRegistry` + WSL distro probe | `ExistingGateway` | Drives Welcome CTA/warning behavior for none, app-owned local WSL, and external-only connections. | + +Threading: every cross-thread mutation marshals through +`DispatcherQueue.TryEnqueue`. The V2 state's `StateChanged` event fires +on the UI thread, bumping a render tick in +[`OnboardingV2App.UseEffect`](../src/OpenClawTray.OnboardingV2/OnboardingV2App.cs). + +Completion: `OnboardingWindow.TryCompleteOnboarding` treats +`V2Route.AllSet` as the terminal setup page. The bridge's `Finished` event +closes the window, which routes through the shared completion logic — +persisting `Settings.AutoStart` via `AutoStartManager`, firing +`OnboardingCompleted`, and launching `HubWindow` on the chat tab when +setup is complete. + +Advanced setup: Welcome's "Advanced setup" link raises +`OnboardingV2State.AdvancedSetupRequested`; `OnboardingWindow` closes setup +without completing it and opens `HubWindow` on the Connections tab. Users +connect to existing, remote, or manual gateways there. + +Existing connections: first-run setup no longer opens automatically when +there is any usable saved gateway connection. Users can intentionally start +local setup from the Connections tab via **Install new WSL Gateway**. + +Welcome CTA/warnings: + +1. No existing gateway: primary CTA stays **Set up locally**. +2. Existing app-owned WSL gateway: primary CTA becomes **Install new WSL Gateway**; confirmation warns that the current OpenClaw WSL gateway and `OpenClawGateway` distro will be deleted before a fresh install. +3. External-only gateway: primary CTA stays **Set up locally**; confirmation says a new local WSL gateway will be installed and connected, while the external gateway remains available in Connections. + +## Follow-up backlog + +The cutover deliberately scopes down to the items below to keep this PR +reviewable. None are blocking — V2 works end-to-end without them. + +1. **Restyle the gateway wizard to native V2 UI.** The current + `GatewayWizardPage` is no longer part of the v1 shell, but it still owns + its own card/buttons while the gateway-driven provider/model flow remains + embedded in V2. +2. **Real translations for V2_* keys.** `tools/seed_v2_resw.py` seeds + every V2_* key into all five `.resw` locales with the English value + and the `Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales` + test is taught (via a `key.StartsWith("V2_")` predicate) that they + are intentionally English-only at first ship. Translations land in + a follow-up by replacing each non-en-us value. + +## Animation discipline + +- All animations live in `src/OpenClawTray.OnboardingV2/Animations.cs`. + Pages opt in via extension methods (`element.WithEntranceFadeIn(...)`). +- Pages must call `ElementCompositionPreview.SetIsTranslationEnabled(fe, true)` + before animating `Translation`. The helper does this for you. +- The `ShouldAnimate` predicate gates every helper. It returns `false` + if `V2Animations.DisableForCapture` is set OR if + `Windows.UI.ViewManagement.UISettings.AnimationsEnabled` is `false` + (Windows reduce-motion). The SetupPreview sets `DisableForCapture` in + capture mode so screenshots are deterministic. +- Don't add page-local `Composition` calls; extend `Animations.cs` so + the gating stays centralised. + +## Accessibility checklist + +- [x] Re-enabled WinUI's system focus visuals (cyan ring) on every V2 + button by removing `UseSystemFocusVisuals = false`. +- [x] Stable `AutomationProperties.AutomationId` on Back / Next / + Finish nav buttons and on every page CTA. +- [x] `AutomationProperties.Name` on the AllSet launch-at-startup + `ToggleSwitch` (which uses empty `OnContent`/`OffContent` so the + "On" label can render to the toggle's left, matching the design). +- [x] `AutomationProperties.Name` on the custom title bar, the lobster + icon, and the title text. +- [ ] Keyboard nav verified end-to-end against the live UI (cutover + gate — capture mode skips animation, we should manually confirm + tab order in interactive mode). +- [ ] Screen reader smoke-test (Narrator + NVDA) at cutover. + +## Visual validation + +`python tools/v2_visual_diff.py --all` regenerates side-by-side +PNGs under `out/v2-visual//diff.png`. A human (or the agent +running this codebase) opens those PNGs and judges parity directly +against the designer references in `tools/v2-design-refs/`. We +intentionally do not pixel-diff — small, intentional rendering +differences (DPI scaling, font hinting, drop shadows) would dominate +the signal. + +When running visual validation: + +1. Render all pages: `python tools/v2_visual_diff.py --all`. +2. View each `diff.png` and note any discrepancies in: + - Layout / spacing / alignment + - Typography (size, weight) + - Color (especially accent cyan, accent green, error pink, amber + warning) + - Iconography (asset / size / position) + - Copy (the V2Strings dictionary holds the source of truth — the + designer mocks contain a couple of typos we intentionally fixed: + `localhost18789` → `http://localhost:18789`, `Stays` → `stays`, + and `Acvtive` → `Active`). +3. If any discrepancy matters, edit the relevant page, re-render, and + visually re-check until parity is restored. diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index a9aeaac33..a7df9239c 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -1,37 +1,28 @@ # Onboarding Wizard -The onboarding wizard is a guided 6-screen setup experience for new Windows users, matching the macOS onboarding flow. +The onboarding wizard is now the V2 setup flow for installing a new app-owned local WSL gateway on Windows. ## Overview -On first launch (or when no gateway token is configured), the wizard walks users through: +On first launch, the wizard appears only when there is no usable saved gateway connection. Users with any existing local or external gateway manage connections from the tray app's Connections tab and can start setup intentionally with **Install new WSL Gateway**. + +The V2 setup flow walks users through: 1. **Welcome** — Greeting and introduction -2. **Connection** — Gateway selection and authentication -3. **Wizard** — Gateway-driven configuration (AI provider, personality, channels) +2. **Local setup progress** — App-owned `OpenClawGateway` WSL installation +3. **Gateway setup** — Gateway-driven provider/model configuration hosted by `GatewayWizardPage` 4. **Permissions** — Windows system permission review -5. **Chat** — First conversation with the agent -6. **Ready** — Feature summary and completion +5. **All set** — Feature summary and completion -The wizard adapts based on the connection mode: -- **Local gateway**: All 6 screens (including Wizard for gateway configuration) -- **Remote gateway**: Skips Wizard (assumes gateway is pre-configured) -- **Configure Later**: Minimal flow — Welcome → Connection → Ready +The setup flow no longer configures remote/manual gateways. The Welcome page's **Advanced setup** link closes setup and opens the tray app's Connections tab. ## Screen Details ### Welcome -Displays the OpenClaw lobster icon, app title, and a brief description. Single "Get Started" button advances to Connection. - -### Connection -Three connection modes via radio buttons: -- **Local** — Pre-fills `ws://localhost:18789` for a gateway running on the same machine or in WSL -- **Remote** — Enter a gateway URL and bootstrap token, or paste a base64url-encoded setup code -- **Later** — Skip connection for now; configure from the tray menu after setup +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. -Connection testing performs a real WebSocket handshake with Ed25519 device authentication. Status feedback shows connecting, connected, pairing required, token mismatch, or timeout. - -When pairing approval is required, the wizard displays the gateway CLI approval command, copies it to the clipboard, and shows a notification with a copy action. Approval still happens through the gateway's normal `openclaw devices approve ` flow; the Windows tray does not edit gateway pairing state directly. +### 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. ### Wizard Renders server-defined setup steps via RPC (`wizard.start` / `wizard.next`). The gateway controls the flow — steps can be: @@ -53,26 +44,23 @@ Checks 5 Windows permissions using native APIs and registry: Each permission shows its current status (Enabled/Disabled/Allowed/Denied) with an "Open Settings" button linking to the relevant `ms-settings:` URI. -### Chat -Embeds the gateway's web chat UI via WebView2, matching the post-setup `ChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization. - -On first load, a bootstrap message is auto-injected to kick off the gateway's first-run ritual (BOOTSTRAP.md). The message is safely encoded using `JsonSerializer.Serialize` to prevent XSS. - -### Ready -Displays 5 feature cards (Tray Menu, Channels, Voice, Canvas, Skills) with localized subtitles. Includes a "Launch at Login" toggle and a "Finish" button that saves settings and closes the wizard. +### All set +Displays a completion summary, a Launch at startup toggle, and a Finish button that saves settings and closes setup. ## Security The onboarding wizard follows these security practices: -- **XSS prevention**: Bootstrap messages encoded via `JsonSerializer.Serialize` for safe JS injection - **Input validation**: Setup codes limited to 2KB, decoded JSON validated, gateway URLs checked via `GatewayUrlHelper` -- **URI scheme whitelists**: Only `ms-settings:` for permissions, `http/https` for chat -- **Navigation restriction**: WebView2 `NavigationStarting` handler blocks navigation to external origins -- **Token protection**: Query params stripped from all log output; WebView2 accelerator keys disabled +- **URI scheme whitelists**: Only `ms-settings:` for permissions and `http/https` for browser-launch links +- **Token protection**: Query params stripped from all log output - **Gateway-owned pairing**: Device approval uses the gateway CLI/API path so scope checks, token issuance, audit, and broadcasts stay centralized - **Error sanitization**: Exception details logged but not shown to users +## Credential Storage + +Gateway credentials are registry-backed. Setup codes and QR payloads create or update a `GatewayRecord`; bootstrap credentials live in `GatewayRecord.BootstrapToken`, long-lived manual tokens live in `GatewayRecord.SharedGatewayToken`, and post-pairing device tokens are saved in the per-gateway identity directory. `SettingsManager` may read legacy `Token` / `BootstrapToken` JSON fields for migration, but it does not write them back. + ## Localization All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages are discovered from the `Strings//Resources.resw` directories; the current locales are English, French, Dutch, Chinese Simplified, and Chinese Traditional. @@ -85,7 +73,7 @@ See [DEVELOPMENT.md](../DEVELOPMENT.md#developing--testing-the-onboarding-wizard ### Test Isolation -`SettingsManager` loads `%APPDATA%\OpenClawTray\settings.json` by default. Onboarding tests must not use `new SettingsManager()` without an isolated settings directory, because local user settings such as `EnableNodeMode=true` change page ordering by intentionally skipping operator-only Wizard and Chat pages. +`SettingsManager` loads `%APPDATA%\OpenClawTray\settings.json` by default. Onboarding tests must not use `new SettingsManager()` without an isolated settings directory, because local user settings such as `EnableNodeMode=true` change setup behavior. Use a temp settings directory for tests that construct `SettingsManager`, or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts. @@ -93,13 +81,16 @@ Use a temp settings directory for tests that construct `SettingsManager`, or set | Path | Purpose | |------|---------| -| `Onboarding/OnboardingWindow.cs` | Host window with WebView2 overlay | -| `Onboarding/OnboardingApp.cs` | Functional UI root component, page navigation | -| `Onboarding/Services/OnboardingState.cs` | Shared state across all pages | -| `Onboarding/Pages/*.cs` | Individual wizard screens | -| `Onboarding/Services/SetupCodeDecoder.cs` | Base64url setup code parsing | +| `Onboarding/OnboardingWindow.cs` | Host window for the V2 setup shell | +| `src/OpenClawTray.OnboardingV2/OnboardingV2App.cs` | V2 Functional UI root component and page navigation | +| `src/OpenClawTray.OnboardingV2/OnboardingV2State.cs` | V2 shared setup state | +| `Onboarding/GatewayWizard/GatewayWizardState.cs` | Host-owned state for the embedded gateway wizard | +| `Onboarding/GatewayWizard/GatewayWizardPage.cs` | Embedded provider/model setup page inside V2 | +| `Services/LocalGatewaySetup/SetupCodeDecoder.cs` | Base64url setup code parsing used from Connections | | `Onboarding/Services/InputValidator.cs` | Security input validation | | `Onboarding/Services/WizardStepParser.cs` | Wizard JSON step parsing | | `Onboarding/Services/LocalGatewayApprover.cs` | Local gateway URL classification | | `Onboarding/Services/PermissionChecker.cs` | Windows permission checks | -| `Helpers/GatewayChatHelper.cs` | Shared WebView2 chat URL builder | +| `Services/Connection/GatewayRegistry.cs` | Persistent gateway records and migration target | +| `Services/Connection/GatewayConnectionManager.cs` | Operator/node connection lifecycle used by onboarding | +| `Services/SetupExistingGatewayClassifier.cs` | Existing gateway classification for V2 Welcome and startup gating | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb9b9f576..0cd477f55 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,6 +16,17 @@ The canonical release flow is **tag-driven**, not manual file patching. ``` 3. CI (`.github/workflows/ci.yml`) builds/signs/publishes artifacts and creates the GitHub release from that tag. +## Alpha MSIX releases + +Alpha tags use the same signed CI release pipeline, but GitHub marks them as pre-releases and not latest releases so normal updater checks do not offer them to users. + +```powershell +git tag -a vX.Y.Z-alpha.1 -m "Alpha vX.Y.Z-alpha.1" +git push origin vX.Y.Z-alpha.1 +``` + +The stable MSIX package identity is `OpenClaw.Companion`. Alpha-tagged MSIX packages are patched during CI to use `OpenClaw.Companion.Alpha`, which lets testers install the signed alpha package without upgrading a stable MSIX install. Command Palette packaging remains separate from the MSIX package. + ## Why this is the correct flow - `GitVersion.yml` is configured for `ContinuousDelivery` with `tag-prefix: 'v'`. @@ -50,3 +61,29 @@ git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin vX.Y.Z ``` +## Gateway version (LKG) pinning + +The default `openclaw` gateway version the tray installs during local setup +is pinned at the repo root in [`gateway-lkg.json`](../gateway-lkg.json) and +mirrored as compile-time constants in +[`src/OpenClaw.Shared/GatewayLkg.cs`](../src/OpenClaw.Shared/GatewayLkg.cs). +A unit test (`GatewayLkgTests`) fails the build on drift between the two, +so the pair always ships in lockstep. + +- **Bumping the pin** is automated by `.github/workflows/gateway-lkg-bump.yml` + (scheduled poll of the npm `openclaw` package). The workflow runs the full + `gateway-compat` suite against the candidate version and, only on green, + opens a PR updating both files. **PRs are never auto-merged** — a human + CODEOWNER must review the candidate (changelog link, tarball shasum, and + npm publish time are included in the PR body). +- **Runtime override** (no rebuild required): set + `OPENCLAW_GATEWAY_VERSION` (e.g. `latest` or `2026.5.18`) before launching + the tray. Useful for the CI compat matrix and for hands-on validation; the + shipped installer always uses the pinned LKG by default. +- **Refusing prereleases**: the auto-bump workflow ignores `-alpha`, `-beta`, + `-rc` dist-tags. Bump them manually if you want them tested. + +See `docs/GATEWAY_COMPAT_TESTING.md` for the surrounding compatibility CI +architecture. + + diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index ea6534659..debfb9f72 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,17 +1,17 @@ # Test Coverage Summary -**1570 tests total** (1182 shared + 388 tray) — all passing ✅ +**2349 tests total** (1464 shared + 885 tray) — required suites passing ✅ | Metric | Value | |--------|-------| -| Total Tests | 1570 | -| Passing | 1570 (100%) | +| Total Tests | 2349 | +| Result | 2327 passed, 22 skipped | | Failing | 0 | -| Framework | xUnit 2.9.3 / .NET 10.0 | +| Framework | xUnit / .NET 10.0 | ## Test Projects -### OpenClaw.Shared.Tests — 1182 tests +### OpenClaw.Shared.Tests — 1464 tests #### ModelsTests - **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting @@ -71,7 +71,7 @@ --- -### OpenClaw.Tray.Tests — 388 tests +### OpenClaw.Tray.Tests — 885 tests #### Core Tray Tests @@ -82,7 +82,7 @@ #### Onboarding Tests -- **OnboardingStateTests** (19) — Page order, mode logic, route changes, wizard state persistence, completion, disposal +- **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 @@ -92,17 +92,25 @@ - **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 -```bash +```powershell # All tests dotnet test # Single project -dotnet test tests/OpenClaw.Shared.Tests -dotnet test tests/OpenClaw.Tray.Tests +dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore +dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore # Specific test class dotnet test --filter "FullyQualifiedName~MenuDisplayHelperTests" @@ -116,14 +124,12 @@ dotnet test --logger "console;verbosity=detailed" ## Not Covered (Requires Integration Tests) -- WebSocket connection/reconnection flow -- Real gateway message parsing -- Concurrent event handling -- File I/O and thread synchronization -- End-to-end onboarding wizard flow (WebView2 requires runtime) +- Windows shell tray hover/click behavior +- Full WinUI onboarding wizard flow, including WebView2 navigation +- Real gateway/node pairing against a live gateway --- -**Last Updated**: 2026-05-04 -**Framework**: xUnit 2.9.3 / .NET 10.0 -**Status**: ✅ 1570 tests passing +**Last Updated**: 2026-05-10 +**Framework**: xUnit / .NET 10.0 +**Status**: ✅ required suites passing (`.\build.ps1`, Shared tests, Tray tests) diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 2c20c2950..f81baac5a 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -87,9 +87,10 @@ When the node connects, it advertises these capabilities: ## Troubleshooting ### Node doesn't connect -- Check that gateway URL and token are correct in Settings +- Check the active gateway in Connection settings. Gateway records live in `%APPDATA%\OpenClawTray\gateways.json`; post-pairing device tokens live under `%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json`. - Check logs for connection errors - Verify gateway is running and accessible +- If only a bootstrap token exists, finish pairing or approve the device; paired device tokens take precedence on future connects. ### No "Node Mode Active" notification - Ensure Windows notifications are enabled for the app @@ -139,6 +140,9 @@ When the node connects, it advertises these capabilities: - `src/OpenClaw.Shared/WindowsNodeClient.cs` - Node protocol client - `src/OpenClaw.Shared/Capabilities/*.cs` - Capability handlers +- `src/OpenClaw.Tray.WinUI/Services/Connection/GatewayRegistry.cs` - persistent gateway records +- `src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs` - operator/node connection lifecycle +- `src/OpenClaw.Tray.WinUI/Services/Connection/CredentialResolver.cs` - device-token/shared/bootstrap credential precedence - `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` - Orchestrates capabilities - `src/OpenClaw.Tray.WinUI/Services/ScreenCaptureService.cs` - screen snapshots - `src/OpenClaw.Tray.WinUI/Services/ScreenRecordingService.cs` - screen recordings diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md index 17fe3a9f9..18b67b024 100644 --- a/docs/gateway-node-integration.md +++ b/docs/gateway-node-integration.md @@ -280,7 +280,7 @@ The QR/setup-code path is preferred for first-time node onboarding because it av The Windows Setup Wizard: 1. Accepts a QR image, clipboard QR image, pasteable setup code, or manual gateway URL/token. 2. For QR/setup-code input, decodes `{ url, bootstrapToken, expiresAtMs }`. -3. Stores `bootstrapToken` separately from the normal gateway `Token` setting. +3. Stores `bootstrapToken` in the active `GatewayRecord.BootstrapToken`; manual long-lived tokens are stored as `GatewayRecord.SharedGatewayToken`. 4. Sends it as `auth.bootstrapToken` in the node connect handshake. This lets the gateway correctly classify QR setup as a bootstrap-token handshake, which enables: @@ -296,7 +296,7 @@ After a successful bootstrap-token pairing: 3. Future connections use `auth.token = ` (device-token auth path) 4. The bootstrap token is revoked and no longer valid -Windows stores `hello-ok.auth.deviceToken` in its device identity file and prefers that saved device token on future node connections. The bootstrap token is only used when there is no saved device token yet. +Windows stores `hello-ok.auth.deviceToken` in the per-gateway device identity file and prefers that saved device token on future node connections. The bootstrap token is only used when there is no saved device token yet. ### 4.7 Bootstrap Flow diff --git a/docs/images/openclawwindows1.png b/docs/images/openclawwindows1.png new file mode 100644 index 000000000..153a8d7a5 Binary files /dev/null and b/docs/images/openclawwindows1.png differ diff --git a/docs/images/openclawwindows2.png b/docs/images/openclawwindows2.png new file mode 100644 index 000000000..2ceee99e5 Binary files /dev/null and b/docs/images/openclawwindows2.png differ diff --git a/docs/images/openclawwindows3.png b/docs/images/openclawwindows3.png new file mode 100644 index 000000000..d9c5b0ba1 Binary files /dev/null and b/docs/images/openclawwindows3.png differ diff --git a/docs/images/openclawwindows4.png b/docs/images/openclawwindows4.png new file mode 100644 index 000000000..5b13d5eb1 Binary files /dev/null and b/docs/images/openclawwindows4.png differ diff --git a/docs/molty1.png b/docs/molty1.png deleted file mode 100644 index 034b54433..000000000 Binary files a/docs/molty1.png and /dev/null differ diff --git a/docs/molty2.png b/docs/molty2.png deleted file mode 100644 index e4ea2e154..000000000 Binary files a/docs/molty2.png and /dev/null differ diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md new file mode 100644 index 000000000..5a50ce336 --- /dev/null +++ b/docs/uninstall-msix.md @@ -0,0 +1,75 @@ +# Uninstalling OpenClaw Tray — MSIX Package + +> **Date:** 2026-05-07 +> **Branch:** feat/wsl-gateway-uninstall + +--- + +## MSIX Cannot Auto-Clean WSL State + +**Feasibility verdict:** `runFullTrust` MSIX packages do **not** support a +`CustomInstall` / `CustomUninstall` extension that runs an arbitrary EXE +at uninstall time. The supported extension points +(`windows.startupTask`, `windows.appExecutionAlias`, `com.extension`, +`windows.protocol`, etc.) do not include an uninstall hook. + +Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray +→ Uninstall** will silently leave behind: + +- **WSL distro** — `OpenClawGateway` remains in `wsl --list`. +- **Roaming app data** under `%APPDATA%\OpenClawTray\` (device key, settings, + mcp-token). +- **Local app data** under `%LOCALAPPDATA%\OpenClawTray\` (setup state, logs, + VHD parent directory). + +> **Note:** If the tray was installed with MSIX and the data landed in the +> package-virtualized path (`%LOCALAPPDATA%\Packages\OpenClaw.Tray_\...`) +> instead of real `%APPDATA%`, those directories are removed automatically by +> MSIX on uninstall. Bostick's commit 7 validation test (Path A vs Path B) +> confirms which layout applies. + +--- + +## Recommended: Run "Remove Local Gateway" Before Uninstalling MSIX + +1. Open the tray icon. +2. Navigate to **Settings → Local Gateway**. +3. Click **"Remove Local Gateway"** (Mattingly's warning banner in commit 4 + surfaces this step for MSIX users). +4. Wait for the engine to complete — it stops keepalive processes, unregisters + the WSL distro, nulls the device token, removes autostart, and cleans up app + data. +5. Uninstall the MSIX package via **Settings → Apps**. + +--- + +## Manual Recovery (After MSIX Removed Without In-Tray Cleanup) + +If the MSIX was already removed and the WSL distro / app data remains: + +```powershell +# 1. Unregister the distro (removes .vhdx from wsl's internal store) +wsl --unregister OpenClawGateway + +# 2. Remove VHD parent directory (wsl --unregister may leave the folder) +Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" ` + -Recurse -Force -ErrorAction SilentlyContinue + +# 3. Remove autostart registry entry +Remove-ItemProperty ` + -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` + -Name "OpenClawTray" -ErrorAction SilentlyContinue + +# 4. Remove local app data (setup state, logs) +Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue + +# 5. Remove roaming app data (settings, device key — only if you want full purge) +# NOTE: mcp-token.txt is intentionally preserved here; delete manually if needed. +Remove-Item "$env:APPDATA\OpenClawTray\setup-state.json" -Force -ErrorAction SilentlyContinue +``` + +Or use the validation script if it is available separately: + +```powershell +.\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive +``` diff --git a/docs/uninstall-portable.md b/docs/uninstall-portable.md new file mode 100644 index 000000000..027b7e164 --- /dev/null +++ b/docs/uninstall-portable.md @@ -0,0 +1,71 @@ +# Uninstalling OpenClaw Tray — Portable ZIP + +> **Date:** 2026-05-07 +> **Branch:** feat/wsl-gateway-uninstall + +Portable (ZIP) installations have **no automatic uninstall hook**. +Simply deleting the folder leaves the WSL distro, app data, and autostart +entry behind. Follow one of the two paths below for a clean removal. + +--- + +## Recommended: In-Tray Removal (Requires the Tray Running) + +1. Open the tray icon. +2. Navigate to **Settings → Local Gateway**. +3. Click **"Remove Local Gateway"**. +4. The engine stops keepalive processes, unregisters the WSL distro, nulls + the device token, removes autostart, and cleans up app data. +5. After the operation completes, delete the portable folder. + +--- + +## CLI: Headless Removal (No Tray UI Required) + +Run from the portable folder: + +```powershell +# Destructive — removes the local WSL gateway cleanly, then print result to stdout +.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive + +# With JSON output for programmatic consumption (tokens redacted in output): +.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive --json-output .\uninstall-result.json + +# Dry-run — records what would happen without any destruction: +.\OpenClaw.Tray.WinUI.exe --uninstall --dry-run +``` + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | Success — all steps completed, postconditions satisfied | +| 1 | Partial failure — one or more steps failed (see JSON output or stderr) | +| 2 | Bad arguments — `--confirm-destructive` or `--dry-run` missing | + +After the CLI command exits 0, delete the portable folder. + +--- + +## WARNING: Deleting the Folder Without Running Uninstall + +Deleting the portable folder **without** running the uninstall first leaves: + +- **WSL distro orphaned** — `OpenClawGateway` remains in `wsl --list`. + Manual cleanup: `wsl --unregister OpenClawGateway` + +- **App data** remains under: + - `%APPDATA%\OpenClawTray\` — device key, settings, mcp-token + - `%LOCALAPPDATA%\OpenClawTray\` — setup state, logs, exec policy, VHD parent dir + +- **Autostart entry** may remain in + `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` + +Manual WSL + registry cleanup: + +```powershell +wsl --unregister OpenClawGateway +Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` + -Name "OpenClawTray" -ErrorAction SilentlyContinue +Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" -Recurse -Force -ErrorAction SilentlyContinue +``` diff --git a/gateway-lkg.json b/gateway-lkg.json new file mode 100644 index 000000000..199e67dae --- /dev/null +++ b/gateway-lkg.json @@ -0,0 +1,7 @@ +{ + "$schema": "./docs/schemas/gateway-lkg.schema.json", + "version": "2026.5.18", + "verifiedAt": "2026-05-20T04:50:00Z", + "verifiedTrayRef": "spike-run-26138294682", + "notes": "Last-Known-Good openclaw gateway npm package version that the tray ships with by default. Bumped by .github/workflows/gateway-lkg-bump.yml after the full gateway-compat suite passes against a newer published version. Runtime override: set OPENCLAW_GATEWAY_VERSION env var (e.g. 'latest' or '2026.5.18') before launching the tray; useful in CI and for hands-on validation." +} diff --git a/installer.iss b/installer.iss index 82d65c673..661cd691f 100644 --- a/installer.iss +++ b/installer.iss @@ -27,6 +27,11 @@ WizardStyle=modern PrivilegesRequired=lowest SetupIconFile=src\OpenClaw.Tray.WinUI\Assets\openclaw.ico UninstallDisplayIcon={app}\{#MyAppExeName} +; Round 2 (Scott #5): block install/uninstall while the tray is running. +; Mutex name matches App.xaml.cs (`new Mutex(true, "OpenClawTray", …)`). +; Tray and Inno run in the same user session, so the bare name resolves +; against Local\OpenClawTray — no Global\ prefix needed. +AppMutex=OpenClawTray #if MyAppArch == "arm64" ArchitecturesInstallIn64BitMode=arm64 ArchitecturesAllowed=arm64 @@ -51,8 +56,12 @@ Name: "cmdpalette"; Description: "Install PowerToys Command Palette extension"; [Files] ; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; Command Palette extension (all files from build output) -Source: "{#publish}\cmdpal\*"; DestDir: "{app}\CommandPalette"; Flags: ignoreversion recursesubdirs; Tasks: cmdpalette +; Command Palette extension (all files from build output). +; skipifsourcedoesntexist: prevents ISCC compile error when the cmdpal publish +; dir is absent (e.g. developer builds that skip the cmdpalette task). +Source: "{#publish}\cmdpal\*"; DestDir: "{app}\CommandPalette"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist; Tasks: cmdpalette +; WSL gateway uninstall helper — invoked by [UninstallRun] to drive clean removal +Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" @@ -66,5 +75,16 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Add-AppxPackage -Register '{app}\CommandPalette\AppxManifest.xml' -ForceApplicationShutdown"""; Flags: runhidden; Tasks: cmdpalette [UninstallRun] +; ORDERING NOTE: Inno Setup runs [UninstallRun] entries BEFORE deleting {app} +; directory contents. This guarantees OpenClawTray.exe is still present when +; the script executes. See Inno docs: "[UninstallRun] section". +; Fallback: if OpenClawTray.exe is missing for any reason, Uninstall-LocalGateway.ps1 +; logs the error to {app}\uninstall-gateway-error.log and exits 0 so Inno continues. +; *** DO NOT COMMENT OUT OR REMOVE THE Flags LINE BELOW *** +; waituntilterminated is non-negotiable: without it Inno races ahead and deletes +; {app} while the PowerShell hook (and the CLI engine it invokes) is still running, +; leaving 279+ application files behind after unins000.exe reports exit 0. +; runhidden suppresses the console window that would otherwise flash briefly. +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\Uninstall-LocalGateway.ps1"""; Flags: shellexec waituntilterminated runhidden; StatusMsg: "Removing local WSL gateway..." ; Unregister Command Palette extension on uninstall Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Get-AppxPackage -Name '*OpenClaw*' | Remove-AppxPackage"""; Flags: runhidden diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 0828de3eb..e249fbfdc 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -15,7 +15,20 @@ + + + + + + + + + + + + + @@ -25,9 +38,13 @@ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..b64336f55 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "openclaw-windows-node-mxc", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-windows-node-mxc", + "version": "0.0.0", + "dependencies": { + "@microsoft/mxc-sdk": "^0.1.8" + } + }, + "node_modules/@microsoft/mxc-sdk": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@microsoft/mxc-sdk/-/mxc-sdk-0.1.8.tgz", + "integrity": "sha512-sjywLhMc/eAnBxauw5Fj+7tXJtvoFKpUjD6++g44vPVauy4wJzWHlw8NmIwIuEWlkkyIXEijdTa+hCU+AqtkDQ==", + "license": "MIT", + "dependencies": { + "node-pty": "^1.2.0-beta.12", + "semver": "^7.7.4" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.2.0-beta.12", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.12.tgz", + "integrity": "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..82b632b94 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "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.", + "dependencies": { + "@microsoft/mxc-sdk": "^0.1.8" + } +} diff --git a/scripts/Test-Localization.ps1 b/scripts/Test-Localization.ps1 new file mode 100644 index 000000000..b59f0051d --- /dev/null +++ b/scripts/Test-Localization.ps1 @@ -0,0 +1,138 @@ +param( + [switch]$StrictHardcodedXaml, + [switch]$SkipDotNetTests +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$winUiRoot = Join-Path $repoRoot 'src\OpenClaw.Tray.WinUI' +$stringsDir = Join-Path $winUiRoot 'Strings' +$enUsResw = Join-Path $stringsDir 'en-us\Resources.resw' +$localizableAttributes = @('Content', 'Description', 'Header', 'Message', 'PlaceholderText', 'Text', 'Title') + +function Get-RelativePath { + param( + [string]$BasePath, + [string]$Path + ) + + $baseFullPath = [System.IO.Path]::GetFullPath($BasePath).TrimEnd('\') + '\' + $pathFullPath = [System.IO.Path]::GetFullPath($Path) + $baseUri = [System.Uri]::new($baseFullPath) + $pathUri = [System.Uri]::new($pathFullPath) + return [System.Uri]::UnescapeDataString($baseUri.MakeRelativeUri($pathUri).ToString()).Replace('/', '\') +} + +function Get-ResourceKeys { + [xml]$resources = Get-Content -LiteralPath $enUsResw -Raw -Encoding UTF8 + $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($data in $resources.root.data) { + [void]$keys.Add([string]$data.name) + } + return $keys +} + +function Test-IsLocalizableValue { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { return $false } + if ($Value -notmatch '\p{L}') { return $false } + + $nonLocalizablePrefixes = @( + '{Binding', + '{x:Bind', + '{StaticResource', + '{ThemeResource', + '{TemplateBinding', + 'ms-appx:///', + 'http://', + 'https://' + ) + + foreach ($prefix in $nonLocalizablePrefixes) { + if ($Value.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $false + } + } + + return $true +} + +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 { + $xamlPath = $_.FullName + $relativePath = Get-RelativePath $repoRoot $xamlPath + [xml]$xml = Get-Content -LiteralPath $xamlPath -Raw -Encoding UTF8 + + $navigator = $xml.CreateNavigator() + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($navigator.NameTable) + $namespaceManager.AddNamespace('x', 'http://schemas.microsoft.com/winfx/2006/xaml') + + foreach ($node in $xml.SelectNodes('//*[@x:Uid]', $namespaceManager)) { + $uid = $node.Attributes['Uid', 'http://schemas.microsoft.com/winfx/2006/xaml'].Value + foreach ($attributeName in $localizableAttributes) { + $attribute = $node.Attributes[$attributeName] + if ($null -eq $attribute -or -not (Test-IsLocalizableValue $attribute.Value)) { + continue + } + + $key = "$uid.$attributeName" + if (-not $resourceKeys.Contains($key)) { + $missingResources.Add("${relativePath}: missing $key") + } + } + } + + foreach ($node in $xml.SelectNodes('//*')) { + if ($null -ne $node.Attributes['Uid', 'http://schemas.microsoft.com/winfx/2006/xaml']) { + continue + } + + foreach ($attributeName in $localizableAttributes) { + $attribute = $node.Attributes[$attributeName] + if ($null -ne $attribute -and (Test-IsLocalizableValue $attribute.Value)) { + $hardcodedValues.Add("${relativePath}: <$($node.Name)> $attributeName=`"$($attribute.Value)`"") + } + } + } + } + + [pscustomobject]@{ + MissingResources = $missingResources + HardcodedValues = $hardcodedValues + } +} + +if (-not $SkipDotNetTests) { + $env:OPENCLAW_REPO_ROOT = $repoRoot + dotnet test (Join-Path $repoRoot 'tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj') --filter 'FullyQualifiedName~LocalizationValidationTests|FullyQualifiedName~CapabilitiesPageLocalizationCoverageTests' --no-restore + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + +$findings = Get-XamlLocalizationFindings + +if ($findings.MissingResources.Count -gt 0) { + Write-Error ("Missing Resources.resw entries for x:Uid controls:`n" + ($findings.MissingResources -join "`n")) + exit 1 +} + +if ($findings.HardcodedValues.Count -gt 0) { + $message = "Found $($findings.HardcodedValues.Count) candidate hard-coded XAML string(s)." + if ($StrictHardcodedXaml) { + Write-Error ($message + "`n" + ($findings.HardcodedValues | Select-Object -First 200 | Out-String)) + exit 1 + } + + Write-Warning $message + $findings.HardcodedValues | Select-Object -First 200 | ForEach-Object { Write-Warning $_ } + Write-Warning "Re-run with -StrictHardcodedXaml to fail on these candidates." +} + +Write-Host "Localization check completed." diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 new file mode 100644 index 000000000..cde043c95 --- /dev/null +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -0,0 +1,79 @@ +<# +.SYNOPSIS + Inno Setup [UninstallRun] helper — removes the local WSL gateway via the + OpenClaw tray CLI flag. + +.DESCRIPTION + INNO ORDERING CONTRACT + ---------------------- + Per Inno Setup documentation, [UninstallRun] entries execute BEFORE the + {app} directory is deleted. OpenClawTray.exe is therefore guaranteed to + be present when this script runs. + + WHAT THIS SCRIPT DOES + --------------------- + 1. Locates OpenClawTray.exe in the same directory as this script ({app}). + 2. Invokes: OpenClawTray.exe --uninstall --confirm-destructive --json-output + 3. Logs success or failure to {app}\uninstall-gateway-result.json. + 4. If the EXE is missing (e.g., partial install), logs the error and exits 0 + so the Inno uninstaller continues. The user may need to clean up manually + (see docs\uninstall-portable.md for manual steps). + + FALLBACK + -------- + Exit 0 in all error cases so Inno does not abort the uninstall if gateway + cleanup fails. The result JSON captures the failure for post-mortem. + +.NOTES + Date: 2026-05-07 + Author: Aaron (Backend / Infrastructure Engineer) + Branch: feat/wsl-gateway-uninstall + Commit: 5 of 7 + + Token / key material is NEVER written to the result log; the engine + and CLI layer both redact sensitive fields before serializing. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$scriptDir = $PSScriptRoot +$exePath = Join-Path $scriptDir 'OpenClaw.Tray.WinUI.exe' +$resultPath = Join-Path $scriptDir 'uninstall-gateway-result.json' +$errorPath = Join-Path $scriptDir 'uninstall-gateway-error.log' + +# --------------------------------------------------------------------------- +# EXE presence check — fallback if somehow missing +# --------------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $exePath)) { + $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1: " + + "OpenClawTray.exe not found at '$exePath'. " + + "WSL gateway cleanup skipped. Manual cleanup may be required." + try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + Write-Warning $msg + exit 0 +} + +# --------------------------------------------------------------------------- +# Invoke CLI uninstall +# --------------------------------------------------------------------------- +$exitCode = 0 +try { + & $exePath --uninstall --confirm-destructive --json-output $resultPath + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + + if ($exitCode -eq 0) { + Write-Host "OpenClaw local WSL gateway removed successfully." -ForegroundColor Green + } else { + Write-Warning "OpenClaw gateway uninstall exited $exitCode; see '$resultPath' for details." + } +} catch { + $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1 error: $($_.Exception.Message)" + try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + Write-Warning $msg +} + +# Always exit 0 so Inno does not abort the broader uninstall. +exit 0 diff --git a/scripts/_uninstall-helpers.ps1 b/scripts/_uninstall-helpers.ps1 new file mode 100644 index 000000000..64c95963c --- /dev/null +++ b/scripts/_uninstall-helpers.ps1 @@ -0,0 +1,151 @@ +# _uninstall-helpers.ps1 +# +# Shared helper functions for OpenClaw uninstall and cleanup scripts. +# Dot-source this file at the top of any script that needs these utilities: +# +# . "$PSScriptRoot\_uninstall-helpers.ps1" +# +# Note: Add-Step requires a $script:steps array to be initialised in the +# calling script before use (e.g. $script:steps = @()). + +# --------------------------------------------------------------------------- +# Distro-name guard +# Exact-match guard. Mirrors C# LocalGatewayUninstall.AllowedDistroName. +# Round 2 (Scott #4): prefix matching was dead allowance that let test +# distros like "OpenClawGateway-test" pass param validation and then strand +# at the final unregister guard (which is exact-match). Exact-everywhere. +# --------------------------------------------------------------------------- + +function Test-IsOpenClawOwnedDistroName { + param([string]$Name) + + return $Name -eq "OpenClawGateway" +} + +# --------------------------------------------------------------------------- +# WSL command runner +# --------------------------------------------------------------------------- + +function Invoke-WslCommand { + <# + .SYNOPSIS + Runs a bash command inside WSL and returns stdout, stderr, and exit code. + .PARAMETER Command + The bash command string to execute via `wsl bash -c`. + .PARAMETER DistroName + Optional WSL distribution name. Omit to use the default distribution. + .OUTPUTS + A hashtable with keys: Stdout, Stderr, ExitCode. + #> + param( + [Parameter(Mandatory)] + [string]$Command, + [string]$DistroName + ) + + $wslArgs = if ($DistroName) { + @("-d", $DistroName, "bash", "-c", $Command) + } else { + @("bash", "-c", $Command) + } + + $stdoutLines = [System.Collections.Generic.List[string]]::new() + $stderrLines = [System.Collections.Generic.List[string]]::new() + + & wsl @wslArgs 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $stderrLines.Add($_.ToString()) + } else { + $stdoutLines.Add($_) + } + } + + return @{ + Stdout = $stdoutLines -join "`n" + Stderr = $stderrLines -join "`n" + ExitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } +} + +# --------------------------------------------------------------------------- +# Process termination +# --------------------------------------------------------------------------- + +function Stop-OpenClawProcessByPid { + <# + .SYNOPSIS + Terminates a process by PID, suppressing "not found" errors. + .PARAMETER ProcessId + PID of the process to terminate. + .PARAMETER Force + If specified, uses -Force on Stop-Process. + #> + param( + [Parameter(Mandatory)] + [int]$ProcessId, + [switch]$Force + ) + + try { + if ($Force) { + Stop-Process -Id $ProcessId -Force -ErrorAction Stop + } else { + Stop-Process -Id $ProcessId -ErrorAction Stop + } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + # Process already exited — not an error. + } +} + +# --------------------------------------------------------------------------- +# Dry-run gate +# --------------------------------------------------------------------------- + +function Assert-DryRunGate { + <# + .SYNOPSIS + Throws if the caller is in dry-run mode. Intended to guard any + statement that mutates persistent state (filesystem, processes, WSL). + .PARAMETER DryRun + Boolean dry-run flag from the calling script. + .PARAMETER OperationDescription + Human-readable description of the blocked operation (used in error message). + #> + param( + [Parameter(Mandatory)] + [bool]$DryRun, + [string]$OperationDescription = "destructive operation" + ) + + if ($DryRun) { + throw "Dry-run mode is active; $OperationDescription was not executed." + } +} + +# --------------------------------------------------------------------------- +# Step logging +# --------------------------------------------------------------------------- + +function Add-Step { + <# + .SYNOPSIS + Appends a structured step entry to `$script:steps` in the calling script. + .NOTES + The calling script must declare `$script:steps = @()` before dot-sourcing + this file or before first calling Add-Step. + #> + param( + [string]$Name, + [string]$Status, + [string]$Message, + [hashtable]$Data = @{} + ) + + $script:steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} diff --git a/scripts/dev-reset-rebuild-launch.ps1 b/scripts/dev-reset-rebuild-launch.ps1 new file mode 100644 index 000000000..7e3ae858d --- /dev/null +++ b/scripts/dev-reset-rebuild-launch.ps1 @@ -0,0 +1,326 @@ +<# +.SYNOPSIS + Dev-loop helper: kill → backup/wipe state → optionally wipe WSL distro → build x64 → (optionally) launch tray. + +.DESCRIPTION + Consolidates the full dev-reset cycle used during OpenClaw tray development. + Idempotent: no error if nothing is running, state dirs are absent, or the WSL + distro is not registered. + + Process kills are always by PID (Stop-Process -Id). Name-based kills are + forbidden in this repo. + + WSL file operations use 'wsl bash -c' — never \\wsl$\ paths (which trigger + Windows permission prompts via the 9P protocol). + +.PARAMETER WipeWslDistro + Also unregister the OpenClawGateway WSL distro (wsl --unregister). + Default: off (preserve the distro). + +.PARAMETER CaptureDir + If set, exports OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR= + before launching the tray so the app auto-captures screenshots. + +.PARAMETER SkipBuild + Skip the 'dotnet build' step. Useful when you have just built. + +.PARAMETER DontLaunch + Reset and (optionally) build, but do not launch the tray. + +.PARAMETER WorktreePath + Root of the git worktree to operate in. + Default: result of 'git rev-parse --show-toplevel' in the current directory. + +.PARAMETER NoBackup + Instead of backing up state dirs to TEMP, delete them directly. + Faster, but no rollback. + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 + Standard reset + rebuild + launch (no WSL wipe, no capture). + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -WipeWslDistro + Full clean slate: also unregister the OpenClawGateway WSL distro. + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -DontLaunch + Reset + build only (useful before testing manually). + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -CaptureDir .\visual-test-output\my-test + Reset + build + launch with OPENCLAW_VISUAL_TEST capture enabled. +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$WipeWslDistro, + [string]$CaptureDir = "", + [switch]$SkipBuild, + [switch]$DontLaunch, + [string]$WorktreePath = "", + [switch]$NoBackup +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ─── Resolve worktree path ──────────────────────────────────────────────────── + +if ([string]::IsNullOrWhiteSpace($WorktreePath)) { + $gitTop = & git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($gitTop)) { + Write-Error "Cannot resolve worktree path: not inside a git repository and -WorktreePath was not supplied." + exit 1 + } + $WorktreePath = $gitTop.Trim() +} +$WorktreePath = (Resolve-Path -LiteralPath $WorktreePath).Path + +# ─── Constants ──────────────────────────────────────────────────────────────── + +$DistroName = "OpenClawGateway" +$TrayProject = Join-Path $WorktreePath "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$AppDataDir = Join-Path $env:APPDATA "OpenClawTray" +$LocalAppDataDir = Join-Path $env:LOCALAPPDATA "OpenClawTray" +$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH-mm-ss") +$BackupRoot = Join-Path $env:TEMP "openclaw-test-backup-$timestamp" + +# ─── Summary state ──────────────────────────────────────────────────────────── + +$summary = [ordered]@{ + backupPath = $null + distroState = "not-checked" + buildResult = "skipped" + launchPid = $null +} + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +function Write-Step { + param([string]$Icon, [string]$Message) + Write-Host " $Icon $Message" +} +function Write-OK { param([string]$m) Write-Step "✓" $m } +function Write-Skip { param([string]$m) Write-Step "-" $m } +function Write-Fail { param([string]$m) Write-Step "x" $m } + +function Get-OpenClawProcesses { + @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like "OpenClaw*" }) +} + +function Get-WslDistros { + $out = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $out) { return @() } + @($out | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ }) +} + +# ─── Banner ─────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "============================================================" +Write-Host " OpenClaw Dev Loop -- Reset / Rebuild / Launch" +Write-Host "============================================================" +Write-Host " Timestamp : $timestamp" +Write-Host " WorktreePath : $WorktreePath" +Write-Host " WipeWslDistro: $WipeWslDistro SkipBuild: $SkipBuild DontLaunch: $DontLaunch" +Write-Host " NoBackup : $NoBackup CaptureDir: $(if ($CaptureDir) { $CaptureDir } else { '(none)' })" +if ($WhatIfPreference) { + Write-Host " *** WHATIF MODE -- no state will be changed ***" +} +Write-Host "" + +# ============================================================================= +# STEP 1 -- Kill OpenClaw* processes (by PID; name-based kills are forbidden) +# ============================================================================= + +Write-Host "STEP 1: Kill OpenClaw* processes" +$procs = @(Get-OpenClawProcesses) + +if ($procs.Count -eq 0) { + Write-Skip "No OpenClaw* processes running" +} +else { + foreach ($p in $procs) { + if ($PSCmdlet.ShouldProcess("PID $($p.Id) ($($p.ProcessName))", "Stop-Process -Id")) { + try { + Stop-Process -Id $p.Id -Force + Write-OK "Stopped PID $($p.Id) ($($p.ProcessName))" + } + catch { + Write-Fail "Failed to stop PID $($p.Id): $_" + exit 1 + } + } + else { + Write-Skip "WhatIf: would stop PID $($p.Id) ($($p.ProcessName))" + } + } + if (-not $WhatIfPreference) { + Start-Sleep -Milliseconds 500 # brief pause for file-lock release + } +} + +# ============================================================================= +# STEP 2 -- Backup or wipe tray state dirs +# ============================================================================= + +Write-Host "" +Write-Host "STEP 2: $(if ($NoBackup) { 'Wipe' } else { 'Backup' }) tray state dirs" + +function Invoke-StateDirReset { + param([string]$Path, [string]$Label) + + if (-not (Test-Path -LiteralPath $Path)) { + Write-Skip "$Label not present -- nothing to do" + return + } + + if ($NoBackup) { + if ($PSCmdlet.ShouldProcess($Path, "Remove-Item -Recurse -Force")) { + Remove-Item -LiteralPath $Path -Recurse -Force + Write-OK "Deleted $Label ($Path)" + } + else { + Write-Skip "WhatIf: would delete $Label ($Path)" + } + } + else { + $dest = Join-Path $BackupRoot $Label + if ($PSCmdlet.ShouldProcess($Path, "Copy-Item to backup then Remove-Item")) { + New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null + Copy-Item -LiteralPath $Path -Destination $dest -Recurse -Force + Remove-Item -LiteralPath $Path -Recurse -Force + Write-OK "Backed up $Label --> $dest" + $script:summary.backupPath = $BackupRoot + } + else { + Write-Skip "WhatIf: would backup $Label --> $dest, then remove source" + $script:summary.backupPath = "(whatif) $BackupRoot" + } + } +} + +Invoke-StateDirReset -Path $AppDataDir -Label "AppData_OpenClawTray" +Invoke-StateDirReset -Path $LocalAppDataDir -Label "LocalAppData_OpenClawTray" + +# ============================================================================= +# STEP 3 -- Optionally wipe the WSL distro +# ============================================================================= + +Write-Host "" +Write-Host "STEP 3: WSL distro ($DistroName)" + +$distros = @(Get-WslDistros) +$distroExists = $distros -contains $DistroName + +if (-not $WipeWslDistro) { + Write-Skip "-WipeWslDistro not set -- preserving $DistroName" + $summary.distroState = if ($distroExists) { "preserved" } else { "absent" } +} +elseif (-not $distroExists) { + Write-Skip "$DistroName is not registered -- nothing to unregister" + $summary.distroState = "absent" +} +else { + if ($PSCmdlet.ShouldProcess($DistroName, "wsl --terminate then wsl --unregister")) { + & wsl.exe --terminate $DistroName 2>$null # ignore exit code -- distro may already be stopped + & wsl.exe --unregister $DistroName + if ($LASTEXITCODE -ne 0) { + Write-Fail "wsl --unregister $DistroName failed (exit $LASTEXITCODE)" + exit 1 + } + Write-OK "Unregistered WSL distro $DistroName" + $summary.distroState = "unregistered" + } + else { + Write-Skip "WhatIf: would terminate + unregister WSL distro $DistroName" + $summary.distroState = "(whatif) would-unregister" + } +} + +# ============================================================================= +# STEP 4 -- Build x64 tray +# ============================================================================= + +Write-Host "" +Write-Host "STEP 4: Build x64 tray" + +if ($SkipBuild) { + Write-Skip "-SkipBuild set -- skipping dotnet build" + $summary.buildResult = "skipped" +} +else { + if (-not (Test-Path -LiteralPath $TrayProject)) { + Write-Fail "Tray project not found: $TrayProject" + exit 1 + } + + if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet build -p:Platform=x64 --no-restore -v q")) { + Write-Verbose "Running: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q" + & dotnet build $TrayProject -p:Platform=x64 --no-restore -v q + if ($LASTEXITCODE -ne 0) { + Write-Fail "dotnet build failed (exit $LASTEXITCODE)" + $summary.buildResult = "failed" + exit 1 + } + Write-OK "Build succeeded" + $summary.buildResult = "succeeded" + } + else { + Write-Skip "WhatIf: would run: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q" + $summary.buildResult = "(whatif) would-build" + } +} + +# ============================================================================= +# STEP 5 -- Launch tray +# ============================================================================= + +Write-Host "" +Write-Host "STEP 5: Launch tray" + +if ($DontLaunch) { + Write-Skip "-DontLaunch set -- not launching" +} +else { + if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet run -p:Platform=x64")) { + if ($CaptureDir) { + $captureAbs = if ([System.IO.Path]::IsPathRooted($CaptureDir)) { + $CaptureDir + } + else { + Join-Path $WorktreePath $CaptureDir + } + $env:OPENCLAW_VISUAL_TEST = "1" + $env:OPENCLAW_VISUAL_TEST_DIR = $captureAbs + Write-Verbose "Set OPENCLAW_VISUAL_TEST=1 OPENCLAW_VISUAL_TEST_DIR=$captureAbs" + } + + Write-Verbose "Launching: dotnet run --project `"$TrayProject`" -p:Platform=x64" + $launchProc = Start-Process -FilePath "dotnet" ` + -ArgumentList "run", "--project", $TrayProject, "-p:Platform=x64" ` + -PassThru -WorkingDirectory $WorktreePath + $summary.launchPid = $launchProc.Id + Write-OK "Tray launched (PID $($launchProc.Id))" + } + else { + Write-Skip "WhatIf: would launch: dotnet run --project `"$TrayProject`" -p:Platform=x64" + if ($CaptureDir) { + Write-Skip "WhatIf: would also set OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR=$CaptureDir" + } + } +} + +# ============================================================================= +# Summary +# ============================================================================= + +Write-Host "" +Write-Host "---------------------------- Summary ----------------------------" +Write-Host " Backup path : $(if ($summary.backupPath) { $summary.backupPath } elseif ($NoBackup) { '(deleted directly)' } else { '(nothing backed up)' })" +Write-Host " Distro state : $($summary.distroState)" +Write-Host " Build result : $($summary.buildResult)" +Write-Host " Launch PID : $(if ($summary.launchPid) { $summary.launchPid } else { '(not launched)' })" +Write-Host "-----------------------------------------------------------------" +Write-Host "" diff --git a/scripts/reset-openclaw-wsl-validation-state.ps1 b/scripts/reset-openclaw-wsl-validation-state.ps1 new file mode 100644 index 000000000..9543c314f --- /dev/null +++ b/scripts/reset-openclaw-wsl-validation-state.ps1 @@ -0,0 +1,390 @@ +# reset-openclaw-wsl-validation-state.ps1 +# +# Exact-target destructive cleanup for OpenClaw-owned WSL validation state. +# +# Safety guarantees enforced by this script: +# 1. Without -ConfirmDestructiveClean, the script runs in DRY-RUN mode and +# reports what it WOULD do; it never mutates state. +# 2. The only WSL distro this script will ever touch is the production +# constant "OpenClawGateway". Any other distro name is rejected. +# 3. Destructive operations are preceded by a copy of the user's +# %APPDATA%\OpenClawTray and %LOCALAPPDATA%\OpenClawTray identity +# directories to a timestamped backup location (printed to console). +# 4. The script never calls `wsl --shutdown`. It uses +# `wsl --terminate OpenClawGateway` only. +# 5. The script never reads or writes \\wsl$ / \\wsl.localhost paths. + +[CmdletBinding()] +param( + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation\reset"), + [string]$BackupRoot, + [string]$AppDataRoot, + [string]$LocalAppDataRoot, + [string]$InstallLocation, + [switch]$CleanInstallLocation, + [switch]$ConfirmDestructiveClean, + [switch]$KeepRunningProcesses, + [switch]$PassThruJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +. "$PSScriptRoot\_uninstall-helpers.ps1" + +# Production-locked WSL distro name (Phase 3 constant). This script will +# refuse to act on any other distro, even via -DistroName overrides +# (which are intentionally absent). +$script:OpenClawDistroName = "OpenClawGateway" + +$startedAt = Get-Date +$timestamp = $startedAt.ToString("yyyyMMddHHmmss") + +if ([string]::IsNullOrWhiteSpace($BackupRoot)) { + $BackupRoot = Join-Path (Get-Location) "artifacts\reset-backups\$timestamp" +} + +$result = [ordered]@{ + script = "reset-openclaw-wsl-validation-state" + startedAt = $startedAt.ToString("o") + finishedAt = $null + outputDir = $OutputDir + backupRoot = $BackupRoot + distroName = $script:OpenClawDistroName + installLocation = $InstallLocation + appDataRoot = $AppDataRoot + localAppDataRoot = $LocalAppDataRoot + destructiveConfirmed = [bool]$ConfirmDestructiveClean + dryRun = -not $ConfirmDestructiveClean + targets = [ordered]@{} + steps = @() +} + +function Add-ResetStep { + param( + [string]$Name, + [string]$Status, + [string]$Message, + [hashtable]$Data = @{} + ) + + $script:result.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Invoke-CapturedCommand { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = (Get-Location).Path, + [switch]$IgnoreExitCode + ) + + $stepDir = Join-Path $OutputDir "commands" + New-Item -ItemType Directory -Force -Path $stepDir | Out-Null + $safeName = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $stepDir "$safeName.stdout.txt" + $stderr = Join-Path $stepDir "$safeName.stderr.txt" + + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } + finally { + Pop-Location + } + + Add-ResetStep $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath + arguments = ($ArgumentList -join " ") + exitCode = $exitCode + stdout = $stdout + stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Backup-Directory { + param( + [string]$Path, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + Add-ResetStep "backup-$Label" "Skipped" "$Path does not exist." + return + } + + New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null + $leaf = Split-Path -Leaf $Path + $destination = Join-Path $BackupRoot "$Label-$leaf" + + if ($result.dryRun) { + Add-ResetStep "backup-$Label" "DryRun" "Would copy $Path to $destination, then remove the original." @{ + source = $Path + destination = $destination + } + return + } + + if (Test-Path -LiteralPath $destination) { + $destination = Join-Path $BackupRoot ("{0}-{1:yyyyMMddHHmmss}" -f "$Label-$leaf", (Get-Date)) + } + + # Copy first so the user can recover even if removal fails partway. + Copy-Item -LiteralPath $Path -Destination $destination -Recurse -Force + Remove-Item -LiteralPath $Path -Recurse -Force + Add-ResetStep "backup-$Label" "Completed" "Backed up $Path to $destination, then removed the original." @{ + source = $Path + destination = $destination + } +} + +function Assert-DestructiveTargetIsAllowed { + # Hard-lock: this script will only ever touch the production OpenClawGateway distro. + # No override flag exists. If $script:OpenClawDistroName is ever something else, + # the script must refuse to run regardless of dry-run mode. + if ($script:OpenClawDistroName -ne "OpenClawGateway") { + throw "Refusing to run: distro name is locked to 'OpenClawGateway' but resolved to '$($script:OpenClawDistroName)'." + } +} + +function Get-PortOwnerSnapshot { + param([string]$Label) + + $port = 18789 + try { + $connections = @(Get-NetTCPConnection -LocalPort $port -ErrorAction Stop) + $snapshot = @($connections | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress + localPort = $_.LocalPort + state = $_.State.ToString() + owningProcess = $_.OwningProcess + } + }) + } + catch { + $snapshot = @() + } + + $snapshotPath = Join-Path $OutputDir "port-18789-$Label.json" + $snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotPath -Encoding UTF8 + Add-ResetStep "port-snapshot-$Label" "Completed" "Captured TCP listener snapshot for port 18789." @{ + path = $snapshotPath + ownerCount = @($snapshot).Count + } + return $snapshot +} + +function Get-WslDistros { + $output = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $output) { + return @() + } + + return @($output | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ }) +} + +function Get-OpenClawProcesses { + return @(Get-Process | Where-Object { $_.ProcessName -like "OpenClaw*" }) +} + +function Add-TargetSummary { + param( + [object[]]$Processes, + [string[]]$Distros, + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath, + [object[]]$PortOwners + ) + + $script:result.targets = [ordered]@{ + processes = @($Processes | ForEach-Object { + [ordered]@{ + pid = $_.Id + name = $_.ProcessName + path = $_.Path + } + }) + distroExists = ($Distros -contains $script:OpenClawDistroName) + distroName = $script:OpenClawDistroName + appDataPath = $AppDataPath + appDataExists = Test-Path -LiteralPath $AppDataPath + localAppDataPath = $LocalAppDataPath + localAppDataExists = Test-Path -LiteralPath $LocalAppDataPath + installLocationPath = $InstallLocationPath + installLocationExists = (-not [string]::IsNullOrWhiteSpace($InstallLocationPath)) -and (Test-Path -LiteralPath $InstallLocationPath) + installLocationCleanupRequested = [bool]$CleanInstallLocation + port18789OwnersBefore = @($PortOwners) + outputDir = $OutputDir + backupRoot = $BackupRoot + } + + Add-ResetStep "target-summary" "Completed" "Captured OpenClaw-owned reset targets." @{ + processCount = @($Processes).Count + distroExists = [bool]$script:result.targets.distroExists + appDataExists = [bool]$script:result.targets.appDataExists + localAppDataExists = [bool]$script:result.targets.localAppDataExists + installLocationExists = [bool]$script:result.targets.installLocationExists + } +} + +function Assert-CleanPostCondition { + param( + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath + ) + + if ($result.dryRun) { + Add-ResetStep "postconditions" "Skipped" "Postconditions are skipped during dry-run." + return + } + + $remainingProcesses = @(Get-OpenClawProcesses) + if (-not $KeepRunningProcesses -and $remainingProcesses.Count -gt 0) { + throw "OpenClaw processes are still running after reset: $(@($remainingProcesses | ForEach-Object { $_.Id }) -join ', ')" + } + + $remainingDistros = @(Get-WslDistros) + if ($remainingDistros -contains $script:OpenClawDistroName) { + throw "WSL distro '$($script:OpenClawDistroName)' is still registered after reset." + } + + if (Test-Path -LiteralPath $AppDataPath) { + throw "AppData path still exists after reset: $AppDataPath" + } + + if (Test-Path -LiteralPath $LocalAppDataPath) { + throw "LocalAppData path still exists after reset: $LocalAppDataPath" + } + + if ($CleanInstallLocation -and -not [string]::IsNullOrWhiteSpace($InstallLocationPath) -and (Test-Path -LiteralPath $InstallLocationPath)) { + throw "Install location still exists after reset: $InstallLocationPath" + } + + $wslListAfterPath = Join-Path $OutputDir "wsl-list-after.txt" + & wsl.exe --list --verbose > $wslListAfterPath 2>&1 + $script:result.targets.port18789OwnersAfter = @(Get-PortOwnerSnapshot -Label "after") + Add-ResetStep "postconditions" "Passed" "OpenClaw-owned state reset postconditions passed." @{ + wslListAfter = $wslListAfterPath + } +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +try { + Assert-DestructiveTargetIsAllowed + + if ([string]::IsNullOrWhiteSpace($AppDataRoot)) { + $AppDataRoot = $env:APPDATA + $result.appDataRoot = $AppDataRoot + } + if ([string]::IsNullOrWhiteSpace($LocalAppDataRoot)) { + $LocalAppDataRoot = $env:LOCALAPPDATA + $result.localAppDataRoot = $LocalAppDataRoot + } + + $appData = Join-Path $AppDataRoot "OpenClawTray" + $localAppData = Join-Path $LocalAppDataRoot "OpenClawTray" + $processes = @(Get-OpenClawProcesses) + $distros = @(Get-WslDistros) + $portOwnersBefore = @(Get-PortOwnerSnapshot -Label "before") + Add-TargetSummary -Processes $processes -Distros $distros -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation -PortOwners $portOwnersBefore + + if ($result.dryRun) { + Add-ResetStep "mode" "DryRun" "No state will be changed. Pass -ConfirmDestructiveClean to reset OpenClaw-owned state." + Write-Host "DRY-RUN: pass -ConfirmDestructiveClean to actually reset OpenClaw-owned state." + } + else { + Add-ResetStep "mode" "Confirmed" "OpenClaw-owned state reset is enabled for this run." + Write-Host "Backups will be written under: $BackupRoot" + } + + if ($processes.Count -eq 0) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "No OpenClaw processes are running." + } + elseif ($KeepRunningProcesses) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "Keeping running OpenClaw processes because -KeepRunningProcesses was set." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + elseif ($result.dryRun) { + Add-ResetStep "stop-openclaw-processes" "DryRun" "Would stop running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + else { + foreach ($process in $processes) { + Stop-Process -Id $process.Id -Force + } + Add-ResetStep "stop-openclaw-processes" "Completed" "Stopped running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + + $hasGatewayDistro = $distros -contains $script:OpenClawDistroName + $wslListPath = Join-Path $OutputDir "wsl-list-before.txt" + & wsl.exe --list --verbose > $wslListPath 2>&1 + Add-ResetStep "capture-wsl-list" "Completed" "Captured WSL distro list." @{ path = $wslListPath } + + if (-not $hasGatewayDistro) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "Skipped" "WSL distro '$($script:OpenClawDistroName)' is not registered." + } + elseif ($result.dryRun) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "DryRun" "Would terminate and unregister only the '$($script:OpenClawDistroName)' WSL distro." @{ distroName = $script:OpenClawDistroName } + } + else { + # Exact-target only: --terminate , never --shutdown. + Invoke-CapturedCommand "wsl-terminate-$($script:OpenClawDistroName)" "wsl.exe" @("--terminate", $script:OpenClawDistroName) -IgnoreExitCode + Invoke-CapturedCommand "wsl-unregister-$($script:OpenClawDistroName)" "wsl.exe" @("--unregister", $script:OpenClawDistroName) + } + + Backup-Directory -Path $appData -Label "appdata" + Backup-Directory -Path $localAppData -Label "localappdata" + if ($CleanInstallLocation) { + if ([string]::IsNullOrWhiteSpace($InstallLocation)) { + Add-ResetStep "backup-install-location" "Skipped" "No install location was supplied." + } + else { + Backup-Directory -Path $InstallLocation -Label "install-location" + } + } + else { + Add-ResetStep "backup-install-location" "Skipped" "Install location cleanup was not requested." + } + Assert-CleanPostCondition -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation + + $result.finishedAt = (Get-Date).ToString("o") + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + if ($PassThruJson) { + $result | ConvertTo-Json -Depth 10 + } + else { + Write-Host "Reset summary: $summaryPath" + if (-not $result.dryRun) { + Write-Host "Backup root: $BackupRoot" + } + } +} +catch { + $result.finishedAt = (Get-Date).ToString("o") + Add-ResetStep "reset" "Failed" $_.Exception.Message + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + Write-Error $_.Exception.Message + exit 1 +} diff --git a/scripts/validate-msix-storage-paths.ps1 b/scripts/validate-msix-storage-paths.ps1 new file mode 100644 index 000000000..4a2d62924 --- /dev/null +++ b/scripts/validate-msix-storage-paths.ps1 @@ -0,0 +1,1272 @@ +<# +.SYNOPSIS + Empirically determines whether OpenClawTray MSIX writes user data to real + %APPDATA%/%LOCALAPPDATA% paths (Path A) or MSIX-virtualized package storage + (Path B). The verdict drives the MSIX uninstall strategy. + +.DESCRIPTION + OpenClawTray declares the runFullTrust restricted capability, which typically + bypasses MSIX filesystem virtualization and writes to the real roaming/local + app-data folders. This cannot be assumed — it must be verified empirically + before the MSIX uninstall surface is considered complete. + + VERDICT SEMANTICS + ----------------- + PathA-OrphanRisk + Files land in real %APPDATA%\OpenClawTray\ and/or %LOCALAPPDATA%\OpenClawTray\. + Remove-AppxPackage does NOT clean these up. The "Remove Local Gateway" in-tray + button is the canonical pre-uninstall cleanup path. A pre-removal warning banner + (PackageHelper.IsPackaged() && setup-state.json exists) MUST ship in commit 5. + + PathB-CleanRemove + Files land only inside %LOCALAPPDATA%\Packages\\. + Remove-AppxPackage deletes them automatically. WSL distro registration still + requires explicit cleanup (not handled by MSIX removal). In-tray warning banner + is optional / informational only. + + Inconclusive + The probe could not produce a definitive answer (no probe files found in either + location, app launch failure, timeout, etc.). Investigation is required before + MSIX uninstall claims can be made. + + ## Notes for Aaron + ================== + Read verdict.json → field "verdict" to branch your commit-5 decisions: + + If "PathA-OrphanRisk": + - Keep the "Remove Local Gateway" in-tray button as the canonical cleanup path. + - MUST add in-app pre-uninstall warning banner gated on: + PackageHelper.IsPackaged() && File.Exists(setupStatePath) + so users are warned before removing the MSIX package. + - The Inno uninstaller script (Uninstall-LocalGateway.ps1) targets real paths + unconditionally — no change needed there. + - Recovery: scripts/validate-wsl-gateway-uninstall.ps1 -Scenario Full + -ConfirmDestructiveClean is still relevant for orphaned state. + + If "PathB-CleanRemove": + - Remove-AppxPackage handles file-based artifact cleanup automatically. + - MSIX section of the plan is limited to WSL distro cleanup only (steps 2-5 of + canonical sequence: stop service, terminate, unregister distro, remove VHD dir). + - In-app warning banner is optional. You may still want it for WSL distro orphan + risk (distro registration is NOT cleaned by Remove-AppxPackage in either path). + - document in Artifact Catalog that MSIX path is path B. + + If "Inconclusive": + - Block commit 5 MSIX claims. Either re-run on a clean VM or defer MSIX + validation to a tracked TODO. Do NOT ship "MSIX removal is sufficient" + language without a pass verdict. + + OPEN QUESTIONS FOR AARON (pre-commit-5) + ======================================== + Q1: If -AutoSetup detection is infeasible on a given test machine (no interactive + session, sandboxed runner), do you want to defer MSIX validation to a manual + TODO tracked in the PR, or require a VM pre-condition before commit 5 merges? + Default assumption: commit 5 is gated on a non-Inconclusive verdict. + + Q2: Should the in-app warning banner check PackageHelper.IsPackaged() at runtime, + or check for APPX identity via Environment.GetEnvironmentVariable("LOCALAPPDATA")? + The former is more robust. Confirm the PackageHelper API is available in the + Settings page code-behind at the time commit 5 lands. + + ⚠️ SECURITY NOTE: Do NOT run this script against a live, user-paired tray instance. + Filesystem snapshots captured in -EvidenceDir would include settings.json + which may contain gateway token fields. Run only on a clean test machine or + dedicated validation VM. + + CI ARTIFACT + ----------- + The MSIX is produced by the build-msix CI job. Download artifact: + gh run download --name openclaw-msix-win-x64 --dir ./msix-drop/ + Then pass the .msix file path to -MsixPath. + +.PARAMETER MsixPath + Absolute path to the OpenClawTray MSIX file (e.g. OpenClawTray_1.2.3.0_x64.msix) + as produced by the build-msix CI job. Required unless -SkipInstall is set. + +.PARAMETER CertPath + Optional path to a .cer or .pfx sideload certificate. If provided, the cert is + imported to Cert:\LocalMachine\TrustedPeople before Add-AppxPackage is called. + +.PARAMETER EvidenceDir + Directory where ALL captured artifacts land. Defaults to + .\msix-validation-evidence\\ + +.PARAMETER SkipInstall + Assume the MSIX is already installed on this machine. Skip Add-AppxPackage and + jump directly to the probe + uninstall phases. + +.PARAMETER AutoSetup + Write a small probe settings.json and run.marker-bait directly to the expected real + app-data paths, then launch the installed tray briefly (30 s), and infer storage + routing from which files change / appear. This avoids the manual UI walk-through. + DEFAULT: enabled (the script defaults to -AutoSetup). + +.PARAMETER SkipAutoSetup + Disable the -AutoSetup logic. The script emits a MANUAL-STEP-REQUIRED.txt and + exits with code 3, asking the operator to walk through Setup-Locally in the tray + UI, then re-run with -SkipInstall to continue from phase 5. + +.PARAMETER WhatIf + Print all planned actions without executing any destructive operations. + +.PARAMETER Help + Print usage and exit. + +.EXAMPLE + # Typical run (auto-probe mode): + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -CertPath C:\drop\OpenClawTray.cer ` + -EvidenceDir C:\msix-evidence\run1 + +.EXAMPLE + # Manual UI walk-through mode: + # Step 1 — install and emit manual instructions: + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -SkipAutoSetup ` + -EvidenceDir C:\msix-evidence\run1 + # (exit code 3 — operator walks through Setup-Locally) + + # Step 2 — probe + teardown (app already installed): + .\validate-msix-storage-paths.ps1 ` + -SkipInstall ` + -EvidenceDir C:\msix-evidence\run1 + +.EXAMPLE + # WhatIf dry-run: + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -WhatIf + + EVIDENCE LAYOUT + --------------- + / + pre-appdata.txt — recursive dir listing of %APPDATA%\OpenClawTray\ pre-install + pre-localappdata.txt — same for %LOCALAPPDATA%\OpenClawTray\ + pre-packages.txt — %LOCALAPPDATA%\Packages\*OpenClaw* pre-install + pre-appx.json — Get-AppxPackage *OpenClaw* pre-install (JSON) + install-stdout.txt — Add-AppxPackage output + install-stderr.txt — Add-AppxPackage errors + package-info.json — PackageFamilyName, InstallLocation, PackageFullName + post-appdata.txt — same dirs post-install/probe + post-localappdata.txt + post-packages.txt + post-appx.json + post-uninstall-appdata.txt — same dirs after Remove-AppxPackage + post-uninstall-localappdata.txt + post-uninstall-packages.txt + verdict.json — final verdict with removal_orphans + summary.json — script run metadata, all steps + MANUAL-STEP-REQUIRED.txt — emitted only when -SkipAutoSetup is used + + EXIT CODES + ---------- + 0 Script passed: non-Inconclusive verdict, all evidence files present, no errors. + 1 Script failed: Inconclusive verdict, incomplete evidence, or unhandled errors. + 2 Pre-flight blocked: OpenClaw* process running, or other blocking condition. + 3 Manual step required: -SkipAutoSetup was used; operator must walk UI flow. + +.NOTES + Date: 2026-05-07 + Author: Bostick (Tester/FIDO) — drafted pre-commit-7 for commit-7 verification. + DO NOT RUN against a paired tray instance. Snapshots capture settings.json content. +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$MsixPath, + [string]$CertPath, + [string]$EvidenceDir, + [switch]$SkipInstall, + [switch]$AutoSetup, # default ON — see logic below + [switch]$SkipAutoSetup, # force manual path + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- +if ($Help) { + Get-Help -Full $PSCommandPath + exit 0 +} + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +$SCRIPT_VERSION = "1.0.0" +$SCRIPT_DATE = "2026-05-07" +$PACKAGE_NAME_GLOB = "*OpenClaw.Tray*" +# Publisher hash in PackageFamilyName is derived from the manifest Publisher DN. +# We resolve it at runtime from Get-AppxPackage after install. +$PROBE_SESSION_ID = [System.Guid]::NewGuid().ToString("N") +$PROBE_MARKER_NAME = ".msix-storage-probe-$PROBE_SESSION_ID" +$RUN_MARKER_NAME = "run.marker" +$SETTINGS_FILE_NAME = "settings.json" + +# --------------------------------------------------------------------------- +# Default -AutoSetup ON unless -SkipAutoSetup explicitly set +# --------------------------------------------------------------------------- +$effectiveAutoSetup = -not $SkipAutoSetup.IsPresent + +# --------------------------------------------------------------------------- +# Evidence directory default +# --------------------------------------------------------------------------- +$runStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss") +if ([string]::IsNullOrWhiteSpace($EvidenceDir)) { + $EvidenceDir = Join-Path (Get-Location) "msix-validation-evidence\$runStamp" +} + +# --------------------------------------------------------------------------- +# Script-level state +# --------------------------------------------------------------------------- +$script:summary = [ordered]@{ + script = "validate-msix-storage-paths" + version = $SCRIPT_VERSION + date = $SCRIPT_DATE + startedAt = (Get-Date).ToString("o") + finishedAt = $null + status = "Running" + probeSessionId = $PROBE_SESSION_ID + autoSetup = $effectiveAutoSetup + evidenceDir = $EvidenceDir + msixPath = $MsixPath + packageFamilyName = $null + packageFullName = $null + installLocation = $null + verdict = $null + steps = @() + error = $null +} + +# Script-level slot for engine result (populated by Invoke-CliEngineUninstall) +$script:engineResult = $null + +# Exit-code sentinels +$EXIT_PASS = 0 +$EXIT_FAIL = 1 +$EXIT_PREFLIGHT_BLOCK = 2 +$EXIT_MANUAL_REQUIRED = 3 + +# --------------------------------------------------------------------------- +# Logging helpers (mirror validate-wsl-gateway.ps1 patterns) +# --------------------------------------------------------------------------- +function Add-Step { + param( + [string]$Name, + [string]$Status, # Completed | Failed | Skipped | Warning + [string]$Message, + [hashtable]$Data = @{} + ) + $entry = [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } + $script:summary.steps += $entry + + $ts = (Get-Date).ToString("HH:mm:ss") + $color = switch ($Status) { + "Completed" { "Cyan" } + "Failed" { "Red" } + "Skipped" { "DarkGray" } + "Warning" { "Yellow" } + default { "White" } + } + Write-Host "[$ts] [$Status] $Name — $Message" -ForegroundColor $color +} + +function Write-StepInfo { + param([string]$Message) + $ts = (Get-Date).ToString("HH:mm:ss") + Write-Host "[$ts] $Message" -ForegroundColor DarkCyan +} + +function Write-Fatal { + param([string]$Message) + $ts = (Get-Date).ToString("HH:mm:ss") + Write-Host "[$ts] [FATAL] $Message" -ForegroundColor Red +} + +# --------------------------------------------------------------------------- +# Evidence directory helpers +# --------------------------------------------------------------------------- +function Ensure-EvidenceDir { + if (-not (Test-Path -LiteralPath $EvidenceDir)) { + New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null + } +} + +function Get-EvidencePath { + param([string]$FileName) + Ensure-EvidenceDir + return Join-Path $EvidenceDir $FileName +} + +# --------------------------------------------------------------------------- +# Snapshot helpers +# --------------------------------------------------------------------------- +function Capture-DirListing { + param( + [string]$Path, + [string]$OutFile + ) + $dest = Get-EvidencePath $OutFile + if (Test-Path -LiteralPath $Path) { + try { + $items = Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | + Select-Object FullName, Length, LastWriteTimeUtc, Attributes | + Sort-Object FullName + $lines = @("# Captured: $(Get-Date -Format 'o') Path: $Path") + foreach ($i in $items) { + $lines += "$($i.LastWriteTimeUtc.ToString('o')) $($i.Attributes.ToString().PadRight(12)) $($i.Length.ToString().PadLeft(12)) $($i.FullName)" + } + $lines | Set-Content -LiteralPath $dest -Encoding UTF8 + return $items.Count + } + catch { + "# ERROR capturing $Path : $_" | Set-Content -LiteralPath $dest -Encoding UTF8 + return -1 + } + } + else { + "# Path does not exist: $Path (captured: $(Get-Date -Format 'o'))" | + Set-Content -LiteralPath $dest -Encoding UTF8 + return 0 + } +} + +function Capture-PackagesListing { + param([string]$OutFile) + $dest = Get-EvidencePath $OutFile + $pkgDir = Join-Path $env:LOCALAPPDATA "Packages" + if (Test-Path -LiteralPath $pkgDir) { + try { + $items = Get-ChildItem -LiteralPath $pkgDir -Directory -Filter "*OpenClaw*" -Force -ErrorAction SilentlyContinue | + Select-Object FullName, CreationTimeUtc, LastWriteTimeUtc + $lines = @("# Captured: $(Get-Date -Format 'o') Packages filter: *OpenClaw*") + foreach ($i in $items) { + $lines += "created=$($i.CreationTimeUtc.ToString('o')) modified=$($i.LastWriteTimeUtc.ToString('o')) $($i.FullName)" + # Recurse one level to show sub-dirs (LocalCache, LocalState, Settings, etc.) + $subs = Get-ChildItem -LiteralPath $i.FullName -Directory -Force -ErrorAction SilentlyContinue + foreach ($s in $subs) { + $lines += " + $($s.Name)" + } + } + $lines | Set-Content -LiteralPath $dest -Encoding UTF8 + return $items.Count + } + catch { + "# ERROR: $_" | Set-Content -LiteralPath $dest -Encoding UTF8 + return -1 + } + } + else { + "# %LOCALAPPDATA%\Packages does not exist" | Set-Content -LiteralPath $dest -Encoding UTF8 + return 0 + } +} + +function Capture-AppxPackage { + param([string]$OutFile) + $dest = Get-EvidencePath $OutFile + try { + $pkgs = Get-AppxPackage -Name "*OpenClaw*" -ErrorAction SilentlyContinue + if ($null -eq $pkgs) { $pkgs = @() } + $pkgs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $dest -Encoding UTF8 + } + catch { + @{ error = $_.ToString() } | ConvertTo-Json | Set-Content -LiteralPath $dest -Encoding UTF8 + } +} + +function Capture-AllSnapshots { + param([string]$Prefix) + # Prefix is "pre" | "post" | "post-uninstall" + $appDataDir = Join-Path $env:APPDATA "OpenClawTray" + $localAppDataDir = Join-Path $env:LOCALAPPDATA "OpenClawTray" + + $countA = Capture-DirListing -Path $appDataDir -OutFile "$Prefix-appdata.txt" + $countL = Capture-DirListing -Path $localAppDataDir -OutFile "$Prefix-localappdata.txt" + $countP = Capture-PackagesListing -OutFile "$Prefix-packages.txt" + Capture-AppxPackage -OutFile "$Prefix-appx.json" + + Add-Step "snapshot-$Prefix" "Completed" "Captured filesystem snapshots ($Prefix)." @{ + appDataItemCount = $countA + localAppDataItemCount = $countL + openClawPackageCount = $countP + } +} + +# --------------------------------------------------------------------------- +# Diff helper — returns list of new/changed paths in target vs baseline +# --------------------------------------------------------------------------- +function Get-NewPaths { + param( + [string]$BaselineFile, + [string]$NewFile + ) + if (-not (Test-Path -LiteralPath $BaselineFile)) { return @() } + if (-not (Test-Path -LiteralPath $NewFile)) { return @() } + + $baseline = Get-Content -LiteralPath $BaselineFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $current = Get-Content -LiteralPath $NewFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + + $baseSet = [System.Collections.Generic.HashSet[string]]::new($baseline, [System.StringComparer]::OrdinalIgnoreCase) + $new = $current | Where-Object { -not $baseSet.Contains($_) } + return @($new) +} + +# --------------------------------------------------------------------------- +# Write summary files +# --------------------------------------------------------------------------- +function Write-Summary { + Ensure-EvidenceDir + $script:summary.finishedAt = (Get-Date).ToString("o") + $summaryPath = Get-EvidencePath "summary.json" + $script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 +} + +# --------------------------------------------------------------------------- +# Invoke a process, capture stdout/stderr, record step +# --------------------------------------------------------------------------- +function Invoke-CapturedProcess { + param( + [string]$StepName, + [string]$FilePath, + [string[]]$ArgumentList = @(), + [string]$WorkingDirectory = (Get-Location).Path, + [switch]$IgnoreExitCode + ) + Ensure-EvidenceDir + $safeName = $StepName -replace '[^a-zA-Z0-9_.-]', '-' + $stdoutFile = Get-EvidencePath "$safeName.stdout.txt" + $stderrFile = Get-EvidencePath "$safeName.stderr.txt" + + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdoutFile 2> $stderrFile + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { [int]$global:LASTEXITCODE } + } + finally { + Pop-Location + } + + Add-Step $StepName "Completed" "Exit code $exitCode." @{ + file = $FilePath + arguments = ($ArgumentList -join " ") + exitCode = $exitCode + stdout = $stdoutFile + stderr = $stderrFile + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$StepName failed with exit code $exitCode. See $stderrFile" + } + return $exitCode +} + +# --------------------------------------------------------------------------- +# PHASE 1 — PREFLIGHT +# --------------------------------------------------------------------------- +function Invoke-Preflight { + Write-Host "" + Write-Host "═══ PHASE 1: PREFLIGHT ═══" -ForegroundColor Magenta + + # 1a. Interactive session check + if ($PSVersionTable.PSVersion.Major -ge 5) { + $sessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + if ($sessionId -eq 0) { + Write-Fatal "This script must run in an interactive Windows session (session 0 detected — likely a non-interactive service context). MSIX installation requires a user session." + return $EXIT_PREFLIGHT_BLOCK + } + } + Add-Step "preflight-session-check" "Completed" "Interactive session confirmed (SessionId=$([System.Diagnostics.Process]::GetCurrentProcess().SessionId))." + + # 1b. Refuse if any OpenClaw* process is running + $running = Get-Process -Name "OpenClaw*" -ErrorAction SilentlyContinue + if ($running) { + $pids = ($running | ForEach-Object { $_.Id }) -join ", " + Write-Fatal "OpenClaw* process(es) are running (PIDs: $pids). Stop them first:" + foreach ($p in $running) { + Write-Fatal " Stop-Process -Id $($p.Id) # $($p.ProcessName)" + } + Add-Step "preflight-process-check" "Failed" "OpenClaw processes running: PIDs $pids" @{ pids = $pids } + return $EXIT_PREFLIGHT_BLOCK + } + Add-Step "preflight-process-check" "Completed" "No OpenClaw* processes running." + + # 1c. Validate -MsixPath (unless -SkipInstall) + if (-not $SkipInstall) { + if ([string]::IsNullOrWhiteSpace($MsixPath)) { + Write-Fatal "-MsixPath is required unless -SkipInstall is set." + Add-Step "preflight-msix-path" "Failed" "-MsixPath not provided." + return $EXIT_PREFLIGHT_BLOCK + } + if (-not (Test-Path -LiteralPath $MsixPath)) { + Write-Fatal "-MsixPath does not exist: $MsixPath" + Add-Step "preflight-msix-path" "Failed" "File not found: $MsixPath" + return $EXIT_PREFLIGHT_BLOCK + } + Add-Step "preflight-msix-path" "Completed" "MSIX file found: $MsixPath" + } + else { + Add-Step "preflight-msix-path" "Skipped" "-SkipInstall set; skipping MSIX path validation." + } + + # 1d. Check for pre-existing OpenClawTray package + $existing = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + if ($existing -and -not $SkipInstall) { + Add-Step "preflight-existing-package" "Warning" "Pre-existing OpenClaw.Tray package found: $($existing.PackageFullName). Will overwrite via Add-AppxPackage." @{ + existingFullName = $existing.PackageFullName + } + } + elseif ($SkipInstall -and -not $existing) { + Write-Fatal "-SkipInstall set but no OpenClaw.Tray package is installed. Nothing to probe." + Add-Step "preflight-existing-package" "Failed" "No installed package found but -SkipInstall was set." + return $EXIT_PREFLIGHT_BLOCK + } + else { + Add-Step "preflight-existing-package" "Completed" "No pre-existing OpenClaw.Tray package found (clean state)." + } + + # 1e. WhatIf gate + if ($WhatIfPreference) { + Write-Host "" + Write-Host "WhatIf mode — planned actions:" -ForegroundColor Yellow + Write-Host " 1. Snapshot pre-install state" + Write-Host " 2. Add-AppxPackage '$MsixPath'" + if (-not [string]::IsNullOrWhiteSpace($CertPath)) { + Write-Host " 2a. Import-Certificate '$CertPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople" + } + Write-Host " 3. Write probe files to real %APPDATA%\OpenClawTray\ + %LOCALAPPDATA%\OpenClawTray\" + Write-Host " 4. Launch installed tray briefly (max 30 s)" + Write-Host " 5. Kill tray by PID" + Write-Host " 6. Snapshot post-probe state" + Write-Host " 7. Compute diff + verdict" + Write-Host " 8. Remove-AppxPackage" + Write-Host " 9. Snapshot post-uninstall state" + Write-Host " Evidence dir: $EvidenceDir" + Add-Step "whatif-output" "Completed" "WhatIf planned actions printed; no destructive operations performed." + return $EXIT_PASS + } + + return $null # $null means "continue" +} + +# --------------------------------------------------------------------------- +# PHASE 2 — SNAPSHOT PRE-INSTALL STATE +# --------------------------------------------------------------------------- +function Invoke-PreInstallSnapshot { + Write-Host "" + Write-Host "═══ PHASE 2: PRE-INSTALL SNAPSHOT ═══" -ForegroundColor Magenta + Capture-AllSnapshots -Prefix "pre" +} + +# --------------------------------------------------------------------------- +# PHASE 3 — INSTALL +# --------------------------------------------------------------------------- +function Invoke-Install { + Write-Host "" + Write-Host "═══ PHASE 3: INSTALL ═══" -ForegroundColor Magenta + + if ($SkipInstall) { + Add-Step "install-msix" "Skipped" "-SkipInstall set; assuming MSIX already installed." + } + else { + # 3a. Import cert if provided + if (-not [string]::IsNullOrWhiteSpace($CertPath)) { + Write-StepInfo "Importing sideload certificate: $CertPath" + try { + Import-Certificate -FilePath $CertPath -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" | Out-Null + Add-Step "import-certificate" "Completed" "Certificate imported to Cert:\LocalMachine\TrustedPeople." + } + catch { + Add-Step "import-certificate" "Failed" "Certificate import failed: $_" + throw + } + } + + # 3b. Add-AppxPackage + Write-StepInfo "Installing: $MsixPath" + $stdoutFile = Get-EvidencePath "install.stdout.txt" + $stderrFile = Get-EvidencePath "install.stderr.txt" + try { + Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown 2>&1 | Tee-Object -FilePath $stdoutFile + Add-Step "install-msix" "Completed" "Add-AppxPackage succeeded." @{ + msixPath = $MsixPath + stdout = $stdoutFile + } + } + catch { + $_ | Out-File -FilePath $stderrFile -Encoding UTF8 + Add-Step "install-msix" "Failed" "Add-AppxPackage threw: $_" @{ + msixPath = $MsixPath + stderr = $stderrFile + } + throw + } + } + + # 3c. Resolve PackageFamilyName and InstallLocation + $pkg = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + if (-not $pkg) { + throw "Package OpenClaw.Tray not found after install step. Cannot continue." + } + + $script:summary.packageFamilyName = $pkg.PackageFamilyName + $script:summary.packageFullName = $pkg.PackageFullName + $script:summary.installLocation = $pkg.InstallLocation + + $pkgInfo = [ordered]@{ + packageFamilyName = $pkg.PackageFamilyName + packageFullName = $pkg.PackageFullName + installLocation = $pkg.InstallLocation + version = $pkg.Version + architecture = $pkg.Architecture + publisherId = $pkg.PublisherId + capturedAt = (Get-Date).ToString("o") + } + $pkgInfoPath = Get-EvidencePath "package-info.json" + $pkgInfo | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $pkgInfoPath -Encoding UTF8 + + Add-Step "resolve-package-info" "Completed" "Package resolved: $($pkg.PackageFamilyName)" @{ + packageFamilyName = $pkg.PackageFamilyName + installLocation = $pkg.InstallLocation + pkgInfoFile = $pkgInfoPath + } + + return $pkg +} + +# --------------------------------------------------------------------------- +# PHASE 4 — TRIGGER / PROBE +# --------------------------------------------------------------------------- +function Invoke-ProbeSetup { + param([object]$Pkg) + + Write-Host "" + Write-Host "═══ PHASE 4: PROBE SETUP ═══" -ForegroundColor Magenta + + if (-not $effectiveAutoSetup) { + # Manual path: emit instructions and exit 3 + $manualPath = Get-EvidencePath "MANUAL-STEP-REQUIRED.txt" + $instructions = @( + "MANUAL STEP REQUIRED — $(Get-Date -Format 'o')", + "", + "The script was run with -SkipAutoSetup. You must manually walk through the", + "OpenClaw Setup-Locally flow in the tray UI, then re-run the script to capture", + "the post-setup snapshot.", + "", + "STEPS:", + " 1. Launch the installed tray from Start / App list:", + " explorer.exe shell:AppsFolder\$($Pkg.PackageFamilyName)!App", + " 2. Follow the Setup-Locally wizard in the tray UI until it completes.", + " 3. Close the tray app (right-click tray icon → Exit, or Stop-Process -Id ).", + " 4. Re-run this script with -SkipInstall and the SAME -EvidenceDir:", + " .\validate-msix-storage-paths.ps1 -SkipInstall -EvidenceDir '$EvidenceDir'", + "", + "Evidence directory: $EvidenceDir", + "PackageFamilyName: $($Pkg.PackageFamilyName)" + ) + $instructions | Set-Content -LiteralPath $manualPath -Encoding UTF8 + Add-Step "probe-setup" "Skipped" "-SkipAutoSetup: manual UI walk-through required. See MANUAL-STEP-REQUIRED.txt." + Write-Host "" + Write-Host "Manual step required. Instructions written to:" -ForegroundColor Yellow + Write-Host " $manualPath" -ForegroundColor Yellow + return $EXIT_MANUAL_REQUIRED + } + + # -AutoSetup path: + # Write probe marker files to the REAL %APPDATA%\OpenClawTray\ and %LOCALAPPDATA%\OpenClawTray\ + # paths with a unique session ID. Then launch the installed tray briefly. + # The presence of a run.marker or modified settings.json at real paths tells us the app + # uses real APPDATA (Path A). If those paths stay untouched but files appear under + # %LOCALAPPDATA%\Packages\\, that confirms Path B. + + $realAppData = Join-Path $env:APPDATA "OpenClawTray" + $realLocalAppData = Join-Path $env:LOCALAPPDATA "OpenClawTray" + + # Ensure dirs exist so we can write probe files + foreach ($dir in @($realAppData, $realLocalAppData)) { + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + } + + # Write probe markers at real paths (unique per session) + $probeAppData = Join-Path $realAppData $PROBE_MARKER_NAME + $probeLocalAppData = Join-Path $realLocalAppData $PROBE_MARKER_NAME + $probeContent = @{ probeSessionId = $PROBE_SESSION_ID; createdAt = (Get-Date).ToString("o") } | ConvertTo-Json + Set-Content -LiteralPath $probeAppData -Value $probeContent -Encoding UTF8 + Set-Content -LiteralPath $probeLocalAppData -Value $probeContent -Encoding UTF8 + + Add-Step "probe-write-markers" "Completed" "Probe marker files written to real APPDATA paths." @{ + appDataProbe = $probeAppData + localAppDataProbe = $probeLocalAppData + probeSessionId = $PROBE_SESSION_ID + } + + # Launch the MSIX-installed tray using the shell:AppsFolder activation URI + # (the canonical way to launch a packaged app without a Start tile shortcut) + Write-StepInfo "Launching installed tray via shell:AppsFolder..." + $pfn = $Pkg.PackageFamilyName + $appUserModelId = "$pfn!App" + $trayProcess = $null + + try { + Start-Process "explorer.exe" -ArgumentList "shell:AppsFolder\$appUserModelId" + Add-Step "probe-launch-tray" "Completed" "explorer.exe shell:AppsFolder launched." @{ appUserModelId = $appUserModelId } + } + catch { + Add-Step "probe-launch-tray" "Warning" "Launch attempt threw: $_. Proceeding with probe anyway." @{ error = $_.ToString() } + } + + # Wait up to 30 s for a OpenClaw* process to appear + Write-StepInfo "Waiting up to 30 s for tray process to start..." + $deadline = (Get-Date).AddSeconds(30) + $trayProcess = $null + while ((Get-Date) -lt $deadline) { + $found = Get-Process -Name "OpenClaw*" -ErrorAction SilentlyContinue + if ($found) { + $trayProcess = $found | Select-Object -First 1 + break + } + Start-Sleep -Milliseconds 500 + } + + if ($trayProcess) { + Write-StepInfo "Tray process started: PID $($trayProcess.Id) ($($trayProcess.ProcessName)). Waiting 10 s for initialization..." + Start-Sleep -Seconds 10 + Add-Step "probe-tray-running" "Completed" "Tray process detected." @{ + pid = $trayProcess.Id + processName = $trayProcess.ProcessName + } + + # Kill tray by PID (never by name — policy requirement) + try { + Stop-Process -Id $trayProcess.Id -Force -ErrorAction SilentlyContinue + Add-Step "probe-tray-stop" "Completed" "Tray process stopped." @{ pid = $trayProcess.Id } + } + catch { + Add-Step "probe-tray-stop" "Warning" "Stop-Process threw: $_. May have already exited." @{ error = $_.ToString() } + } + Start-Sleep -Seconds 2 # let file handles close + } + else { + Add-Step "probe-tray-running" "Warning" "Tray process did not appear within 30 s. Probe results may be Inconclusive." + } + + # Clean up our own probe markers (they served their purpose; we don't want them + # contaminating the diff as "app-written" files) + foreach ($f in @($probeAppData, $probeLocalAppData)) { + if (Test-Path -LiteralPath $f) { + Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue + } + } + + return $null # continue +} + +# --------------------------------------------------------------------------- +# PHASE 4a — CLI ENGINE UNINSTALL +# Invoke OpenClawTray.exe --uninstall CLI to: +# 1. Drive the engine's own cleanup (WSL distro, settings, etc.) +# 2. Capture engine postconditions for cross_check_consistent in verdict.json +# +# Runs AFTER the probe has triggered file writes so the filesystem diff is +# captured before engine cleanup happens (Phase 5 snapshots the post-probe +# state AFTER this phase so the diff reflects what the tray itself wrote, +# not the engine cleanup). +# +# NOTE: The EXE is taken from the MSIX install location so it is the same +# binary that was probed. If the EXE is not found, the phase is skipped and +# cross_check_consistent will be false. +# --------------------------------------------------------------------------- +function Invoke-CliEngineUninstall { + param([object]$Pkg) + + Write-Host "" + Write-Host "═══ PHASE 4a: CLI ENGINE UNINSTALL ═══" -ForegroundColor Magenta + + # Locate the EXE inside the MSIX install location + $installLocation = if ($Pkg) { $Pkg.InstallLocation } else { $script:summary.installLocation } + $exePath = $null + if (-not [string]::IsNullOrEmpty($installLocation)) { + $candidate = Join-Path $installLocation "OpenClaw.Tray.WinUI.exe" + if (Test-Path -LiteralPath $candidate) { + $exePath = $candidate + } + } + + if ([string]::IsNullOrEmpty($exePath)) { + Add-Step "cli-engine-uninstall" "Skipped" "OpenClaw.Tray.WinUI.exe not found in install location '$installLocation'. cross_check_consistent will be false." @{ + installLocation = $installLocation + } + return [ordered]@{ + invoked = $false + exit_code = $null + success = $false + json_path = $null + postconditions = $null + skip_reason = "EXE not found at install location" + } + } + + Write-StepInfo "Engine EXE: $exePath" + + Ensure-EvidenceDir + $jsonOutputPath = Get-EvidencePath "engine-uninstall-result.json" + $stdoutPath = Get-EvidencePath "cli-engine-uninstall.stdout.txt" + $stderrPath = Get-EvidencePath "cli-engine-uninstall.stderr.txt" + + $cliArgs = @('--uninstall', '--confirm-destructive', '--json-output', $jsonOutputPath) + Write-StepInfo "Invoking: $exePath $($cliArgs -join ' ')" + + $exitCode = $null + try { + & $exePath @cliArgs > $stdoutPath 2> $stderrPath + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { [int]$global:LASTEXITCODE } + } + catch { + $exitCode = -1 + "Exception: $($_.ToString())" | Set-Content -LiteralPath $stderrPath -Encoding UTF8 + } + + # Parse engine JSON result + $engineJson = $null + $engineSuccess = $false + $enginePostconds = $null + if (Test-Path -LiteralPath $jsonOutputPath) { + try { + $raw = Get-Content -LiteralPath $jsonOutputPath -Raw -Encoding UTF8 + $engineJson = $raw | ConvertFrom-Json + $engineSuccess = [bool]$engineJson.success + # Build an ordered hashtable from the postconditions object + $pc = $engineJson.postconditions + if ($pc) { + $enginePostconds = [ordered]@{ + wsl_distro_absent = [bool]$pc.wsl_distro_absent + autostart_cleared = [bool]$pc.autostart_cleared + setup_state_absent = [bool]$pc.setup_state_absent + device_token_cleared = [bool]$pc.device_token_cleared + mcp_token_preserved = [bool]$pc.mcp_token_preserved + keepalives_absent = [bool]$pc.keepalives_absent + vhd_dir_absent = [bool]$pc.vhd_dir_absent + } + } + } + catch { + Write-StepInfo "Warning: could not parse engine JSON: $_" + } + } + + $stepStatus = if ($exitCode -eq 0) { "Completed" } else { "Warning" } + Add-Step "cli-engine-uninstall" $stepStatus "CLI engine exit code $exitCode. success=$engineSuccess." @{ + exePath = $exePath + exitCode = $exitCode + success = $engineSuccess + jsonPath = $jsonOutputPath + stdout = $stdoutPath + stderr = $stderrPath + } + + $result = [ordered]@{ + invoked = $true + exit_code = $exitCode + success = $engineSuccess + json_path = $jsonOutputPath + postconditions = $enginePostconds + skip_reason = $null + } + + # Store in script-level slot so Invoke-Teardown can reference it + $script:engineResult = $result + + return $result +} + +# --------------------------------------------------------------------------- +# PHASE 5 — POST-INSTALL SNAPSHOT +# --------------------------------------------------------------------------- +function Invoke-PostInstallSnapshot { + Write-Host "" + Write-Host "═══ PHASE 5: POST-INSTALL SNAPSHOT ═══" -ForegroundColor Magenta + Capture-AllSnapshots -Prefix "post" +} + +# --------------------------------------------------------------------------- +# PHASE 6 — DIFF & VERDICT +# --------------------------------------------------------------------------- +function Invoke-Verdict { + param( + [object]$Pkg, + [object]$EngineResult # result from Invoke-CliEngineUninstall; may be $null + ) + + Write-Host "" + Write-Host "═══ PHASE 6: DIFF & VERDICT ═══" -ForegroundColor Magenta + + $preAppData = Get-EvidencePath "pre-appdata.txt" + $preLocalAppData = Get-EvidencePath "pre-localappdata.txt" + $prePackages = Get-EvidencePath "pre-packages.txt" + $postAppData = Get-EvidencePath "post-appdata.txt" + $postLocalAppData = Get-EvidencePath "post-localappdata.txt" + $postPackages = Get-EvidencePath "post-packages.txt" + + $newInAppData = Get-NewPaths -BaselineFile $preAppData -NewFile $postAppData + $newInLocalAppData = Get-NewPaths -BaselineFile $preLocalAppData -NewFile $postLocalAppData + $newInPackages = Get-NewPaths -BaselineFile $prePackages -NewFile $postPackages + + $writesToRealAppData = $newInAppData.Count -gt 0 + $writesToRealLocalAppData = $newInLocalAppData.Count -gt 0 + + # "Virtualized" = package-local storage gained new sub-dirs/files + # We check if the PFN package dir gained a LocalState or LocalCache dir with content + $pfn = if ($Pkg) { $Pkg.PackageFamilyName } else { $script:summary.packageFamilyName } + $pkgDir = Join-Path $env:LOCALAPPDATA "Packages\$pfn" + $pkgLocalState = Join-Path $pkgDir "LocalState" + $pkgRoaming = Join-Path $pkgDir "RoamingState" + + $writesToVirtualized = $false + foreach ($candidate in @($pkgLocalState, $pkgRoaming)) { + if (Test-Path -LiteralPath $candidate) { + $items = Get-ChildItem -LiteralPath $candidate -Recurse -Force -ErrorAction SilentlyContinue + if ($items.Count -gt 0) { + $writesToVirtualized = $true + break + } + } + } + + # Determine verdict + $verdict = "Inconclusive" + $reasoning = "No new files detected in either real APPDATA paths or MSIX package LocalState. Possible causes: tray did not launch, first-run initialization skipped, or probe window too short." + + if ($writesToRealAppData -or $writesToRealLocalAppData) { + $verdict = "PathA-OrphanRisk" + $reasoning = "New files detected in real APPDATA and/or LOCALAPPDATA paths. runFullTrust is bypassing MSIX virtualization. Remove-AppxPackage will NOT clean these files. In-tray cleanup (Remove Local Gateway) and pre-removal warning banner are required." + } + elseif ($writesToVirtualized) { + $verdict = "PathB-CleanRemove" + $reasoning = "No new files in real APPDATA paths. New content detected under %LOCALAPPDATA%\Packages\$pfn\. MSIX filesystem virtualization is active. Remove-AppxPackage will clean file-based artifacts. WSL distro still requires explicit cleanup." + } + elseif ($newInPackages.Count -gt 0 -and -not $writesToRealAppData -and -not $writesToRealLocalAppData) { + $verdict = "PathB-CleanRemove" + $reasoning = "New package directories found under %LOCALAPPDATA%\Packages (filter *OpenClaw*) and no real APPDATA growth detected. Consistent with virtualized storage." + } + + $verdictData = [ordered]@{ + msix_writes_to_real_appdata = $writesToRealAppData + msix_writes_to_real_localappdata = $writesToRealLocalAppData + msix_writes_to_virtualized_storage = $writesToVirtualized + package_family_name = $pfn + install_location = if ($Pkg) { $Pkg.InstallLocation } else { $script:summary.installLocation } + evidence_dir = $EvidenceDir + verdict = $verdict + reasoning = $reasoning + new_real_appdata_files = @($newInAppData) + new_real_localappdata_files = @($newInLocalAppData) + removal_orphans = @() # populated in phase 7 + # ------------------------------------------------------------------ 7A fields + # Engine CLI postconditions from --uninstall --confirm-destructive --json-output. + # cross_check_consistent is updated in Phase 7 (Invoke-Teardown) once orphan + # data is available. Initial value is $false; Teardown sets final value. + engine_cli_invoked = $false + engine_cli_exit_code = $null + engine_postconditions = $null + cross_check_consistent = $false + # ------------------------------------------------------------------ + capturedAt = (Get-Date).ToString("o") + } + + # Embed engine result if we have one (may have been populated by Invoke-CliEngineUninstall) + if ($null -ne $EngineResult) { + $verdictData.engine_cli_invoked = [bool]$EngineResult.invoked + $verdictData.engine_cli_exit_code = $EngineResult.exit_code + $verdictData.engine_postconditions = $EngineResult.postconditions + } elseif ($null -ne $script:engineResult) { + $er = $script:engineResult + $verdictData.engine_cli_invoked = [bool]$er.invoked + $verdictData.engine_cli_exit_code = $er.exit_code + $verdictData.engine_postconditions = $er.postconditions + } + + $verdictPath = Get-EvidencePath "verdict.json" + $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + + $script:summary.verdict = $verdict + + # Color-coded console output + $verdictColor = switch ($verdict) { + "PathA-OrphanRisk" { "Red" } + "PathB-CleanRemove" { "Green" } + default { "Yellow" } + } + Write-Host "" + Write-Host "┌─────────────────────────────────────────────────────────────┐" -ForegroundColor $verdictColor + Write-Host "│ VERDICT: $verdict" -ForegroundColor $verdictColor + Write-Host "│ $reasoning" -ForegroundColor $verdictColor + Write-Host "│ Evidence: $verdictPath" -ForegroundColor $verdictColor + Write-Host "└─────────────────────────────────────────────────────────────┘" -ForegroundColor $verdictColor + Write-Host "" + + Add-Step "verdict" $( if ($verdict -eq "Inconclusive") { "Warning" } else { "Completed" } ) ` + "Verdict: $verdict" @{ + verdict = $verdict + writesToRealAppData = $writesToRealAppData + writesToRealLocal = $writesToRealLocalAppData + writesToVirtualized = $writesToVirtualized + newRealAppDataCount = $newInAppData.Count + newRealLocalCount = $newInLocalAppData.Count + verdictFile = $verdictPath + } + + return $verdictData +} + +# --------------------------------------------------------------------------- +# PHASE 7 — TEARDOWN +# --------------------------------------------------------------------------- +function Invoke-Teardown { + param( + [object]$Pkg, + [object]$VerdictData + ) + + Write-Host "" + Write-Host "═══ PHASE 7: TEARDOWN ═══" -ForegroundColor Magenta + + if (-not $Pkg) { + # Attempt to resolve by name in case we used -SkipInstall + $Pkg = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + } + + if (-not $Pkg) { + Add-Step "teardown-remove-appx" "Skipped" "No OpenClaw.Tray package found to remove." + } + else { + $pkgFullName = $Pkg.PackageFullName + Write-StepInfo "Removing package: $pkgFullName" + $removeStdout = Get-EvidencePath "remove-appx.stdout.txt" + $removeStderr = Get-EvidencePath "remove-appx.stderr.txt" + try { + Remove-AppxPackage -Package $pkgFullName 2>&1 | Tee-Object -FilePath $removeStdout + Add-Step "teardown-remove-appx" "Completed" "Remove-AppxPackage succeeded." @{ + packageFullName = $pkgFullName + stdout = $removeStdout + } + } + catch { + $_ | Out-File -FilePath $removeStderr -Encoding UTF8 + Add-Step "teardown-remove-appx" "Warning" "Remove-AppxPackage threw: $_. Proceeding with post-uninstall snapshot." @{ + error = $_.ToString() + stderr = $removeStderr + } + } + } + + # Post-uninstall snapshot + Capture-AllSnapshots -Prefix "post-uninstall" + + # Compute orphans: files that still exist after removal that existed at post-install time + $postAppData = Get-EvidencePath "post-appdata.txt" + $postLocalData = Get-EvidencePath "post-localappdata.txt" + $postUnAppData = Get-EvidencePath "post-uninstall-appdata.txt" + $postUnLocalData = Get-EvidencePath "post-uninstall-localappdata.txt" + + $orphansAppData = Get-NewPaths -BaselineFile $postUnAppData -NewFile $postAppData + $orphansLocal = Get-NewPaths -BaselineFile $postUnLocalData -NewFile $postLocalData + + # Items that survived removal (present in post-uninstall = NOT new relative to post = survivors) + # Re-interpret: survivors = items in post-uninstall that were also in post (gained during run) + # Simpler: items in post that are ALSO in post-uninstall = not cleaned up + function Get-SurvivedPaths { + param([string]$PostFile, [string]$PostUninstallFile) + if (-not (Test-Path -LiteralPath $PostFile)) { return @() } + if (-not (Test-Path -LiteralPath $PostUninstallFile)) { return @() } + $postItems = Get-Content -LiteralPath $PostFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $postUnItems = Get-Content -LiteralPath $PostUninstallFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $uninstallSet = [System.Collections.Generic.HashSet[string]]::new($postUnItems, [System.StringComparer]::OrdinalIgnoreCase) + return @($postItems | Where-Object { $uninstallSet.Contains($_) }) + } + + $survivedAppData = Get-SurvivedPaths -PostFile $postAppData -PostUninstallFile $postUnAppData + $survivedLocal = Get-SurvivedPaths -PostFile $postLocalData -PostUninstallFile $postUnLocalData + $allSurvivors = @($survivedAppData) + @($survivedLocal) + + # ----------------------------------------------------------------------- + # cross_check_consistent (7A requirement): + # "Do the engine postconditions match the empirical filesystem diff?" + # + # For PathA-OrphanRisk: consistent = engine was invoked AND succeeded + # AND engine.postconditions.wsl_distro_absent == true (engine cleaned or + # confirmed WSL distro was never registered) WHILE files in real APPDATA + # survived Remove-AppxPackage (confirming MSIX does not auto-clean them). + # This is the definitive PathA evidence package. + # + # For PathB-CleanRemove: consistent = engine was invoked AND succeeded + # AND no orphan files survived Remove-AppxPackage. + # + # Inconclusive: always false. + # ----------------------------------------------------------------------- + $crossCheckConsistent = $false + $er = $script:engineResult + if ($VerdictData -and $null -ne $er -and [bool]$er.invoked -and $er.exit_code -eq 0) { + $enginePc = $er.postconditions + $engineWslAbsent = if ($enginePc -and $null -ne $enginePc.wsl_distro_absent) { [bool]$enginePc.wsl_distro_absent } else { $false } + $currentVerdict = $VerdictData.verdict + + if ($currentVerdict -eq "PathA-OrphanRisk") { + # PathA: real-APPDATA writes confirmed AND engine cleaned WSL (or no distro was ever + # registered). Orphans surviving MSIX removal confirm the orphan-risk claim. + $crossCheckConsistent = $engineWslAbsent + } + elseif ($currentVerdict -eq "PathB-CleanRemove") { + # PathB: no orphan files after MSIX removal AND engine completed successfully. + $crossCheckConsistent = ($engineWslAbsent -and ($allSurvivors.Count -eq 0)) + } + # Inconclusive → stays false + } + + # Update verdict.json with removal_orphans AND cross_check_consistent + if ($VerdictData) { + $VerdictData.removal_orphans = $allSurvivors + $VerdictData.cross_check_consistent = $crossCheckConsistent + $verdictPath = Get-EvidencePath "verdict.json" + $VerdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + } + + if ($allSurvivors.Count -gt 0) { + Add-Step "teardown-orphan-check" "Warning" "$($allSurvivors.Count) file(s) survived Remove-AppxPackage (orphans)." @{ + orphans = $allSurvivors + } + Write-Host "⚠ $($allSurvivors.Count) orphan file(s) found after MSIX removal:" -ForegroundColor Yellow + foreach ($o in $allSurvivors) { Write-Host " $o" -ForegroundColor Yellow } + } + else { + Add-Step "teardown-orphan-check" "Completed" "No orphan files detected after Remove-AppxPackage." + } +} + +# --------------------------------------------------------------------------- +# PASS/FAIL evaluation +# --------------------------------------------------------------------------- +function Get-FinalExitCode { + param([object]$VerdictData) + + $hasAllEvidence = $true + foreach ($required in @( + "pre-appdata.txt", "pre-localappdata.txt", "pre-packages.txt", "pre-appx.json", + "post-appdata.txt", "post-localappdata.txt", "post-packages.txt", "post-appx.json", + "post-uninstall-appdata.txt", "post-uninstall-localappdata.txt", "post-uninstall-packages.txt", + "verdict.json", "package-info.json" + )) { + $p = Get-EvidencePath $required + if (-not (Test-Path -LiteralPath $p)) { + Write-Host " MISSING evidence file: $p" -ForegroundColor Red + $hasAllEvidence = $false + } + } + + if (-not $hasAllEvidence) { return $EXIT_FAIL } + + $verdict = if ($VerdictData) { $VerdictData.verdict } else { $script:summary.verdict } + if ($verdict -eq "Inconclusive") { return $EXIT_FAIL } + if ([string]::IsNullOrWhiteSpace($verdict)) { return $EXIT_FAIL } + + return $EXIT_PASS +} + +# --------------------------------------------------------------------------- +# MAIN +# --------------------------------------------------------------------------- +Ensure-EvidenceDir +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ validate-msix-storage-paths.ps1 v$SCRIPT_VERSION ($SCRIPT_DATE) ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host " Evidence dir : $EvidenceDir" +Write-Host " AutoSetup : $effectiveAutoSetup" +Write-Host " SkipInstall : $($SkipInstall.IsPresent)" +if ($MsixPath) { Write-Host " MsixPath : $MsixPath" } +Write-Host "" + +$exitCode = $EXIT_FAIL +$pkg = $null +$verdictData = $null + +try { + # Phase 1: Preflight + $preflightResult = Invoke-Preflight + if ($null -ne $preflightResult) { + # Non-null means a terminal early result (WhatIf, preflight block, etc.) + $exitCode = $preflightResult + } + else { + # Phase 2: Pre-install snapshot + Invoke-PreInstallSnapshot + + # Phase 3: Install + $pkg = Invoke-Install + + # Phase 4: Probe setup + $probeResult = Invoke-ProbeSetup -Pkg $pkg + if ($null -ne $probeResult) { + $exitCode = $probeResult # manual step required (exit 3) + } + else { + # Phase 5: Post-install snapshot + Invoke-PostInstallSnapshot + + # Phase 4a: CLI Engine Uninstall — invoke Aaron's --uninstall flag to: + # 1. Drive the engine's own gateway cleanup (WSL distro, settings, etc.) + # 2. Capture engine postconditions for cross_check_consistent in verdict.json + # Must run AFTER the probe snapshot so the post-probe state reflects what the + # tray itself wrote (not engine cleanup). + $engineResult = Invoke-CliEngineUninstall -Pkg $pkg + + # Phase 6: Verdict (includes engine data in verdict.json) + $verdictData = Invoke-Verdict -Pkg $pkg -EngineResult $engineResult + + # Phase 7: Teardown (Remove-AppxPackage, orphan check, final cross_check update) + Invoke-Teardown -Pkg $pkg -VerdictData $verdictData + + # Evaluate final exit code + $exitCode = Get-FinalExitCode -VerdictData $verdictData + } + } + + $script:summary.status = if ($exitCode -eq $EXIT_PASS) { "Passed" } elseif ($exitCode -eq $EXIT_MANUAL_REQUIRED) { "ManualStepRequired" } elseif ($exitCode -eq $EXIT_PREFLIGHT_BLOCK) { "PreflightBlocked" } else { "Failed" } +} +catch { + $script:summary.status = "Error" + $script:summary.error = $_.ToString() + Add-Step "unhandled-error" "Failed" $_.ToString() + Write-Fatal "Unhandled error: $_" + $exitCode = $EXIT_FAIL +} +finally { + Write-Summary + Write-Host "" + Write-Host "Summary written to: $(Get-EvidencePath 'summary.json')" -ForegroundColor Cyan + Write-Host "Evidence directory: $EvidenceDir" -ForegroundColor Cyan + $verdictDisplay = if ($verdictData) { $verdictData.verdict } elseif ($script:summary.verdict) { $script:summary.verdict } else { "N/A" } + Write-Host "Verdict : $verdictDisplay" -ForegroundColor $( + switch ($verdictDisplay) { + "PathA-OrphanRisk" { "Red" } + "PathB-CleanRemove" { "Green" } + default { "Yellow" } + } + ) + Write-Host "Exit code : $exitCode" -ForegroundColor $(if ($exitCode -eq 0) { "Green" } else { "Yellow" }) + Write-Host "" +} + +exit $exitCode diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 new file mode 100644 index 000000000..2eaab1c89 --- /dev/null +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -0,0 +1,1095 @@ +<# +.SYNOPSIS + Empirical verification of LocalGatewayUninstall postconditions. + Mirror of validate-wsl-gateway.ps1 for the inverse (uninstall) direction. + +.DESCRIPTION + PURPOSE + ------- + This script duplicates the LocalGatewayUninstall step sequence in PowerShell + so Bostick can validate end-to-end without invoking the tray. The C# engine + remains the production code path; this script MUST stay aligned with it. + + Source of truth for steps: + src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs + + Shared helpers dot-sourced from: + scripts/_uninstall-helpers.ps1 + (Test-IsOpenClawOwnedDistroName, Invoke-WslCommand, Stop-OpenClawProcessByPid, + Assert-DryRunGate, Add-Step) + + MODES + ----- + PreflightOnly + Verifies the distro is registered, validates the distro-name guard, and + snapshots pre-state to pre-state.json. Non-destructive. If the distro + is absent, exits 0 immediately with verdict "AlreadyClean". + + Full + PreflightOnly snapshot -> execute 13-step uninstall sequence in + PowerShell -> post-state snapshot -> writes verdict.json. + Requires -ConfirmDestructive unless -DryRun is also set. + + PostconditionOnly + Skips snapshot and destruction. Evaluates current filesystem / + registry / WSL state against the uninstall-completed expectations. + Use after an in-tray uninstall or a manual cleanup. + + OUTPUT LAYOUT + ------------- + / + pre-state.json state snapshot before uninstall + post-state.json state snapshot after uninstall (Full only) + steps.json ordered step audit trail (Full only) + verdict.json final pass/fail verdict + postconditions + summary.md human-readable summary + + EXIT CODES + ---------- + 0 PASS All postconditions match expected uninstall-complete state. + 1 FAIL One or more postconditions do not match. + 2 BLOCKED Preflight blocked (wrong distro name, -ConfirmDestructive missing). + 3 ERROR Full mode failed mid-execution (unhandled exception). + +.PARAMETER Mode + Required. One of: PreflightOnly | Full | PostconditionOnly. + +.PARAMETER ConfirmDestructive + Required for Full mode unless -DryRun is also set. Safety gate. + +.PARAMETER DistroName + WSL distro name to target. Default: OpenClawGateway. + Must be exactly "OpenClawGateway" (no prefix variants). + +.PARAMETER PreserveLogs + When $true (default), gateway logs are not deleted in Full mode. + Mirrors LocalGatewayUninstallOptions.PreserveLogs. + +.PARAMETER PreserveExecPolicy + When $true (default), exec-policy.json is not deleted in Full mode. + Mirrors LocalGatewayUninstallOptions.PreserveExecPolicy. + +.PARAMETER OutputDir + Directory for output artifacts. + Default: .\uninstall-validation-output\\ + +.PARAMETER DryRun + When set with Full mode, executes step logic with no destructive mutations. + Each step records status "DryRun". Verdict is "DryRunComplete". + +.PARAMETER Help + Prints usage and exits 0. + +.EXAMPLE + # Non-destructive preflight + pre-state snapshot: + .\validate-wsl-gateway-uninstall.ps1 -Mode PreflightOnly + +.EXAMPLE + # Dry-run: records every step without destroying anything: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -DryRun + +.EXAMPLE + # Live full uninstall + postcondition verification: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive + +.EXAMPLE + # Verify postconditions after in-tray uninstall: + .\validate-wsl-gateway-uninstall.ps1 -Mode PostconditionOnly + +.EXAMPLE + # Full uninstall preserving logs but deleting exec-policy: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -PreserveExecPolicy $false + +.NOTES + Date: 2026-05-07 + Author: Aaron (Backend / Infrastructure Engineer) + Branch: feat/wsl-gateway-uninstall + + File I/O against WSL is via `wsl bash -c` only. + NEVER use \\wsl$ / \\wsl.localhost paths. + Token / key material is redacted in all output artifacts. +#> + +[CmdletBinding()] +param( + # Mode is effectively required — validated manually so that -Help works without it. + [string]$Mode = "", + + [switch]$ConfirmDestructive, + + [string]$DistroName = "OpenClawGateway", + + [bool]$PreserveLogs = $true, + + [bool]$PreserveExecPolicy = $true, + + [string]$OutputDir = "", + + [switch]$DryRun, + + # When set, -Mode Full skips the CLI delegate and executes the inline + # PowerShell step replication instead. Use for diagnostics or when + # OpenClawTray.exe is not available (e.g. standalone script on bare machine). + [switch]$NoCli, + + # Path to OpenClawTray.exe. Auto-detected from common locations when empty. + [string]$ExePath = "", + + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot\_uninstall-helpers.ps1" + +# Step audit trail required by Add-Step (from _uninstall-helpers.ps1). +$script:steps = @() + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +if ($Help -or [string]::IsNullOrEmpty($Mode)) { + Write-Host @' +validate-wsl-gateway-uninstall.ps1 — OpenClaw WSL gateway uninstall validator + +USAGE: + .\validate-wsl-gateway-uninstall.ps1 -Mode [options] + +MODES (required): + PreflightOnly Snapshot pre-state only. Non-destructive. + Full Snapshot -> execute 13-step uninstall -> verdict. + PostconditionOnly Verify current state against uninstall-complete expectations. + +OPTIONS: + -ConfirmDestructive Required for Full mode (unless -DryRun is also set). + -DistroName Default: OpenClawGateway (must be exact match) + -PreserveLogs Default: $true (do not delete gateway logs) + -PreserveExecPolicy Default: $true (do not delete exec-policy.json) + -OutputDir Default: .\uninstall-validation-output\\ + -DryRun Full mode records steps without any destruction. + -NoCli Full mode: skip CLI delegate; use inline PS replication. + Use for diagnostics or when the EXE is unavailable. + -ExePath Explicit path to OpenClawTray.exe (auto-detected when empty). + -Help Show this help. + +EXIT CODES: + 0 PASS All postconditions match uninstall-complete state. + 1 FAIL One or more postconditions do not match. + 2 BLOCKED Preflight blocked (wrong distro name / missing -ConfirmDestructive). + 3 ERROR Full mode failed mid-execution. + +EXAMPLES: + # Non-destructive preflight + pre-state snapshot: + .\validate-wsl-gateway-uninstall.ps1 -Mode PreflightOnly + + # Dry-run (records steps, no destruction): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -DryRun + + # Live full uninstall + verification (via CLI delegate — default): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive + + # Live full uninstall via inline PS replication (diagnostic fallback): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -NoCli + + # Verify state after in-tray uninstall: + .\validate-wsl-gateway-uninstall.ps1 -Mode PostconditionOnly +'@ + exit 0 +} + +# --------------------------------------------------------------------------- +# Parameter validation +# --------------------------------------------------------------------------- + +$validModes = @('PreflightOnly', 'Full', 'PostconditionOnly') +if ($Mode -notin $validModes) { + Write-Host "ERROR: -Mode must be one of: $($validModes -join ', '). Got: '$Mode'" -ForegroundColor Red + exit 2 +} + +if (-not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { + Write-Host "ERROR: Refusing to operate on distro '$DistroName': name must be exactly 'OpenClawGateway'. Pass -DistroName OpenClawGateway." -ForegroundColor Red + exit 2 +} + +if ($Mode -eq 'Full' -and (-not $DryRun) -and (-not $ConfirmDestructive)) { + Write-Host "ERROR: -ConfirmDestructive is required for -Mode Full when -DryRun is not set. This prevents accidental destructive operations." -ForegroundColor Red + exit 2 +} + +# --------------------------------------------------------------------------- +# Path constants +# --------------------------------------------------------------------------- + +$appData = $env:APPDATA +$localAppData = $env:LOCALAPPDATA + +$setupStatePath = Join-Path $localAppData "OpenClawTray\setup-state.json" +$deviceKeyPath = Join-Path $appData "OpenClawTray\device-key-ed25519.json" +$mcpTokenPath = Join-Path $appData "OpenClawTray\mcp-token.txt" +$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" + +$autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" +$autoStartAppName = "OpenClawTray" + +# --------------------------------------------------------------------------- +# Output directory +# --------------------------------------------------------------------------- + +$utcStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssZ") +if ([string]::IsNullOrEmpty($OutputDir)) { + $OutputDir = Join-Path (Get-Location) "uninstall-validation-output\$utcStamp" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$preStatePath = Join-Path $OutputDir "pre-state.json" +$postStatePath = Join-Path $OutputDir "post-state.json" +$stepsPath = Join-Path $OutputDir "steps.json" +$verdictPath = Join-Path $OutputDir "verdict.json" +$summaryMdPath = Join-Path $OutputDir "summary.md" + +# --------------------------------------------------------------------------- +# Token redaction +# --------------------------------------------------------------------------- + +function Invoke-Redact { + param([string]$Content) + if ([string]::IsNullOrEmpty($Content)) { return $Content } + # Redact JSON fields containing key/token material. + $r = $Content -replace '("(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1$2' + # Redact bare key=value / key: value patterns. + $r = $r -replace '(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,"''}{]+', '$1' + return $r +} + +# --------------------------------------------------------------------------- +# WSL helpers +# --------------------------------------------------------------------------- + +function Get-WslDistroLines { + try { + $raw = & wsl --list --quiet 2>&1 + return ($raw | Out-String) -split "`r?`n" | + ForEach-Object { ($_ -replace '\x00', '').Trim() } | + Where-Object { $_ } + } catch { + return @() + } +} + +function Test-DistroRegistered { + param([string]$Name) + $lines = Get-WslDistroLines + return (@($lines | Where-Object { $_ -eq $Name })).Count -gt 0 +} + +# --------------------------------------------------------------------------- +# Process helpers +# --------------------------------------------------------------------------- + +function Get-WslKeepalivePids { + param([string]$Name) + $found = [System.Collections.Generic.List[int]]::new() + try { + $wslProcs = Get-CimInstance -ClassName Win32_Process -Filter "Name LIKE 'wsl%'" -ErrorAction SilentlyContinue + foreach ($proc in $wslProcs) { + $cmd = $proc.CommandLine + if ($cmd -and + $cmd -match [regex]::Escape($Name) -and + $cmd -match 'sleep\s+2147483647') { + $found.Add([int]$proc.ProcessId) + } + } + } catch { } + return $found.ToArray() +} + +function Get-OpenClawProcessSnapshot { + try { + $procs = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'OpenClaw*' } + return @($procs | ForEach-Object { + [ordered]@{ + pid = $_.Id + name = $_.Name + path = try { $_.MainModule.FileName } catch { '' } + } + }) + } catch { return @() } +} + +# --------------------------------------------------------------------------- +# Registry helpers +# --------------------------------------------------------------------------- + +function Test-AutoStartRegistryPresent { + try { + $val = Get-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -ErrorAction SilentlyContinue + return ($null -ne $val -and $null -ne $val.$autoStartAppName) + } catch { return $false } +} + +function Remove-AutoStartRegistryValue { + try { + $existing = Get-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -ErrorAction SilentlyContinue + if ($null -ne $existing) { + Remove-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -Force -ErrorAction Stop + return $true + } + return $false + } catch { + return $false + } +} + +# --------------------------------------------------------------------------- +# State snapshot +# --------------------------------------------------------------------------- + +function Get-StateSnapshot { + param([string]$Label) + + $allDistros = Get-WslDistroLines + $openClawDistros = @($allDistros | Where-Object { $_ -like '*OpenClaw*' }) + $registered = Test-DistroRegistered -Name $DistroName + + $snapshot = [ordered]@{ + captured_at = (Get-Date).ToString("o") + label = $Label + distro_name = $DistroName + distro_registered = $registered + wsl_distros_openclaw = $openClawDistros + autostart_registry = Test-AutoStartRegistryPresent + settings_autostart = $null + device_token_is_null = $null + files = [ordered]@{ + setup_state_exists = (Test-Path -LiteralPath $setupStatePath) + device_key_exists = (Test-Path -LiteralPath $deviceKeyPath) + mcp_token_exists = (Test-Path -LiteralPath $mcpTokenPath) + settings_exists = (Test-Path -LiteralPath $settingsPath) + exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) + vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) + } + processes_openclaw = @() + } + + # settings.AutoStart — read-only (no token content exposed) + if (Test-Path -LiteralPath $settingsPath) { + try { + $j = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + $snapshot.settings_autostart = if ($j.PSObject.Properties['AutoStart']) { [bool]$j.AutoStart } else { $null } + } catch { $snapshot.settings_autostart = '' } + } + + # DeviceToken null check — only reports bool, never exposes value + if (Test-Path -LiteralPath $deviceKeyPath) { + try { + $j = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 | ConvertFrom-Json + $dtVal = if ($j.PSObject.Properties['DeviceToken']) { $j.DeviceToken } else { $null } + $snapshot.device_token_is_null = [string]::IsNullOrEmpty($dtVal) + } catch { $snapshot.device_token_is_null = '' } + } else { + $snapshot.device_token_is_null = '' + } + + $snapshot.processes_openclaw = Get-OpenClawProcessSnapshot + + return $snapshot +} + +function Get-TrayExePath { + param([string]$Hint) + + # 1. Explicit hint from caller + if ($Hint -and (Test-Path -LiteralPath $Hint)) { return $Hint } + + # 2. Same directory as this script (Inno install layout: script is in {app}) + $candidate = Join-Path $PSScriptRoot 'OpenClaw.Tray.WinUI.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + + # 3. Parent directory (repo layout: scripts\ sibling of publish\) + $candidate = Join-Path (Split-Path $PSScriptRoot -Parent) 'publish\OpenClaw.Tray.WinUI.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + + return $null +} + +# --------------------------------------------------------------------------- +# Postcondition evaluation +# --------------------------------------------------------------------------- + +function Get-Postconditions { + # WSL distro absent? + $wslDistroAbsent = -not (Test-DistroRegistered -Name $DistroName) + + # Autostart cleared: registry absent AND settings.AutoStart == false + $regAbsent = -not (Test-AutoStartRegistryPresent) + $autoStartFalse = $false + if (Test-Path -LiteralPath $settingsPath) { + try { + $j = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + $autoStartFalse = if ($j.PSObject.Properties['AutoStart']) { -not [bool]$j.AutoStart } else { $true } + } catch { $autoStartFalse = $false } + } else { + # File absent means no auto-start configuration exists; treat as cleared. + $autoStartFalse = $true + } + $autostartCleared = $regAbsent -and $autoStartFalse + + # setup-state.json absent? + $setupStateAbsent = -not (Test-Path -LiteralPath $setupStatePath) + + # DeviceToken cleared: file absent OR DeviceToken null/empty + $deviceTokenCleared = $false + if (-not (Test-Path -LiteralPath $deviceKeyPath)) { + $deviceTokenCleared = $true + } else { + try { + $j = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 | ConvertFrom-Json + $dtVal = if ($j.PSObject.Properties['DeviceToken']) { $j.DeviceToken } else { $null } + $deviceTokenCleared = [string]::IsNullOrEmpty($dtVal) + } catch { $deviceTokenCleared = $false } + } + + # device-key-ed25519.json preserved (file itself must NOT have been deleted per v3 §C). + # If the machine never ran setup the file may legitimately be absent; + # in that case we report absent but do not fail (it can't be "preserved" if it never existed). + $deviceKeyFilePreserved = Test-Path -LiteralPath $deviceKeyPath + + # mcp-token.txt preserved (never touched — any state is correct by definition per v3 §F). + $mcpTokenPreserved = $true + + # No OpenClaw keepalive processes running. + $keepalivePids = @(Get-WslKeepalivePids -Name $DistroName) + $keepalivesAbsent = ($keepalivePids.Count -eq 0) + + return [ordered]@{ + wsl_distro_absent = $wslDistroAbsent + autostart_cleared = $autostartCleared + setup_state_absent = $setupStateAbsent + device_token_cleared = $deviceTokenCleared + device_key_file_preserved = $deviceKeyFilePreserved + mcp_token_preserved = $mcpTokenPreserved + keepalives_absent = $keepalivesAbsent + vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath)) + } +} + +function Get-Verdict { + param( + [System.Collections.Specialized.OrderedDictionary]$Postconditions, + [string[]]$Errors + ) + + # 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') + $failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true }) + $errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count } + + if ($failedKeys.Count -eq 0 -and $errCount -eq 0) { return 'PASS' } + if ($failedKeys.Count -eq $required.Count) { return 'FAIL' } + return 'PARTIAL' +} + +# --------------------------------------------------------------------------- +# 13-step uninstall execution (PowerShell mirror of LocalGatewayUninstall.cs) +# --------------------------------------------------------------------------- +# IMPORTANT: This sequence MUST stay aligned with: +# src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs +# The C# engine remains the production code path. +# Any divergence found here should be filed as a commit-5 integration gap. +# --------------------------------------------------------------------------- + +function Invoke-UninstallSteps { + param([bool]$IsDryRun) + + $stepErrors = [System.Collections.Generic.List[string]]::new() + + # ------------------------------------------------------------------ Step 1 + # Preflight gate + # ------------------------------------------------------------------ Step 1 + if ($IsDryRun) { + Add-Step -Name 'preflight-gate' -Status 'DryRun' ` + -Message 'DryRun=true; no destructive changes will be made.' + } else { + Add-Step -Name 'preflight-gate' -Status 'Executed' ` + -Message 'ConfirmDestructive=true; proceeding with live uninstall.' + } + + # ------------------------------------------------------------------ Step 2 + # Stop WSL keepalive process + # ------------------------------------------------------------------ Step 2 + try { + $kPids = @(Get-WslKeepalivePids -Name $DistroName) + if ($kPids.Count -eq 0) { + Add-Step -Name 'stop-keepalive-process' -Status 'Skipped' ` + -Message "No WSL keepalive process found for '$DistroName'." + } elseif ($IsDryRun) { + Add-Step -Name 'stop-keepalive-process' -Status 'DryRun' ` + -Message "Would stop $($kPids.Count) keepalive PID(s): $($kPids -join ', ')." + } else { + $stopped = 0 + foreach ($pid in $kPids) { + try { + Stop-OpenClawProcessByPid -ProcessId $pid -Force + $stopped++ + } catch { + $stepErrors.Add("stop-keepalive-process PID $($pid): $($_.Exception.Message)") + } + } + Add-Step -Name 'stop-keepalive-process' -Status 'Executed' ` + -Message "Stopped $stopped / $($kPids.Count) keepalive process(es)." + } + } catch { + $stepErrors.Add("stop-keepalive-process: $($_.Exception.Message)") + Add-Step -Name 'stop-keepalive-process' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 3 + # Stop systemd gateway service inside WSL + # ------------------------------------------------------------------ Step 3 + try { + if (-not (Test-DistroRegistered -Name $DistroName)) { + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Skipped' ` + -Message "Distro '$DistroName' not registered." + } elseif ($IsDryRun) { + Add-Step -Name 'stop-systemd-gateway-service' -Status 'DryRun' ` + -Message "Would run: wsl -d $DistroName bash -c 'sudo systemctl stop openclaw-gateway 2>&1 || true'" + } else { + $r = Invoke-WslCommand -Command 'sudo systemctl stop openclaw-gateway 2>&1 || true' -DistroName $DistroName + $detail = ($r.Stdout + ' ' + $r.Stderr).Trim() + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Executed' ` + -Message (if ($detail) { $detail } else { 'Service stop command issued.' }) + } + } catch { + $stepErrors.Add("stop-systemd-gateway-service: $($_.Exception.Message)") + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 4 + # Revoke operator token — best-effort HTTP call; gateway may already be down. + # ------------------------------------------------------------------ Step 4 + try { + $hasToken = $false + $gwUrl = 'ws://localhost:18789' + if (Test-Path -LiteralPath $settingsPath) { + $settingsJson = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($settingsJson.PSObject.Properties['Token']) { + $hasToken = -not [string]::IsNullOrWhiteSpace($settingsJson.Token) + } + if ($settingsJson.PSObject.Properties['GatewayUrl']) { + $gwUrl = $settingsJson.GatewayUrl + } + } + + if (-not $hasToken) { + Add-Step -Name 'revoke-operator-token' -Status 'Skipped' ` + -Message 'No operator token in settings.json.' + } elseif ($IsDryRun) { + Add-Step -Name 'revoke-operator-token' -Status 'DryRun' ` + -Message 'Would POST to /api/v1/operator/disconnect. Token: ***REDACTED***' + } else { + $httpBase = ($gwUrl -replace '^ws://', 'http://' -replace '^wss://', 'https://').TrimEnd('/') + $token = $settingsJson.Token + try { + $resp = Invoke-WebRequest -Uri "$httpBase/api/v1/operator/disconnect" -Method Post ` + -Headers @{ Authorization = "Bearer $token" } ` + -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop + Add-Step -Name 'revoke-operator-token' -Status 'Executed' ` + -Message "Response: $([int]$resp.StatusCode). Token: ***REDACTED***" + } catch { + # Gateway likely already down — absorb and continue. + Add-Step -Name 'revoke-operator-token' -Status 'Executed' ` + -Message "Best-effort revoke failed ($($_.Exception.GetType().Name)); gateway likely down. Token: ***REDACTED***" + } + } + } catch { + $stepErrors.Add("revoke-operator-token: $($_.Exception.Message)") + Add-Step -Name 'revoke-operator-token' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 5 + # Unregister WSL distro. + # Safety guard: only "OpenClawGateway" may be unregistered (mirrors C# AllowedDistroName). + # wsl --terminate first to cleanly stop the distro before unregistering. + # ------------------------------------------------------------------ Step 5 + try { + if ($DistroName -ne 'OpenClawGateway') { + $guard = "Refused to unregister '$DistroName': only 'OpenClawGateway' is allowed." + $stepErrors.Add($guard) + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $guard + } elseif (-not (Test-DistroRegistered -Name $DistroName)) { + Add-Step -Name 'unregister-wsl-distro' -Status 'Skipped' ` + -Message "Distro '$DistroName' not registered." + } elseif ($IsDryRun) { + Add-Step -Name 'unregister-wsl-distro' -Status 'DryRun' ` + -Message "Would run: wsl --terminate $DistroName && wsl --unregister $DistroName" + } else { + & wsl --terminate $DistroName 2>&1 | Out-Null + & wsl --unregister $DistroName 2>&1 | Out-Null + $ec = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + if ($ec -eq 0 -or $ec -eq 1) { + Add-Step -Name 'unregister-wsl-distro' -Status 'Executed' ` + -Message "wsl --unregister completed (exit $ec)." + } else { + $msg = "wsl --unregister exited $ec." + $stepErrors.Add($msg) + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $msg + } + } + } catch { + $stepErrors.Add("unregister-wsl-distro: $($_.Exception.Message)") + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 6 + # Reset autostart. + # CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting the registry value. + # ------------------------------------------------------------------ Step 6 + try { + if ($IsDryRun) { + Add-Step -Name 'reset-autostart' -Status 'DryRun' ` + -Message "Would set settings.AutoStart=false and save, then delete HKCU\...\Run\$autoStartAppName." + } else { + # 6a — Update settings.json (AutoStart=false) FIRST. + if (Test-Path -LiteralPath $settingsPath) { + $sRaw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + $sObj = $sRaw | ConvertFrom-Json + $sObj.AutoStart = $false + $sObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 + Add-Step -Name 'persist-settings-autostart-false' -Status 'Executed' ` + -Message 'AutoStart=false persisted to settings.json.' + } else { + Add-Step -Name 'persist-settings-autostart-false' -Status 'Skipped' ` + -Message 'settings.json not found; no AutoStart field to update.' + } + + # 6b — Delete registry Run value SECOND (idempotent per v3 §B). + $removed = Remove-AutoStartRegistryValue + if ($removed) { + Add-Step -Name 'delete-autostart-registry' -Status 'Executed' ` + -Message "Deleted HKCU\...\Run\$autoStartAppName." + } else { + Add-Step -Name 'delete-autostart-registry' -Status 'Skipped' ` + -Message "Registry value '$autoStartAppName' was not present." + } + } + } catch { + $stepErrors.Add("reset-autostart: $($_.Exception.Message)") + Add-Step -Name 'reset-autostart' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 7 + # Null DeviceToken field — preserve the file per v3 §C. + # ------------------------------------------------------------------ Step 7 + try { + if (-not (Test-Path -LiteralPath $deviceKeyPath)) { + Add-Step -Name 'null-device-token' -Status 'Skipped' ` + -Message 'device-key-ed25519.json not found; nothing to null.' + } elseif ($IsDryRun) { + Add-Step -Name 'null-device-token' -Status 'DryRun' ` + -Message 'Would null DeviceToken field; keypair file preserved. Value: ***REDACTED***' + } else { + $kRaw = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 + $kObj = $kRaw | ConvertFrom-Json + $dtVal = if ($kObj.PSObject.Properties['DeviceToken']) { $kObj.DeviceToken } else { $null } + if (-not [string]::IsNullOrEmpty($dtVal)) { + $kObj.DeviceToken = $null + if ($kObj.PSObject.Properties['DeviceTokenScopes']) { $kObj.DeviceTokenScopes = $null } + $kObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $deviceKeyPath -Encoding UTF8 + Add-Step -Name 'null-device-token' -Status 'Executed' ` + -Message 'DeviceToken set to null; keypair file preserved. Value: ***REDACTED***' + } else { + Add-Step -Name 'null-device-token' -Status 'Skipped' ` + -Message 'DeviceToken already null or absent; file preserved.' + } + } + } catch { + $stepErrors.Add("null-device-token: $($_.Exception.Message)") + Add-Step -Name 'null-device-token' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 8 + # Delete setup-state.json + # ------------------------------------------------------------------ Step 8 + try { + if (-not (Test-Path -LiteralPath $setupStatePath)) { + Add-Step -Name 'delete-setup-state' -Status 'Skipped' -Message 'setup-state.json not found.' + } elseif ($IsDryRun) { + Add-Step -Name 'delete-setup-state' -Status 'DryRun' ` + -Message "Would delete: $setupStatePath" + } else { + Remove-Item -LiteralPath $setupStatePath -Force + Add-Step -Name 'delete-setup-state' -Status 'Executed' ` + -Message "Deleted $setupStatePath." + } + } catch { + $stepErrors.Add("delete-setup-state: $($_.Exception.Message)") + Add-Step -Name 'delete-setup-state' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 9 + # Delete gateway logs (unless PreserveLogs=true). + # ------------------------------------------------------------------ Step 9 + try { + if ($PreserveLogs) { + Add-Step -Name 'delete-gateway-logs' -Status 'Skipped' -Message 'PreserveLogs=true.' + } elseif (-not (Test-Path -LiteralPath $logsDir)) { + Add-Step -Name 'delete-gateway-logs' -Status 'Skipped' ` + -Message "Logs directory not found: $logsDir" + } elseif ($IsDryRun) { + $cnt = (Get-ChildItem -LiteralPath $logsDir -File -ErrorAction SilentlyContinue).Count + Add-Step -Name 'delete-gateway-logs' -Status 'DryRun' ` + -Message "Would delete $cnt file(s) matching *.log / crash.log in $logsDir." + } else { + $logFiles = Get-ChildItem -LiteralPath $logsDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -eq '.log' -or $_.Name -eq 'crash.log' } + $deleted = 0 + foreach ($f in $logFiles) { + try { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop; $deleted++ } catch { } + } + Add-Step -Name 'delete-gateway-logs' -Status 'Executed' ` + -Message "Deleted $deleted log file(s)." + } + } catch { + $stepErrors.Add("delete-gateway-logs: $($_.Exception.Message)") + Add-Step -Name 'delete-gateway-logs' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 10 + # Delete exec-policy.json (unless PreserveExecPolicy=true). + # ----------------------------------------------------------------- Step 10 + try { + if ($PreserveExecPolicy) { + Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'PreserveExecPolicy=true.' + } elseif (-not (Test-Path -LiteralPath $execPolicyPath)) { + Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'exec-policy.json not found.' + } elseif ($IsDryRun) { + Add-Step -Name 'delete-exec-policy' -Status 'DryRun' ` + -Message "Would delete: $execPolicyPath" + } else { + Remove-Item -LiteralPath $execPolicyPath -Force + Add-Step -Name 'delete-exec-policy' -Status 'Executed' ` + -Message "Deleted $execPolicyPath." + } + } catch { + $stepErrors.Add("delete-exec-policy: $($_.Exception.Message)") + Add-Step -Name 'delete-exec-policy' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 11 + # Reset onboarding settings: Token, BootstrapToken, GatewayUrl. + # EnableMcpServer is deliberately left as-is (v3 §F / Q-M): + # it controls whether RequiresSetup fires post-uninstall. + # ----------------------------------------------------------------- Step 11 + try { + if ($IsDryRun) { + Add-Step -Name 'reset-onboarding-settings' -Status 'DryRun' ` + -Message "Would reset Token='', BootstrapToken='', GatewayUrl='ws://localhost:18789'. EnableMcpServer preserved. Tokens: ***REDACTED***" + } elseif (-not (Test-Path -LiteralPath $settingsPath)) { + Add-Step -Name 'reset-onboarding-settings' -Status 'Skipped' ` + -Message 'settings.json not found.' + } else { + $sRaw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + $sObj = $sRaw | ConvertFrom-Json + foreach ($field in @('Token', 'BootstrapToken')) { + if ($sObj.PSObject.Properties[$field]) { $sObj.$field = '' } + } + if ($sObj.PSObject.Properties['GatewayUrl']) { + $sObj.GatewayUrl = 'ws://localhost:18789' + } + # EnableMcpServer: NOT touched. + $sObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 + Add-Step -Name 'reset-onboarding-settings' -Status 'Executed' ` + -Message 'Token=***REDACTED***, BootstrapToken=***REDACTED***, GatewayUrl reset. EnableMcpServer preserved.' + } + } catch { + $stepErrors.Add("reset-onboarding-settings: $($_.Exception.Message)") + Add-Step -Name 'reset-onboarding-settings' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 12 + # Preserve mcp-token.txt — no-op; logged for audit clarity. + # Per v3 §F: mcp-token is not a gateway artifact and is NEVER deleted. + # ----------------------------------------------------------------- Step 12 + Add-Step -Name 'preserve-mcp-token' -Status 'Skipped' ` + -Message 'mcp-token.txt preserved unconditionally (v3 §F). Not a gateway artifact.' + + return $stepErrors.ToArray() +} + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +function Write-SummaryMarkdown { + param([System.Collections.Specialized.OrderedDictionary]$VerdictData) + + $lines = @( + "# OpenClaw WSL Gateway Uninstall Validation", + "", + "| Field | Value |", + "|-------------|-------|", + "| Mode | $Mode |", + "| DistroName | $DistroName |", + "| DryRun | $($DryRun.IsPresent) |", + "| Verdict | $($VerdictData.verdict) |", + "| StartedAt | $($VerdictData.started_at) |", + "| FinishedAt | $($VerdictData.finished_at) |", + "| OutputDir | $OutputDir |", + "" + ) + + if ($VerdictData.postconditions -and $VerdictData.postconditions.Count -gt 0) { + $lines += "## Postconditions", "" + foreach ($key in $VerdictData.postconditions.Keys) { + $val = $VerdictData.postconditions[$key] + $icon = if ($val -eq $true) { 'PASS' } elseif ($val -eq $false) { 'FAIL' } else { 'INFO' } + $lines += "- [$icon] $key : $val" + } + $lines += "" + } + + if ($VerdictData.errors -and $VerdictData.errors.Count -gt 0) { + $lines += "## Errors", "" + foreach ($e in $VerdictData.errors) { $lines += "- $e" } + $lines += "" + } + + if ($script:steps.Count -gt 0) { + $lines += "## Steps", "" + foreach ($step in $script:steps) { + $lines += "- [$($step.status)] $($step.name): $($step.message)" + } + $lines += "" + } + + $lines | Set-Content -LiteralPath $summaryMdPath -Encoding UTF8 +} + +function Write-ColorVerdict { + param([string]$Verdict) + + $color = switch ($Verdict) { + 'PASS' { 'Green' } + 'DryRunComplete' { 'Cyan' } + 'AlreadyClean' { 'Cyan' } + 'PreflightOnly' { 'Cyan' } + 'PARTIAL' { 'Yellow' } + 'FAIL' { 'Red' } + 'ERROR' { 'Red' } + default { 'White' } + } + + Write-Host "" + Write-Host "════════════════════════════════════════════" -ForegroundColor $color + Write-Host " VERDICT : $Verdict" -ForegroundColor $color + Write-Host " Output : $OutputDir" -ForegroundColor $color + Write-Host "════════════════════════════════════════════" -ForegroundColor $color + Write-Host "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +$verdictData = [ordered]@{ + mode = $Mode + dry_run = $DryRun.IsPresent + started_at = (Get-Date).ToString("o") + finished_at = $null + distro_name = $DistroName + preflight_passed = $false + destruction_executed = $false + postconditions = [ordered]@{} + verdict = 'UNKNOWN' + errors = @() +} + +$exitCode = 0 + +try { + switch ($Mode) { + + # =================================================================== + 'PreflightOnly' { + # =================================================================== + $registered = Test-DistroRegistered -Name $DistroName + + if (-not $registered) { + Add-Step -Name 'distro-check' -Status 'Passed' ` + -Message "Distro '$DistroName' is not registered — AlreadyClean." + $verdictData.preflight_passed = $true + $verdictData.verdict = 'AlreadyClean' + Write-Host "AlreadyClean: '$DistroName' not registered. Nothing to uninstall." -ForegroundColor Cyan + } else { + Add-Step -Name 'distro-check' -Status 'Passed' ` + -Message "Distro '$DistroName' is registered." + + $preState = Get-StateSnapshot -Label 'preflight' + $preState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $preStatePath -Encoding UTF8 + Add-Step -Name 'pre-state-snapshot' -Status 'Completed' ` + -Message "Pre-state snapshot written to: $preStatePath" + + $verdictData.preflight_passed = $true + $verdictData.verdict = 'PreflightOnly' + Write-Host "PreflightOnly: '$DistroName' registered. Pre-state: $preStatePath" -ForegroundColor Green + } + $exitCode = 0 + } + + # =================================================================== + 'Full' { + # =================================================================== + # Pre-state snapshot. + $preState = Get-StateSnapshot -Label 'pre-uninstall' + $preState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $preStatePath -Encoding UTF8 + Add-Step -Name 'pre-state-snapshot' -Status 'Completed' ` + -Message "Pre-state snapshot written to: $preStatePath" + $verdictData.preflight_passed = $true + + if (-not $preState.distro_registered) { + Write-Host "Note: '$DistroName' is not registered. Postcondition evaluation may show AlreadyClean." ` + -ForegroundColor Yellow + } + + $isDryRun = $DryRun.IsPresent + $stepErrors = @() + + # ---------------------------------------------------------------- + # CLI delegate path (default) — eliminates engine/script drift + # -NoCli falls back to the inline PS replication below. + # ---------------------------------------------------------------- + $trayExe = Get-TrayExePath -Hint $ExePath + $useCliPath = (-not $NoCli.IsPresent) -and ($null -ne $trayExe) + + if ($useCliPath) { + Write-Host "Delegating to CLI: $trayExe" -ForegroundColor Cyan + + $cliJsonPath = Join-Path $OutputDir 'uninstall-result.json' + $cliArgs = @('--uninstall', '--json-output', $cliJsonPath) + + if ($isDryRun) { $cliArgs += '--dry-run' } + if (-not $isDryRun) { $cliArgs += '--confirm-destructive' } + + try { + & $trayExe @cliArgs + $cliExitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + + Add-Step -Name 'cli-uninstall-delegate' -Status 'Completed' ` + -Message "Exit code: $cliExitCode. JSON: $cliJsonPath" + $verdictData.destruction_executed = -not $isDryRun + + # Merge CLI result JSON into step audit trail + if (Test-Path -LiteralPath $cliJsonPath) { + try { + $cliResult = Get-Content -LiteralPath $cliJsonPath -Raw | ConvertFrom-Json + foreach ($step in $cliResult.steps) { + Add-Step -Name $step.name -Status $step.status -Message $step.detail + } + if ($cliResult.errors.Count -gt 0) { + $stepErrors = @($cliResult.errors) + } + # Persist CLI step JSON as the primary step audit trail + $cliJsonPath | Out-Null # already written to OutputDir + } catch { + $stepErrors += "cli-result-parse: $($_.Exception.Message)" + } + } + + if ($cliExitCode -ne 0) { + $stepErrors += "CLI exited $cliExitCode" + } + } catch { + $stepErrors += "cli-delegate: $($_.Exception.Message)" + Add-Step -Name 'cli-uninstall-delegate' -Status 'Failed' ` + -Message $_.Exception.Message + } + + } else { + # ---------------------------------------------------------------- + # Inline PS replication (diagnostic fallback / -NoCli) + # ---------------------------------------------------------------- + if ($NoCli.IsPresent) { + Write-Host "Inline PS replication (-NoCli)." -ForegroundColor Yellow + } else { + Write-Host "OpenClawTray.exe not found; falling back to inline PS replication." ` + -ForegroundColor Yellow + } + + $stepErrors = Invoke-UninstallSteps -IsDryRun $isDryRun + $verdictData.destruction_executed = -not $isDryRun + } + + $verdictData.errors = @($stepErrors) + + # Save step audit trail. + $script:steps | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $stepsPath -Encoding UTF8 + + # Post-state snapshot. + $postState = Get-StateSnapshot -Label 'post-uninstall' + $postState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $postStatePath -Encoding UTF8 + Add-Step -Name 'post-state-snapshot' -Status 'Completed' ` + -Message "Post-state snapshot written to: $postStatePath" + + # Postconditions + verdict. + if ($isDryRun) { + $verdictData.verdict = 'DryRunComplete' + $exitCode = 0 + Write-Host "DryRun complete. No state was mutated." -ForegroundColor Cyan + } else { + $postconditions = Get-Postconditions + $verdictData.postconditions = $postconditions + $verdict = Get-Verdict -Postconditions $postconditions -Errors $stepErrors + $verdictData.verdict = $verdict + $exitCode = if ($verdict -eq 'PASS') { 0 } else { 1 } + } + } + + # =================================================================== + 'PostconditionOnly' { + # =================================================================== + Add-Step -Name 'postcondition-check' -Status 'Running' ` + -Message 'Evaluating current state against uninstall-complete expectations.' + + $postconditions = Get-Postconditions + $verdictData.postconditions = $postconditions + $verdict = Get-Verdict -Postconditions $postconditions -Errors @() + $verdictData.verdict = $verdict + $exitCode = if ($verdict -eq 'PASS') { 0 } else { 1 } + + Add-Step -Name 'postcondition-check' -Status 'Completed' ` + -Message "Verdict: $verdict." + } + } + +} catch { + $verdictData.verdict = 'ERROR' + $verdictData.errors = @($verdictData.errors) + @($_.Exception.Message) + Add-Step -Name 'execution-error' -Status 'Failed' -Message $_.Exception.Message + $stackTrace = if ($_.ScriptStackTrace) { $_.ScriptStackTrace } else { '' } + Write-Host "ERROR: Unhandled error in -Mode $Mode : $($_.Exception.Message)" -ForegroundColor Red + Write-Host "Stack: $stackTrace" -ForegroundColor Red + $exitCode = 3 + +} finally { + $verdictData.finished_at = (Get-Date).ToString("o") + + # Write verdict.json (always, even on error). + $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + + # Write summary.md. + Write-SummaryMarkdown -VerdictData $verdictData + + Write-Host "Verdict JSON : $verdictPath" + Write-Host "Summary MD : $summaryMdPath" +} + +Write-ColorVerdict -Verdict $verdictData.verdict +exit $exitCode diff --git a/scripts/validate-wsl-gateway.ps1 b/scripts/validate-wsl-gateway.ps1 new file mode 100644 index 000000000..75e0f1ee0 --- /dev/null +++ b/scripts/validate-wsl-gateway.ps1 @@ -0,0 +1,947 @@ +<# +.SYNOPSIS + Validate the OpenClaw WSL gateway local-setup product code path end-to-end. + +.DESCRIPTION + Phase 6 clean port. Drives the WinUI3 tray app from launch through the + forked onboarding (SetupWarningPage -> "Set up locally" -> LocalSetupProgressPage) + so the *product* code path that runs + + wsl --install Ubuntu-24.04 --name OpenClawGateway --location --no-launch --version 2 + + is exercised end-to-end. The script does NOT install WSL itself and does NOT + invoke `wsl --install` directly: it expects the tray engine to do that and + only verifies the postcondition. + + Networking diagnostics are loopback-only. There is no WSL-IP / lan / auto + fallback. Token / setup-code / private-key material is redacted in artifacts. + +.PARAMETER Scenario + PreflightOnly - Repo layout + WSL host status + relay probe (safe; no install). + UpstreamInstall - Build/test, drive tray onboarding to install OpenClawGateway, + run smoke + pairing proofs. Reuses an existing distro if present. + FreshMachine - Like UpstreamInstall, but unregisters any existing + OpenClawGateway distro first (simulates a clean machine). + Recreate - Iterated FreshMachine (unregister between runs). Use `-Iterations`. + +.NOTES + Diagnostics on networking/lifecycle health failures point operators at + https://aka.ms/wsllogs (per Craig). + + File I/O against WSL is via `wsl bash -c` only. NEVER \\wsl$ / \\wsl.localhost. +#> +[CmdletBinding()] +param( + [ValidateSet("PreflightOnly", "UpstreamInstall", "FreshMachine", "Recreate")] + [string]$Scenario = "PreflightOnly", + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation"), + [int]$Iterations = 1, + [switch]$ConfirmDestructiveClean, + [switch]$KeepFailedDistro, + [bool]$CleanupAfterSuccess = $true, + [switch]$ContinueOnCleanupFailure, + [switch]$NoBuild, + [int]$TimeoutSeconds = 600, + [string]$DistroName = "OpenClawGateway", + [string]$GatewayUrl = "ws://127.0.0.1:18789", + [string]$RelayProbeUri, + [switch]$RequireRelayProbe, + [switch]$RequireRealGatewayBootstrap, + [switch]$RequireOperatorPairing, + [switch]$RequireWindowsNodePairing, + [switch]$ContinueOnFailure +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$runStamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runRoot = Join-Path $OutputDir $runStamp +$commandsRoot = Join-Path $runRoot "commands" +$screenshotsRoot = Join-Path $runRoot "screenshots" +$summaryPath = Join-Path $runRoot "summary.json" +$summaryMarkdownPath = Join-Path $runRoot "summary.md" +$trayProject = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$runtimeIdentifier = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "win-arm64" } else { "win-x64" } +$trayTfm = ([xml](Get-Content -LiteralPath $trayProject -Raw)).Project.PropertyGroup | + ForEach-Object { $_.TargetFramework } | + Where-Object { $_ } | + Select-Object -First 1 +if (-not $trayTfm) { throw "Could not derive TargetFramework from $trayProject." } +$trayExe = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\bin\Debug\$trayTfm\$runtimeIdentifier\OpenClaw.Tray.WinUI.exe" +$cliProject = Join-Path $repoRoot "src\OpenClaw.Cli\OpenClaw.Cli.csproj" + +# Always isolate AppData under run root for non-Preflight scenarios so we never +# trample the operator's real Windows tray identity. +$validationAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:APPDATA } else { Join-Path $runRoot "isolated\appdata" } +$validationLocalAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:LOCALAPPDATA } else { Join-Path $runRoot "isolated\localappdata" } +$setupStatePath = Join-Path $validationLocalAppDataRoot "OpenClawTray\setup-state.json" +$settingsPath = Join-Path $validationAppDataRoot "settings.json" +$wslInstallLocation = Join-Path $runRoot "wsl\$DistroName" + +$script:summary = [ordered]@{ + script = "validate-wsl-gateway" + scenario = $Scenario + startedAt = (Get-Date).ToString("o") + finishedAt = $null + status = "Running" + validationStatus = "Running" + cleanupStatus = "NotStarted" + repository = $repoRoot.Path + outputDir = $runRoot + networkingMode = "LocalhostOnly" + activeDistroName = $DistroName + activeInstallLocation = $wslInstallLocation + selectedGatewayUrl = $GatewayUrl + pairingValidation = [ordered]@{ + gatewayImplementation = "Unknown" + bootstrapQrShape = "Unknown" + realUpstreamBootstrapHandoff = $false + operatorPaired = $false + windowsNodePaired = $false + } + setupPhases = @() + iterations = @() + steps = @() + error = $null +} + +function Add-Step { + param([string]$Name, [string]$Status, [string]$Message, [hashtable]$Data = @{}) + $script:summary.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Test-IsOpenClawOwnedDistroName { + param([string]$Name) + return $Name -eq "OpenClawGateway" +} + +function Assert-DestructiveSafety { + if ($Scenario -in @("FreshMachine", "Recreate") -and -not $ConfirmDestructiveClean) { + throw "-ConfirmDestructiveClean is required when -Scenario is $Scenario (will unregister WSL distro '$DistroName')." + } + if ($Scenario -in @("FreshMachine", "Recreate") -and -not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { + throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must be exactly 'OpenClawGateway'." + } +} + +function Get-SafeUriDisplay { + param([string]$Uri) + try { + $b = [System.UriBuilder]::new($Uri) + $b.Query = $null; $b.Fragment = $null + return $b.Uri.AbsoluteUri + } catch { + return "" + } +} + +function Write-Summary { + New-Item -ItemType Directory -Force -Path $runRoot | Out-Null + $script:summary.finishedAt = (Get-Date).ToString("o") + $script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + + $lines = @( + "# OpenClaw WSL gateway validation", + "", + "- Scenario: $Scenario", + "- Status: $($script:summary.status)", + "- Validation: $($script:summary.validationStatus)", + "- Cleanup: $($script:summary.cleanupStatus)", + "- Networking mode: LocalhostOnly (loopback only)", + "- Started: $($script:summary.startedAt)", + "- Finished: $($script:summary.finishedAt)", + "- Output: $runRoot", + "", + "## Steps" + ) + foreach ($step in $script:summary.steps) { + $lines += "- $($step.status): $($step.name) - $($step.message)" + } + if ($script:summary.error) { + $lines += "", "## Error", $script:summary.error + $lines += "", "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." + } + $lines | Set-Content -LiteralPath $summaryMarkdownPath -Encoding UTF8 +} + +function Redact-SensitiveGatewayOutput { + param([string]$Content) + if ([string]::IsNullOrEmpty($Content)) { return $Content } + $r = $Content -replace '("(?:bootstrapToken|bootstrap_token|deviceToken|device_token|token|setupCode|setup_code|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1$2' + $r = $r -replace '(?i)((?:bootstrap|device|gateway|auth)[_-]?token\s*[:=]\s*)[^\s,"''}]+', '$1' + return $r +} + +function Read-TextFileWithRetry { + param([string]$Path, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Write-TextFileWithRetry { + param([string]$Path, [string]$Content, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { $Content | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop ; return } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Copy-RedactedFileIfExists { + param([string]$SourcePath, [string]$DestinationPath) + if (-not (Test-Path -LiteralPath $SourcePath)) { return $false } + $content = Read-TextFileWithRetry -Path $SourcePath + Write-TextFileWithRetry -Path $DestinationPath -Content (Redact-SensitiveGatewayOutput $content) + return $true +} + +function Invoke-LoggedProcess { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = $repoRoot.Path, + [hashtable]$Environment = @{}, + [switch]$IgnoreExitCode, + [switch]$SensitiveOutput + ) + + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + $safe = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $commandsRoot "$safe.stdout.txt" + $stderr = Join-Path $commandsRoot "$safe.stderr.txt" + $saved = @{} + foreach ($k in $Environment.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$Environment[$k], "Process") + } + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } finally { + Pop-Location + foreach ($k in $Environment.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } + + if ($SensitiveOutput) { + foreach ($p in @($stdout, $stderr)) { + if (Test-Path -LiteralPath $p) { + $c = Read-TextFileWithRetry -Path $p -Attempts 20 -DelayMilliseconds 250 + Write-TextFileWithRetry -Path $p -Content (Redact-SensitiveGatewayOutput $c) -Attempts 20 -DelayMilliseconds 250 + } + } + } + + Add-Step $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath; arguments = ($ArgumentList -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Invoke-LoggedPowerShellScript { + param([string]$Name, [string]$ScriptPath, [string[]]$ArgumentList = @()) + $hostExe = if ($PSHOME -and (Test-Path (Join-Path $PSHOME "pwsh.exe"))) { Join-Path $PSHOME "pwsh.exe" } else { "powershell.exe" } + $args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $ScriptPath) + $ArgumentList + Invoke-LoggedProcess -Name $Name -FilePath $hostExe -ArgumentList $args +} + +function Invoke-RepositoryValidation { + if ($NoBuild) { + Add-Step "repository-validation" "Skipped" "Skipped build and tests because -NoBuild was set." + return + } + Invoke-LoggedPowerShellScript "build" (Join-Path $repoRoot "build.ps1") + Invoke-LoggedProcess "test-shared" "dotnet" @("test", ".\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj", "--no-restore") + Invoke-LoggedProcess "test-tray" "dotnet" @("test", ".\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj", "--no-restore") +} + +function Invoke-Preflight { + Invoke-LoggedProcess "dotnet-info" "dotnet" @("--info") -IgnoreExitCode + Invoke-LoggedProcess "wsl-status" "wsl.exe" @("--status") -IgnoreExitCode + Invoke-LoggedProcess "wsl-list-before" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + + if (-not (Test-Path -LiteralPath $trayProject)) { throw "Tray project not found: $trayProject" } + if (-not (Test-Path -LiteralPath $cliProject)) { throw "CLI project not found: $cliProject" } + Add-Step "repo-layout" "Passed" "Required projects are present." + + Invoke-RelayPrototypeProbe +} + +function Invoke-RelayPrototypeProbe { + $probeUri = if (-not [string]::IsNullOrWhiteSpace($RelayProbeUri)) { $RelayProbeUri } else { [Environment]::GetEnvironmentVariable("OPENCLAW_RELAY_PROBE_URI", "Process") } + if ([string]::IsNullOrWhiteSpace($probeUri)) { + $msg = "No relay probe endpoint was supplied. Set -RelayProbeUri or OPENCLAW_RELAY_PROBE_URI." + if ($RequireRelayProbe) { throw "RelayProbeMissing: $msg" } + Add-Step "relay-prototype-probe" "NotAvailable" $msg + return + } + $relayPath = Join-Path $commandsRoot "relay-prototype-probe.txt" + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + try { + $r = Invoke-WebRequest -Uri $probeUri -TimeoutSec 15 -UseBasicParsing + $body = if ($null -ne $r.Content) { $r.Content } else { "" } + $body = $body -replace '(?i)(token=)[^&\s]+', '$1' + $body | Set-Content -LiteralPath $relayPath -Encoding UTF8 + Add-Step "relay-prototype-probe" "Passed" "Relay probe endpoint responded." @{ + uri = (Get-SafeUriDisplay $probeUri); statusCode = [int]$r.StatusCode; path = $relayPath + } + } catch { + throw "RelayProbeFailed: relay probe failed for $(Get-SafeUriDisplay $probeUri): $($_.Exception.Message)" + } +} + +function Get-LatestScreenshotPath { + if (-not (Test-Path -LiteralPath $screenshotsRoot)) { return $null } + $latest = Get-ChildItem -LiteralPath $screenshotsRoot -Filter "*.png" -File -Recurse | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -eq $latest) { return $null } + return $latest.FullName +} + +function Save-DiagnosticsSnapshot { + param([string]$Reason) + $diag = Join-Path $runRoot "diagnostics" + New-Item -ItemType Directory -Force -Path $diag | Out-Null + + if (Test-Path -LiteralPath $setupStatePath) { + Copy-RedactedFileIfExists -SourcePath $setupStatePath -DestinationPath (Join-Path $diag "setup-state.redacted.json") | Out-Null + } + if (Test-Path -LiteralPath $settingsPath) { + Copy-RedactedFileIfExists -SourcePath $settingsPath -DestinationPath (Join-Path $diag "settings.redacted.json") | Out-Null + } + $identityPath = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (Test-Path -LiteralPath $identityPath) { + Copy-RedactedFileIfExists -SourcePath $identityPath -DestinationPath (Join-Path $diag "device-key.shape.redacted.json") | Out-Null + } + + Add-Step "diagnostics-snapshot" "Completed" "Saved diagnostics snapshot for $Reason. See https://aka.ms/wsllogs for WSL networking/lifecycle logs." @{ + path = $diag + latestScreenshot = (Get-LatestScreenshotPath) + wslLogsHelp = "https://aka.ms/wsllogs" + } +} + +function Get-ValidationAppEnvironment { + return @{ + OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + } +} + +function Convert-SetupStatus { + param([object]$Status) + $v = [string]$Status + if ($v -match '^\d+$') { + # Aligned with LocalGatewaySetupStatus enum + $names = @("Pending", "Running", "RequiresAdmin", "RequiresRestart", "Blocked", + "FailedRetryable", "FailedTerminal", "Complete", "Cancelled") + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Convert-SetupPhase { + param([object]$Phase) + $v = [string]$Phase + if ($v -match '^\d+$') { + # Aligned with the clean LocalGatewaySetupPhase enum (worker / rootfs phases removed). + $names = @( + "NotStarted", "Preflight", "ElevationCheck", + "EnsureWslEnabled", "CreateWslInstance", "ConfigureWslInstance", + "InstallOpenClawCli", "PrepareGatewayConfig", "InstallGatewayService", + "StartGateway", "WaitForGateway", + "MintBootstrapToken", "PairOperator", + "CheckWindowsNodeReadiness", "PairWindowsTrayNode", + "VerifyEndToEnd", "Complete", "Failed", "Cancelled" + ) + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Wait-ForUiAutomationElement { + param([string]$AutomationId, [int]$TimeoutSeconds) + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, $AutomationId) + while ((Get-Date) -lt $deadline) { + $el = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, $cond) + if ($null -ne $el) { return $el } + Start-Sleep -Milliseconds 500 + } + return $null +} + +function Invoke-UiAutomationClick { + param([string]$AutomationId, [int]$TimeoutSeconds) + $el = Wait-ForUiAutomationElement -AutomationId $AutomationId -TimeoutSeconds $TimeoutSeconds + if ($null -ne $el) { + $p = $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $p.Invoke() + Add-Step "ui-click-$AutomationId" "Completed" "Clicked UI element with AutomationId '$AutomationId'." + return + } + Save-DiagnosticsSnapshot -Reason "missing-ui-target-$AutomationId" + throw "UI element with AutomationId '$AutomationId' was not found within $TimeoutSeconds seconds." +} + +function Stop-ExistingTrayProcesses { + param([string]$Reason) + $repoPrefix = [string]$repoRoot.Path + $procs = Get-Process -Name "OpenClaw.Tray.WinUI" -ErrorAction SilentlyContinue | + Where-Object { + try { -not [string]::IsNullOrWhiteSpace($_.Path) -and $_.Path.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase) } + catch { $false } + } + foreach ($p in $procs) { + $procId = $p.Id + try { + Stop-Process -Id $procId -Force -ErrorAction Stop + Add-Step "stop-existing-tray" "Completed" "Stopped existing repo tray process by PID before validation." @{ pid = $procId; reason = $Reason } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-existing-tray" "Skipped" "Repo tray process had already exited before cleanup." @{ pid = $procId; reason = $Reason } + } + } +} + +function Stop-WslKeepAliveProcesses { + $target = $DistroName + $procs = Get-CimInstance Win32_Process -Filter "Name = 'wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine.Contains($target, [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("sleep", [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("2147483647", [System.StringComparison]::OrdinalIgnoreCase) + } + foreach ($p in $procs) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop + Add-Step "stop-wsl-keepalive" "Completed" "Stopped $target keepalive process by PID." @{ pid = $p.ProcessId; distroName = $target } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-wsl-keepalive" "Skipped" "$target keepalive process had already exited." @{ pid = $p.ProcessId; distroName = $target } + } + } +} + +function Start-TrayForLocalSetup { + Stop-ExistingTrayProcesses -Reason "pre-launch" + + # Forked onboarding entry point is SetupWarning by default; we just force + # onboarding mode and let the script click "Set up locally". + $env = @{ + OPENCLAW_SKIP_UPDATE_CHECK = "1" + OPENCLAW_FORCE_ONBOARDING = "1" + OPENCLAW_WSL_DISTRO_NAME = $DistroName + # TODO: OPENCLAW_WSL_INSTALL_LOCATION was removed (commit: remove OPENCLAW_WSL_INSTALL_LOCATION env-var binding). + # The install location is now derived from the distro name by the tray app. Remove this comment once uninstall support lands. + OPENCLAW_WSL_ALLOW_EXISTING_DISTRO = if ($Scenario -eq "UpstreamInstall") { "1" } else { "0" } + OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + OPENCLAW_VISUAL_TEST = "1" + OPENCLAW_VISUAL_TEST_DIR = $screenshotsRoot + } + + $saved = @{} + foreach ($k in $env.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$env[$k], "Process") + } + + try { + New-Item -ItemType Directory -Force -Path $screenshotsRoot | Out-Null + if (-not (Test-Path -LiteralPath $trayExe)) { + throw "Built tray executable not found at $trayExe. Run build.ps1 first or omit -NoBuild." + } + $proc = Start-Process -FilePath $trayExe -WorkingDirectory $repoRoot -PassThru + Add-Step "launch-tray" "Completed" "Launched tray onboarding for WSL local setup." @{ + pid = $proc.Id; screenshots = $screenshotsRoot; file = $trayExe; runtimeIdentifier = $runtimeIdentifier + } + return $proc + } finally { + foreach ($k in $env.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } +} + +function Wait-ForSetupCompletion { + param([int]$TimeoutSeconds) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPhase = ""; $lastStatus = "" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $setupStatePath) { + $text = Read-TextFileWithRetry -Path $setupStatePath + $state = $text | ConvertFrom-Json + $copy = Join-Path $runRoot "setup-state.json" + $text | Set-Content -LiteralPath $copy -Encoding UTF8 + + $phase = Convert-SetupPhase $state.Phase + $status = Convert-SetupStatus $state.Status + if ($phase -ne $lastPhase -or $status -ne $lastStatus) { + $lastPhase = $phase; $lastStatus = $status + $script:summary.setupPhases += [ordered]@{ + phase = $phase; status = $status; message = [string]$state.UserMessage; timestamp = (Get-Date).ToString("o") + } + Add-Step "setup-phase-$phase" $status ([string]$state.UserMessage) @{ phase = $phase; status = $status } + } + + if ($status -eq "Complete") { + if ($state.PSObject.Properties.Name -contains "GatewayUrl" -and -not [string]::IsNullOrWhiteSpace([string]$state.GatewayUrl)) { + $script:GatewayUrl = [string]$state.GatewayUrl + $script:summary.selectedGatewayUrl = $script:GatewayUrl + } + Add-Step "setup-state" "Passed" "Setup reached $status." @{ + status = $status; phase = $phase; path = $copy + gatewayUrl = (Get-SafeUriDisplay $script:GatewayUrl) + } + return + } + if ($status -in @("FailedRetryable", "FailedTerminal", "Blocked", "Cancelled")) { + Save-DiagnosticsSnapshot -Reason "setup-failed-$phase" + throw "Setup failed with status $status, phase $phase, code $($state.FailureCode): $($state.UserMessage). Diagnostics: https://aka.ms/wsllogs." + } + } + Start-Sleep -Seconds 2 + } + Save-DiagnosticsSnapshot -Reason "setup-timeout" + throw "Setup did not reach Complete within $TimeoutSeconds seconds. Diagnostics: https://aka.ms/wsllogs." +} + +function Invoke-TrayLocalSetup { + $proc = Start-TrayForLocalSetup + Start-Sleep -Seconds 5 + + # SetupWarningPage hosts the "Set up locally" primary button. + if ($null -eq (Wait-ForUiAutomationElement -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 60)) { + Save-DiagnosticsSnapshot -Reason "setup-local-button-not-found" + throw "UI automation target OnboardingSetupLocal was not found on SetupWarningPage." + } + Invoke-UiAutomationClick -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 5 + + # LocalSetupProgressPage starts the engine on appearance; just wait for state. + Wait-ForSetupCompletion -TimeoutSeconds $TimeoutSeconds + return $proc +} + +function Stop-TrayProcess { + param([object]$Process) + if ($null -ne $Process) { + $procId = $Process.Id + $live = Get-Process -Id $procId -ErrorAction SilentlyContinue + if ($null -ne $live) { + Stop-Process -Id $procId -Force + Add-Step "stop-tray" "Completed" "Stopped tray process by PID after setup validation." @{ pid = $procId } + } else { + Add-Step "stop-tray" "Skipped" "Tray process had already exited before cleanup." @{ pid = $procId } + } + } + Stop-ExistingTrayProcesses -Reason "post-validation" + Stop-WslKeepAliveProcesses +} + +function Convert-GatewayUrlToHealthUri { + param([string]$Url) + $b = [System.UriBuilder]::new($Url) + if ($b.Scheme -eq "ws") { $b.Scheme = "http" } + elseif ($b.Scheme -eq "wss") { $b.Scheme = "https" } + $b.Path = ($b.Path.TrimEnd("/") + "/health") + return $b.Uri.AbsoluteUri +} + +function Save-LoopbackNetworkDiagnostics { + param([string]$Reason) + # Loopback only - no WSL IP, no `hostname -I`, no lan probes. + $safe = $Reason -replace "[^a-zA-Z0-9_.-]", "-" + $tcpPath = Join-Path $commandsRoot "network-$safe-windows-tcp-18789.json" + try { + $cs = @(Get-NetTCPConnection -LocalPort 18789 -ErrorAction Stop | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress; localPort = $_.LocalPort + state = $_.State.ToString(); owningProcess = $_.OwningProcess + } + }) + $cs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Completed" "Captured Windows TCP listener state for loopback gateway port." @{ path = $tcpPath } + } catch { + $_.Exception.Message | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Skipped" "Could not capture Windows TCP listener state. See https://aka.ms/wsllogs." @{ path = $tcpPath } + } +} + +function Save-RedactedSettings { + if (-not (Test-Path -LiteralPath $settingsPath)) { + Add-Step "settings-redacted" "Skipped" "Tray settings file was not found." + return + } + $copy = Join-Path $runRoot "settings.redacted.json" + $c = Read-TextFileWithRetry -Path $settingsPath + $c = $c -replace '("(?:Token|token|GatewayToken|BootstrapToken|bootstrapToken|bootstrap_token|NodeToken|nodeToken)"\s*:\s*")[^"]*(")', '$1$2' + $c | Set-Content -LiteralPath $copy -Encoding UTF8 + Add-Step "settings-redacted" "Completed" "Saved redacted tray settings." @{ path = $copy } +} + +function Test-SetupHistoryPhase { + param([string]$Phase) + if (-not (Test-Path -LiteralPath $setupStatePath)) { return $false } + $state = Read-TextFileWithRetry -Path $setupStatePath | ConvertFrom-Json + if (-not ($state.PSObject.Properties.Name -contains "History")) { return $false } + foreach ($e in @($state.History)) { + if ((Convert-SetupPhase $e.Phase) -eq $Phase -and (Convert-SetupStatus $e.Status) -in @("Running", "Complete")) { + return $true + } + } + return (Convert-SetupPhase $state.Phase) -eq $Phase +} + +function Save-RedactedDeviceIdentityShape { + $idp = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (-not (Test-Path -LiteralPath $idp)) { + Add-Step "device-identity" "Failed" "Device identity file was not found." @{ path = $idp } + return $false + } + $copy = Join-Path $runRoot "device-key.shape.redacted.json" + Copy-RedactedFileIfExists -SourcePath $idp -DestinationPath $copy | Out-Null + try { + $id = Get-Content -LiteralPath $idp -Raw | ConvertFrom-Json + $hasOperatorToken = ($id.PSObject.Properties.Name -contains "DeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.DeviceToken)) -or + ($id.PSObject.Properties.Name -contains "OperatorDeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.OperatorDeviceToken)) + Add-Step "device-identity" ($(if ($hasOperatorToken) { "Passed" } else { "Failed" })) "Checked stored device identity token shape." @{ + path = $copy; hasOperatorToken = $hasOperatorToken + } + return $hasOperatorToken + } catch { + Add-Step "device-identity" "Failed" "Device identity JSON could not be parsed." @{ path = $copy } + return $false + } +} + +function Test-JsonStringProperty { + param([object]$Json, [string[]]$Names) + foreach ($n in $Names) { + if ($Json.PSObject.Properties.Name -contains $n) { + $v = [string]$Json.$n + if (-not [string]::IsNullOrWhiteSpace($v)) { return $true } + } + } + return $false +} + +function Get-JsonStringProperty { + param([object]$Json, [string]$Name) + if ($Json -and $Json.PSObject.Properties.Name -contains $Name) { return [string]$Json.$Name } + return "" +} + +function Invoke-BootstrapHandoffProbe { + # Real upstream setup-code / bootstrap proof. + $stdout = Join-Path $commandsRoot "wsl-bootstrap-token.stdout.txt" + $stderr = Join-Path $commandsRoot "wsl-bootstrap-token.stderr.txt" + $args = @("-d", $DistroName, "--", "/opt/openclaw/bin/openclaw", "qr", "--json", "--url", $GatewayUrl) + & wsl.exe @args > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + $raw = if (Test-Path -LiteralPath $stdout) { Read-TextFileWithRetry -Path $stdout -Attempts 20 -DelayMilliseconds 250 } else { "" } + Write-TextFileWithRetry -Path $stdout -Content (Redact-SensitiveGatewayOutput $raw) -Attempts 20 -DelayMilliseconds 250 + + if ($exitCode -ne 0) { + Add-Step "wsl-bootstrap-token" "Failed" "Gateway QR command failed with exit code $exitCode." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + throw "BootstrapTokenCommandFailed: openclaw qr --json failed. See $stdout and $stderr." + } + + $hasSetupCode = $false; $hasDirectToken = $false + try { + $qr = $raw | ConvertFrom-Json + $hasSetupCode = Test-JsonStringProperty $qr @("setupCode", "setup_code") + $hasDirectToken = Test-JsonStringProperty $qr @("bootstrapToken", "bootstrap_token", "token") + } catch { + throw "BootstrapTokenJsonInvalid: openclaw qr --json did not produce valid JSON: $($_.Exception.Message)" + } + + $shape = if ($hasSetupCode) { "UpstreamSetupCode" } elseif ($hasDirectToken) { "DirectBootstrapToken" } else { "Unknown" } + $script:summary.pairingValidation["bootstrapQrShape"] = $shape + $script:summary.pairingValidation["realUpstreamBootstrapHandoff"] = $hasSetupCode + + Add-Step "wsl-bootstrap-token" "Completed" "Gateway QR command completed; bootstrap shape is $shape." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr; bootstrapQrShape = $shape; realUpstreamBootstrapHandoff = $hasSetupCode + } + + if ($RequireRealGatewayBootstrap -and -not $hasSetupCode) { + throw "RealGatewayBootstrapRequired: expected upstream setupCode bootstrap handoff, but openclaw qr --json returned $shape." + } +} + +function Invoke-OperatorPairingProof { + if (-not $RequireOperatorPairing) { + Add-Step "operator-pairing-proof" "Skipped" "Operator pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairOperator")) { + Save-DiagnosticsSnapshot -Reason "operator-pair-phase-missing" + throw "OperatorPairingProofFailed: setup state did not record PairOperator." + } + if (-not (Save-RedactedDeviceIdentityShape)) { + Save-DiagnosticsSnapshot -Reason "operator-device-token-missing" + throw "OperatorPairingProofFailed: stored operator device token is missing." + } + Invoke-LoggedProcess "operator-stored-token-reconnect" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", + "--connect-timeout-ms", "15000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["operatorPaired"] = $true + Add-Step "operator-pairing-proof" "Passed" "Stored operator device token reconnect succeeded." +} + +function Invoke-WindowsNodePairingProof { + # Windows tray IS the node (per Mike). Confirm the PairWindowsTrayNode phase + # ran and that gateway node.list returns the tray node. + if (-not $RequireWindowsNodePairing) { + Add-Step "windows-node-pairing-proof" "Skipped" "Windows tray node pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairWindowsTrayNode")) { + Save-DiagnosticsSnapshot -Reason "windows-node-pair-phase-missing" + throw "WindowsNodePairingProofFailed: setup state did not record PairWindowsTrayNode." + } + Invoke-LoggedProcess "windows-node-list-proof" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", "--require-node", + "--connect-timeout-ms", "90000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["windowsNodePaired"] = $true + Add-Step "windows-node-pairing-proof" "Passed" "Gateway node.list returned the Windows tray node." +} + +function Invoke-SmokeChecks { + Invoke-LoggedProcess "wsl-list-after" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + Save-LoopbackNetworkDiagnostics -Reason "post-install" + + # Gateway in WSL via systemd user unit (UpstreamInstall layout). + Invoke-LoggedProcess "wsl-openclaw-version" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "--version") + Invoke-LoggedProcess "wsl-openclaw-config-validate" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "config", "validate") + Invoke-LoggedProcess "wsl-gateway-journal" "wsl.exe" @( + "-d", $DistroName, "-u", "root", "--", "journalctl", "--user", "-u", "openclaw-gateway", + "--no-pager", "-n", "200") -IgnoreExitCode -SensitiveOutput + + # Loopback-only health probe. + $healthUri = Convert-GatewayUrlToHealthUri -Url $GatewayUrl + $healthPath = Join-Path $commandsRoot "gateway-health.json" + try { + $h = Invoke-RestMethod -Uri $healthUri -TimeoutSec 10 + $h | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $healthPath -Encoding UTF8 + if (-not $h.ok) { throw "Gateway health response did not contain ok=true." } + $gw = if ($h.PSObject.Properties.Name -contains "gateway") { $h.gateway } else { $null } + $version = Get-JsonStringProperty $gw "version" + $displayName = Get-JsonStringProperty $gw "displayName" + $isDev = $version -like "*-dev*" -or $displayName -like "Dev OpenClaw*" + $script:summary.pairingValidation["gatewayImplementation"] = if ($isDev) { "DevShim" } else { "ProductionCandidate" } + Add-Step "gateway-health" "Passed" "Gateway health endpoint returned ok=true." @{ uri = $healthUri; path = $healthPath } + } catch { + throw "Gateway health check failed for ${healthUri}: $($_.Exception.Message). Diagnostics: https://aka.ms/wsllogs." + } + + Invoke-BootstrapHandoffProbe + Save-RedactedSettings + Invoke-OperatorPairingProof + Invoke-WindowsNodePairingProof + + $args = @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", + "--message", "openclaw validation ping", + "--connect-timeout-ms", "15000" + ) + if ($RequireOperatorPairing) { $args += "--require-stored-device-token" } + Invoke-LoggedProcess "openclaw-cli-probe" "dotnet" $args -Environment (Get-ValidationAppEnvironment) -SensitiveOutput +} + +function Invoke-DistroUnregisterIfPresent { + param([string]$Reason) + Stop-WslKeepAliveProcesses + # Authoritative repair primitive: `wsl --unregister`. NEVER `wsl --shutdown`. + Invoke-LoggedProcess "wsl-unregister-$Reason" "wsl.exe" @("--unregister", $DistroName) -IgnoreExitCode + + if (Test-Path -LiteralPath $wslInstallLocation) { + try { + Remove-Item -LiteralPath $wslInstallLocation -Recurse -Force -ErrorAction Stop + Add-Step "remove-install-location-$Reason" "Completed" "Removed install location directory." @{ path = $wslInstallLocation } + } catch { + Add-Step "remove-install-location-$Reason" "Skipped" "Could not remove install location: $($_.Exception.Message)" @{ path = $wslInstallLocation } + } + } +} + +function Invoke-PreIterationCleanup { + param([int]$Index) + if ($Scenario -in @("FreshMachine", "Recreate")) { + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-pre" + # Wipe isolated AppData so identity store starts empty. + foreach ($p in @($validationAppDataRoot, $validationLocalAppDataRoot)) { + if (Test-Path -LiteralPath $p) { + try { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction Stop } catch { } + } + } + } else { + Stop-WslKeepAliveProcesses + } +} + +function Invoke-PostIterationCleanup { + param([int]$Index, [bool]$IterationFailed) + if ($Scenario -ne "Recreate") { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Post-iteration distro cleanup is only required in Recreate scenario." + return "Skipped" + } + if ($IterationFailed -and $KeepFailedDistro) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Keeping failed WSL distro for inspection (-KeepFailedDistro)." @{ distroName = $DistroName } + return "Skipped" + } + if (-not $IterationFailed -and -not $CleanupAfterSuccess) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Leaving successful distro (-CleanupAfterSuccess:`$false)." @{ distroName = $DistroName } + return "Skipped" + } + try { + $script:summary.cleanupStatus = "Running" + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-post" + $script:summary.cleanupStatus = "Passed" + Add-Step "iteration-$Index-cleanup" "Passed" "Cleaned recreated WSL distro after validation iteration." @{ distroName = $DistroName } + return "Passed" + } catch { + $script:summary.cleanupStatus = "Failed" + Add-Step "iteration-$Index-cleanup" "Failed" $_.Exception.Message + if (-not $ContinueOnCleanupFailure) { throw } + return "Failed" + } +} + +function New-IterationRecord { + param([int]$Index) + return [ordered]@{ + index = $Index + distroName = $DistroName + installLocation = $wslInstallLocation + validationStatus = "Running" + cleanupStatus = "NotStarted" + error = $null + cleanupError = $null + startedAt = (Get-Date).ToString("o") + finishedAt = $null + } +} + +function Invoke-ValidationIteration { + param([int]$Index) + $iteration = New-IterationRecord -Index $Index + $script:summary.iterations += $iteration + Add-Step "iteration-$Index" "Started" "Starting validation iteration $Index." + $trayProcess = $null + $iterationFailed = $false + + try { + Invoke-RepositoryValidation + Invoke-PreIterationCleanup -Index $Index + $trayProcess = Invoke-TrayLocalSetup + Invoke-SmokeChecks + + Add-Step "iteration-$Index" "Passed" "Validation iteration $Index passed." + $iteration.validationStatus = "Passed" + $script:summary.validationStatus = "Passed" + } catch { + $iterationFailed = $true + $iteration.validationStatus = "Failed" + $iteration.error = $_.Exception.Message + $script:summary.validationStatus = "Failed" + Save-DiagnosticsSnapshot -Reason "iteration-$Index-failed" + throw + } finally { + try { + Stop-TrayProcess -Process $trayProcess + $iteration.cleanupStatus = Invoke-PostIterationCleanup -Index $Index -IterationFailed $iterationFailed + } catch { + $iteration.cleanupStatus = "Failed" + $iteration.cleanupError = $_.Exception.Message + throw + } finally { + $iteration.finishedAt = (Get-Date).ToString("o") + } + } +} + +New-Item -ItemType Directory -Force -Path $runRoot, $commandsRoot, $screenshotsRoot | Out-Null + +$exitCode = 0 +try { + Assert-DestructiveSafety + Invoke-Preflight + + if ($Scenario -eq "PreflightOnly") { + Add-Step "scenario" "Passed" "Preflight completed." + $script:summary.validationStatus = "Passed" + $script:summary.cleanupStatus = "Skipped" + } elseif ($Scenario -eq "Recreate" -or $Iterations -gt 1) { + if ($Iterations -lt 1) { throw "-Iterations must be at least 1." } + for ($i = 1; $i -le $Iterations; $i++) { + try { Invoke-ValidationIteration -Index $i } + catch { + Add-Step "iteration-$i" "Failed" $_.Exception.Message + if (-not $ContinueOnFailure) { throw } + } + } + } else { + # UpstreamInstall or FreshMachine, single shot. + Invoke-ValidationIteration -Index 1 + } + + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Passed" } + if ($script:summary.cleanupStatus -in @("Running", "NotStarted")) { $script:summary.cleanupStatus = "Skipped" } + if ($script:summary.validationStatus -eq "Failed") { + $script:summary.status = "Failed"; $exitCode = 1 + } else { + $script:summary.status = if ($script:summary.cleanupStatus -eq "Failed") { "PassedWithCleanupFailure" } else { "Passed" } + } +} catch { + $script:summary.status = "Failed" + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Failed" } + if ($script:summary.cleanupStatus -eq "Running") { $script:summary.cleanupStatus = "Failed" } + $script:summary.error = $_.Exception.Message + Add-Step "validation" "Failed" $_.Exception.Message + $exitCode = 1 +} finally { + Write-Summary +} + +Write-Host "Validation summary: $summaryPath" +if ($script:summary.status -eq "Failed") { + Write-Host "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." +} +exit $exitCode diff --git a/src/OpenClaw.Chat/ChatModels.cs b/src/OpenClaw.Chat/ChatModels.cs new file mode 100644 index 000000000..1c090e3c5 --- /dev/null +++ b/src/OpenClaw.Chat/ChatModels.cs @@ -0,0 +1,200 @@ +namespace OpenClaw.Chat; + +public enum ChatThreadStatus +{ + Created, + Running, + Suspended +} + +public enum ChatActivity +{ + Idle, + Working, + AwaitingInput, + AwaitingPermission, + Error +} + +public enum ChatTimelineItemKind +{ + User, + Assistant, + ToolCall, + Reasoning, + Status, + Raw +} + +public enum ChatToolCallStatus +{ + InProgress, + Success, + Error, + Interrupted +} + +public enum ChatTone +{ + Info, + Success, + Warning, + Error, + Dim +} + +public record ChatThread +{ + public required string Id { get; init; } + public required string Title { get; init; } + public ChatThreadStatus Status { get; init; } + public ChatActivity Activity { get; init; } + public string? Cwd { get; init; } + public string? Workspace { get; init; } + public string? Repository { get; init; } + public string? Branch { get; init; } + public string? HostName { get; init; } + public string? Compute { get; init; } + public string? ProfileName { get; init; } + public string? Model { get; init; } + public string? ThinkingLevel { get; init; } + public int? HistoryCursor { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } + + public string DisplayTitle => Title; +} + +public record ChatTimelineItem( + string Id, + ChatTimelineItemKind Kind, + string Text, + bool IsStreaming = false, + string? ToolName = null, + ChatToolCallStatus? ToolResult = null, + string? ToolOutput = null, + string? IntentSummary = null, + JsonObject? ToolArgs = null, + ChatTone? Tone = null); + +public record ChatPermissionRequest(string RequestId, string PermissionKind, string ToolName, string Detail); + +public record ChatTimelineState( + System.Collections.Immutable.ImmutableList Entries, + bool TurnActive, + int NextId, + string? ActiveAssistantId, + string? ActiveReasoningId, + string? ActiveToolCallId, + string? CurrentIntent, + System.Collections.Immutable.ImmutableHashSet LocalNonces, + System.Collections.Immutable.ImmutableDictionary ActiveToolCalls, + bool HistoryLoaded = false, + ChatPermissionRequest? PendingPermission = null) +{ + public static ChatTimelineState Initial() => new( + System.Collections.Immutable.ImmutableList.Empty, + false, 1, null, null, null, null, + System.Collections.Immutable.ImmutableHashSet.Empty, + System.Collections.Immutable.ImmutableDictionary.Empty); +} + +public record ChatHistoryPage(ChatEvent[] Events, int NextSince, int PrevBefore, bool HasMore); + +public abstract record ChatEvent; +public record ChatUserMessageEvent(string Text, string? Nonce = null) : ChatEvent; +public record ChatThinkingEvent(string Text) : ChatEvent; +public record ChatReasoningEvent(string Text) : ChatEvent; +public record ChatReasoningDeltaEvent(string Text) : ChatEvent; +public record ChatMessageEvent(string Text, string? ReasoningText = null, bool ReconcilePrevious = false) : ChatEvent; +public record ChatMessageDeltaEvent(string Text) : ChatEvent; +public record ChatTurnEndEvent() : ChatEvent; +public record ChatIntentEvent(string Intent) : ChatEvent; +public record ChatToolStartEvent(string Text, string ToolName, JsonObject? ToolArgs = null, string? ToolCallId = null) : ChatEvent; +public record ChatToolOutputEvent(string Text, string? ToolCallId = null) : ChatEvent; +public record ChatToolErrorEvent(string Text, string? ToolCallId = null) : ChatEvent; +public record ChatContextChangedEvent(string? Cwd, string? GitBranch) : ChatEvent; +public record ChatStatusEvent(string Text, ChatTone Tone) : ChatEvent; +public record ChatErrorEvent(string Text) : ChatEvent; +public record ChatRestoredEvent(string Text) : ChatEvent; +public record ChatPermissionRequestEvent(string RequestId, string PermissionKind, string ToolName, string Detail) : ChatEvent; +public record ChatModelChangedEvent(string Model) : ChatEvent; +public record ChatRawEvent(string EventType, string? Text = null) : ChatEvent; + +public record ChatDataSnapshot( + ChatThread[] Threads, + IReadOnlyDictionary Timelines, + string? DefaultThreadId, + string? ConnectionStatus, + string[] AvailableModels, + ChatComposeTarget ComposeTarget); + +/// +/// Describes where the UI may send the next chat message. Distinct from +/// because, in protocols like the +/// OpenClaw gateway, there is always a canonical "main" target whose session +/// row may not have been materialized yet (zero sessions on fresh install). +/// The UI uses this to decide whether the composer should be enabled even +/// when is empty. +/// +/// +/// The provider-resolved canonical session key for the default send target, +/// or null when not yet known (e.g. handshake incomplete). +/// +/// +/// True when the provider is connected, has resolved a canonical send target, +/// and accepts calls keyed by +/// . +/// +public sealed record ChatComposeTarget(string? SessionKey, bool IsReady) +{ + public static ChatComposeTarget NotReady { get; } = new(null, false); +} + +public sealed class ChatDataChangedEventArgs(ChatDataSnapshot snapshot) : EventArgs +{ + public ChatDataSnapshot Snapshot { get; } = snapshot; +} + +public enum ChatProviderNotificationKind +{ + TurnComplete, + PermissionRequested, + Error +} + +public record ChatProviderNotification( + ChatProviderNotificationKind Kind, + string ThreadId, + string Title, + string? Message = null, + string? ToolName = null); + +public sealed class ChatProviderNotificationEventArgs(ChatProviderNotification notification) : EventArgs +{ + public ChatProviderNotification Notification { get; } = notification; +} + +public interface IChatDataProvider : IAsyncDisposable +{ + string DisplayName { get; } + + event EventHandler? Changed; + event EventHandler? NotificationRequested; + + Task LoadAsync(CancellationToken cancellationToken = default); + // Note: there is intentionally no CreateThreadAsync. The gateway protocol + // has no "create new session" RPC; the canonical send target is exposed via + // ChatDataSnapshot.ComposeTarget and the first SendMessageAsync against it + // implicitly materializes the session on the server. + Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken = default); + Task SendMessageAsync(string threadId, string message, CancellationToken cancellationToken, IReadOnlyList? attachments) => + SendMessageAsync(threadId, message, cancellationToken); + Task StopResponseAsync(string threadId, CancellationToken cancellationToken = default); + Task SetThreadSuspendedAsync(string threadId, bool suspended, CancellationToken cancellationToken = default); + Task DeleteThreadAsync(string threadId, CancellationToken cancellationToken = default); + Task SetModelAsync(string threadId, string model, CancellationToken cancellationToken = default); + Task SetThinkingLevelAsync(string threadId, string thinkingLevel, CancellationToken cancellationToken = default); + Task SetPermissionModeAsync(string threadId, bool allowAll, CancellationToken cancellationToken = default); + Task RespondToPermissionAsync(string threadId, string requestId, bool allow, CancellationToken cancellationToken = default); +} diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs new file mode 100644 index 000000000..0caed60e3 --- /dev/null +++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs @@ -0,0 +1,291 @@ +namespace OpenClaw.Chat; + +public static class ChatTimelineReducer +{ + private const int MaxLocalNonces = 256; + + public static ChatTimelineState Apply(ChatTimelineState state, ChatEvent evt) + { + return evt switch + { + ChatUserMessageEvent e => ApplyUserMessage(state, e), + ChatThinkingEvent => state with { TurnActive = true }, + ChatReasoningEvent e => UpsertReasoning(BeginTurn(state), e.Text, replace: true), + ChatReasoningDeltaEvent e => UpsertReasoning(BeginTurn(state), e.Text, replace: false), + ChatMessageDeltaEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: false, streaming: true), + ChatMessageEvent e => UpsertAssistant(BeginTurn(state), e.Text, replace: true, streaming: false, e.ReconcilePrevious), + ChatTurnEndEvent => ApplyTurnEnd(state), + ChatIntentEvent e => state with { CurrentIntent = e.Intent }, + ChatToolStartEvent e => ApplyToolStart(state, e), + ChatToolOutputEvent e => ApplyToolOutput(state, e), + ChatToolErrorEvent e => ApplyToolError(state, e), + ChatErrorEvent e => PushEntry(ApplyTurnEnd(state), ChatTimelineItemKind.Status, e.Text, ChatTone.Error), + ChatStatusEvent e => PushEntry(state, ChatTimelineItemKind.Status, e.Text, e.Tone), + ChatRestoredEvent e => PushEntry(state, ChatTimelineItemKind.Status, e.Text, ChatTone.Info), + ChatContextChangedEvent => state, + ChatModelChangedEvent e => PushEntry(state, ChatTimelineItemKind.Status, $"Model -> {e.Model}", ChatTone.Success), + ChatPermissionRequestEvent e => state with + { + PendingPermission = new ChatPermissionRequest(e.RequestId, e.PermissionKind, e.ToolName, e.Detail) + }, + ChatRawEvent e => e.Text is { Length: > 0 } t ? PushEntry(state, ChatTimelineItemKind.Raw, t) : state, + _ => state + }; + } + + public static ChatTimelineState AddLocalUser(ChatTimelineState state, string text, string nonce) + { + var id = $"e{state.NextId}"; + var localNonces = state.LocalNonces; + if (localNonces.Count >= MaxLocalNonces) + { + foreach (var nonceToDrop in localNonces) + { + localNonces = localNonces.Remove(nonceToDrop); + break; + } + } + + return state with + { + Entries = state.Entries.Add(new(id, ChatTimelineItemKind.User, text)), + LocalNonces = localNonces.Add(nonce), + NextId = state.NextId + 1, + TurnActive = true + }; + } + + public static ChatTimelineState AddSystem(ChatTimelineState state, string text, ChatTone tone = ChatTone.Info) + => PushEntry(state, ChatTimelineItemKind.Status, text, tone); + + public static ChatTimelineState ClearPermission(ChatTimelineState state) + => state with { PendingPermission = null }; + + static ChatTimelineState ApplyUserMessage(ChatTimelineState state, ChatUserMessageEvent e) + { + if (e.Nonce is { } nonce && state.LocalNonces.Contains(nonce)) + { + return state with { LocalNonces = state.LocalNonces.Remove(nonce) }; + } + + var id = $"e{state.NextId}"; + return state with + { + Entries = state.Entries.Add(new(id, ChatTimelineItemKind.User, e.Text)), + NextId = state.NextId + 1, + TurnActive = true + }; + } + + static ChatTimelineState ApplyToolStart(ChatTimelineState state, ChatToolStartEvent e) + { + var id = $"e{state.NextId}"; + var activeToolCalls = state.ActiveToolCalls; + + // Register by ToolCallId if available (parallel-safe correlation). + if (e.ToolCallId is { } tcId) + activeToolCalls = activeToolCalls.SetItem(tcId, id); + + return state with + { + Entries = state.Entries.Add(new(id, ChatTimelineItemKind.ToolCall, e.Text, + ToolName: e.ToolName, ToolResult: ChatToolCallStatus.InProgress, + IntentSummary: e.Text, ToolArgs: e.ToolArgs)), + NextId = state.NextId + 1, + // Only update legacy positional slot for events without a correlation ID. + ActiveToolCallId = e.ToolCallId is null ? id : state.ActiveToolCallId, + ActiveToolCalls = activeToolCalls, + TurnActive = true + }; + } + + static ChatTimelineState ApplyToolOutput(ChatTimelineState state, ChatToolOutputEvent e) + { + var (entries, entryId) = ResolveToolEntry(state, e.ToolCallId); + if (entryId is { } tid) + { + var idx = entries.FindIndex(en => en.Id == tid); + if (idx >= 0) + { + var existingOutput = entries[idx].ToolOutput; + entries = entries.SetItem(idx, entries[idx] with + { + ToolResult = ChatToolCallStatus.Success, + ToolOutput = string.IsNullOrEmpty(e.Text) && existingOutput is not null + ? existingOutput + : e.Text + }); + } + } + return state with + { + Entries = entries, + // Don't remove from ActiveToolCalls here: multiple output events can arrive + // for the same tool (command_output + item end). Mapping is cleared at turn end. + ActiveToolCallId = (entryId == state.ActiveToolCallId) ? null : state.ActiveToolCallId, + PendingPermission = null + }; + } + + static ChatTimelineState ApplyToolError(ChatTimelineState state, ChatToolErrorEvent e) + { + var (entries, entryId) = ResolveToolEntry(state, e.ToolCallId); + if (entryId is { } tid) + { + var idx = entries.FindIndex(en => en.Id == tid); + if (idx >= 0) + { + entries = entries.SetItem(idx, entries[idx] with + { + ToolResult = ChatToolCallStatus.Error, + ToolOutput = e.Text + }); + } + } + return state with + { + Entries = entries, + ActiveToolCallId = (entryId == state.ActiveToolCallId) ? null : state.ActiveToolCallId, + ActiveToolCalls = e.ToolCallId is { } k ? state.ActiveToolCalls.Remove(k) : state.ActiveToolCalls, + PendingPermission = null + }; + } + + /// + /// Resolve which timeline entry a tool output/error belongs to. + /// Prefers ID-based lookup (parallel-safe); falls back to ActiveToolCallId only for legacy events (no ID). + /// + static (System.Collections.Immutable.ImmutableList Entries, string? EntryId) ResolveToolEntry( + ChatTimelineState state, string? toolCallId) + { + if (toolCallId is { } tcId) + { + // ID provided: strict lookup only. If mapping already consumed, no-op (don't misroute). + return state.ActiveToolCalls.TryGetValue(tcId, out var entryId) + ? (state.Entries, entryId) + : (state.Entries, null); + } + + // No ID: legacy positional fallback. + return (state.Entries, state.ActiveToolCallId); + } + + static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, bool replace, bool streaming, bool reconcilePrevious = false) + { + if (state.ActiveAssistantId is { } aid) + { + var idx = state.Entries.FindIndex(e => e.Id == aid); + if (idx >= 0) + { + var existing = state.Entries[idx]; + return state with + { + Entries = state.Entries.SetItem(idx, existing with + { + Text = replace ? text : existing.Text + text, + IsStreaming = streaming + }) + }; + } + } + + if (replace && reconcilePrevious && state.Entries.Count > 0) + { + var lastIndex = state.Entries.Count - 1; + var last = state.Entries[lastIndex]; + if (last.Kind == ChatTimelineItemKind.Assistant) + { + return state with + { + Entries = state.Entries.SetItem(lastIndex, last with + { + Text = text, + IsStreaming = streaming + }) + }; + } + } + + var id = $"e{state.NextId}"; + return state with + { + Entries = state.Entries.Add(new(id, ChatTimelineItemKind.Assistant, text, IsStreaming: streaming)), + NextId = state.NextId + 1, + ActiveAssistantId = id + }; + } + + static ChatTimelineState UpsertReasoning(ChatTimelineState state, string text, bool replace) + { + if (state.ActiveReasoningId is { } rid) + { + var idx = state.Entries.FindIndex(e => e.Id == rid); + if (idx >= 0) + { + var existing = state.Entries[idx]; + return state with + { + Entries = state.Entries.SetItem(idx, existing with { Text = replace ? text : existing.Text + text }) + }; + } + } + + var id = $"e{state.NextId}"; + return state with + { + Entries = state.Entries.Add(new(id, ChatTimelineItemKind.Reasoning, text)), + NextId = state.NextId + 1, + ActiveReasoningId = id + }; + } + + static ChatTimelineState PushEntry(ChatTimelineState state, ChatTimelineItemKind kind, string text, ChatTone? tone = null) + { + var id = $"e{state.NextId}"; + return state with + { + Entries = state.Entries.Add(new(id, kind, text, Tone: tone)), + NextId = state.NextId + 1 + }; + } + + static ChatTimelineState ApplyTurnEnd(ChatTimelineState state) + { + var entries = state.Entries; + + // Collect all entry IDs that are still tracked as active. + var activeEntryIds = new System.Collections.Generic.HashSet(); + if (state.ActiveToolCallId is { } fallbackId) + activeEntryIds.Add(fallbackId); + foreach (var kvp in state.ActiveToolCalls) + activeEntryIds.Add(kvp.Value); + + // Mark all remaining in-progress tools as Interrupted (reality: they never completed). + foreach (var entryId in activeEntryIds) + { + var idx = entries.FindIndex(en => en.Id == entryId); + if (idx >= 0 && entries[idx].ToolResult == ChatToolCallStatus.InProgress) + { + entries = entries.SetItem(idx, entries[idx] with + { + ToolResult = ChatToolCallStatus.Interrupted + }); + } + } + + return state with + { + Entries = entries, + TurnActive = false, + ActiveAssistantId = null, + ActiveReasoningId = null, + ActiveToolCallId = null, + ActiveToolCalls = System.Collections.Immutable.ImmutableDictionary.Empty, + PendingPermission = null + }; + } + + + static ChatTimelineState BeginTurn(ChatTimelineState state) => + state.TurnActive ? state : state with { TurnActive = true }; +} diff --git a/src/OpenClaw.Chat/GlobalUsings.cs b/src/OpenClaw.Chat/GlobalUsings.cs new file mode 100644 index 000000000..b3fb2b505 --- /dev/null +++ b/src/OpenClaw.Chat/GlobalUsings.cs @@ -0,0 +1 @@ +global using System.Text.Json.Nodes; diff --git a/src/OpenClaw.Chat/OpenClaw.Chat.csproj b/src/OpenClaw.Chat/OpenClaw.Chat.csproj new file mode 100644 index 000000000..bf61c357a --- /dev/null +++ b/src/OpenClaw.Chat/OpenClaw.Chat.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + x64;ARM64 + OpenClaw.Chat + OpenClaw.Chat + enable + enable + preview + + + + + + + diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index 7fb544cd3..0e2b05987 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -169,11 +169,7 @@ private static (string GatewayUrl, string Token, SettingsData? Loaded) LoadConne gatewayUrl = BuildEffectiveGatewayUrl(loaded); } - var token = options.TokenOverride; - if (string.IsNullOrWhiteSpace(token)) - { - token = loaded?.Token; - } + var token = options.TokenOverride ?? string.Empty; return (gatewayUrl ?? string.Empty, token ?? string.Empty, loaded); } diff --git a/src/OpenClaw.CommandPalette/Directory.Packages.props b/src/OpenClaw.CommandPalette/Directory.Packages.props index b6e619f1b..347dd8281 100644 --- a/src/OpenClaw.CommandPalette/Directory.Packages.props +++ b/src/OpenClaw.CommandPalette/Directory.Packages.props @@ -8,7 +8,7 @@ - + diff --git a/src/OpenClaw.Connection/ChatNavigationReadiness.cs b/src/OpenClaw.Connection/ChatNavigationReadiness.cs new file mode 100644 index 000000000..bfefe5374 --- /dev/null +++ b/src/OpenClaw.Connection/ChatNavigationReadiness.cs @@ -0,0 +1,43 @@ +namespace OpenClaw.Connection; + +public static class ChatNavigationReadiness +{ + public static bool IsOperatorHandshakeReady(IGatewayConnectionManager? connectionManager) => + connectionManager == null || + connectionManager.CurrentSnapshot.OperatorState == RoleConnectionState.Connected; + + public static async Task WaitForOperatorHandshakeAsync( + IGatewayConnectionManager? connectionManager, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (IsOperatorHandshakeReady(connectionManager)) + return true; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EventHandler? handler = null; + handler = (_, snapshot) => + { + if (snapshot.OperatorState == RoleConnectionState.Connected) + tcs.TrySetResult(true); + }; + + connectionManager!.StateChanged += handler; + try + { + if (IsOperatorHandshakeReady(connectionManager)) + return true; + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(timeout, cancellationToken)).ConfigureAwait(true); + if (completed == tcs.Task) + return await tcs.Task.ConfigureAwait(true); + + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + finally + { + connectionManager.StateChanged -= handler; + } + } +} diff --git a/src/OpenClaw.Connection/ConnectionDiagnosticEvent.cs b/src/OpenClaw.Connection/ConnectionDiagnosticEvent.cs new file mode 100644 index 000000000..f53a2f577 --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionDiagnosticEvent.cs @@ -0,0 +1,10 @@ +namespace OpenClaw.Connection; + +/// +/// Timestamped diagnostic event for the connection diagnostics ring buffer. +/// +public sealed record ConnectionDiagnosticEvent( + DateTime Timestamp, + string Category, // "state", "credential", "websocket", "pairing", "node", "error" + string Message, + string? Detail); diff --git a/src/OpenClaw.Connection/ConnectionDiagnostics.cs b/src/OpenClaw.Connection/ConnectionDiagnostics.cs new file mode 100644 index 000000000..f3ed8fadc --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionDiagnostics.cs @@ -0,0 +1,100 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Fixed-capacity ring buffer of timestamped connection diagnostic events. +/// Thread-safe via lock. Fires synchronously. +/// +public sealed class ConnectionDiagnostics +{ + private readonly ConnectionDiagnosticEvent[] _buffer; + private readonly IClock _clock; + private int _head; // next write position + private int _count; + private readonly object _lock = new(); + + public event EventHandler? EventRecorded; + + public ConnectionDiagnostics(int capacity = 500, IClock? clock = null) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _buffer = new ConnectionDiagnosticEvent[capacity]; + _clock = clock ?? SystemClock.Instance; + } + + public int Capacity => _buffer.Length; + + public void Record(string category, string message, string? detail = null) + { + var evt = new ConnectionDiagnosticEvent(_clock.UtcNow, category, message, detail); + lock (_lock) + { + _buffer[_head] = evt; + _head = (_head + 1) % _buffer.Length; + if (_count < _buffer.Length) _count++; + } + EventRecorded?.Invoke(this, evt); + } + + public void RecordStateChange(OverallConnectionState from, OverallConnectionState to) + { + Record("state", $"{from} → {to}"); + } + + public void RecordCredentialResolution(GatewayCredential? credential) + { + if (credential == null) + Record("credential", "No credential resolved"); + else + Record("credential", $"Resolved: {credential.Source}", $"IsBootstrap={credential.IsBootstrapToken}"); + } + + public void RecordWebSocketEvent(string eventName, string? detail = null) + { + Record("websocket", eventName, detail); + } + + public IReadOnlyList GetRecent(int count = 100) + { + lock (_lock) + { + var result = new List(Math.Min(count, _count)); + var start = (_head - Math.Min(count, _count) + _buffer.Length) % _buffer.Length; + for (int i = 0; i < Math.Min(count, _count); i++) + { + result.Add(_buffer[(start + i) % _buffer.Length]); + } + return result; + } + } + + public IReadOnlyList GetAll() + { + lock (_lock) + { + var result = new List(_count); + var start = (_head - _count + _buffer.Length) % _buffer.Length; + for (int i = 0; i < _count; i++) + { + result.Add(_buffer[(start + i) % _buffer.Length]); + } + return result; + } + } + + public int Count + { + get { lock (_lock) return _count; } + } + + public void Clear() + { + lock (_lock) + { + Array.Clear(_buffer); + _head = 0; + _count = 0; + } + } +} diff --git a/src/OpenClaw.Connection/ConnectionErrorCategory.cs b/src/OpenClaw.Connection/ConnectionErrorCategory.cs new file mode 100644 index 000000000..cd320cd97 --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionErrorCategory.cs @@ -0,0 +1,20 @@ +namespace OpenClaw.Connection; + +/// +/// Error categories with explicit retry behavior. +/// +public enum ConnectionErrorCategory +{ + AuthFailure, + PairingPending, + PairingRejected, + RateLimited, + NetworkUnreachable, + ServerClose, + ProtocolMismatch, + MalformedMessage, + InternalError, + SshTunnelFailure, + Cancelled, + Disposed +} diff --git a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs new file mode 100644 index 000000000..a152d966a --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs @@ -0,0 +1,23 @@ +namespace OpenClaw.Connection; + +/// +/// Immutable snapshot of connection-relevant settings for change classification. +/// Extracted from the full SettingsData to decouple the connection layer from UI settings. +/// +public sealed record ConnectionSettingsSnapshot( + string? GatewayUrl, + bool UseSshTunnel, + string? SshTunnelUser, + string? SshTunnelHost, + int SshTunnelRemotePort, + int SshTunnelLocalPort, + bool EnableNodeMode, + bool EnableMcpServer, + bool NodeCanvasEnabled, + bool NodeScreenEnabled, + bool NodeCameraEnabled, + bool NodeLocationEnabled, + bool NodeBrowserProxyEnabled, + bool NodeSttEnabled, + bool NodeTtsEnabled, + string? FullSettingsJson); diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs new file mode 100644 index 000000000..56fc6ecb0 --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -0,0 +1,318 @@ +namespace OpenClaw.Connection; + +/// +/// Pure-logic state machine enforcing valid operator sub-FSM transitions. +/// Not thread-safe — callers must serialize access via a semaphore. +/// Owns no I/O, no events, no async methods. +/// +internal sealed class ConnectionStateMachine +{ + public GatewayConnectionSnapshot Current { get; set; } = GatewayConnectionSnapshot.Idle; + + private RoleConnectionState _operatorState = RoleConnectionState.Idle; + private RoleConnectionState _nodeState = RoleConnectionState.Idle; + private string? _operatorError; + private string? _nodeError; + private bool _nodeEnabled; + + /// + /// Attempt to apply a trigger to the operator or node sub-FSM. + /// Returns true if the transition was valid, false otherwise. + /// Updates on success. + /// + public bool TryTransition(ConnectionTrigger trigger, string? detail = null) + { + if (!CanTransition(trigger)) + return false; + + ApplyTransition(trigger, detail); + RebuildSnapshot(); + return true; + } + + /// Check whether a trigger is currently valid. + public bool CanTransition(ConnectionTrigger trigger) + { + return trigger switch + { + // ─── Operator triggers ─── + ConnectionTrigger.ConnectRequested => + _operatorState is RoleConnectionState.Idle or RoleConnectionState.Error, + + ConnectionTrigger.ConnectRequestSent => + _operatorState == RoleConnectionState.Connecting, + + ConnectionTrigger.ChallengeReceived => + _operatorState == RoleConnectionState.Connecting, + + ConnectionTrigger.WebSocketConnected => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Error, + + ConnectionTrigger.HandshakeSucceeded => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Error, + + ConnectionTrigger.PairingPending => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Connected or RoleConnectionState.Error, + + ConnectionTrigger.PairingApproved => + _operatorState == RoleConnectionState.PairingRequired, + + ConnectionTrigger.PairingRejected => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.PairingRequired, + + ConnectionTrigger.AuthenticationFailed => + _operatorState == RoleConnectionState.Connecting, + + ConnectionTrigger.RateLimited => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Connected, + + ConnectionTrigger.WebSocketDisconnected => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Connected + or RoleConnectionState.PairingRequired, + + ConnectionTrigger.WebSocketError => + _operatorState is RoleConnectionState.Connecting or RoleConnectionState.Connected, + + ConnectionTrigger.DisconnectRequested => + _operatorState is not RoleConnectionState.Idle, + + ConnectionTrigger.ReconnectScheduled => + _operatorState == RoleConnectionState.Error, + + ConnectionTrigger.ReconnectSuppressed => + _operatorState == RoleConnectionState.Error, + + ConnectionTrigger.Cancelled => + _operatorState == RoleConnectionState.Connecting, + + ConnectionTrigger.Disposed => + true, // from any state + + // ─── Node triggers ─── + ConnectionTrigger.NodeConnected => + _nodeState is RoleConnectionState.Connecting or RoleConnectionState.Idle, + + ConnectionTrigger.NodeDisconnected => + _nodeState is not RoleConnectionState.Idle and not RoleConnectionState.Disabled, + + ConnectionTrigger.NodePairingRequired => + _nodeState is RoleConnectionState.Connecting or RoleConnectionState.Connected or RoleConnectionState.Error, + + ConnectionTrigger.NodePaired => + _nodeState == RoleConnectionState.PairingRequired, + + ConnectionTrigger.NodePairingRejected => + _nodeState is RoleConnectionState.Connecting or RoleConnectionState.PairingRequired, + + ConnectionTrigger.NodeError => + _nodeState is not RoleConnectionState.Idle and not RoleConnectionState.Disabled, + + ConnectionTrigger.NodeRateLimited => + _nodeState is RoleConnectionState.Connecting or RoleConnectionState.Connected, + + _ => false + }; + } + + /// Set node enabled/disabled. Updates snapshot. + public void SetNodeEnabled(bool enabled) + { + _nodeEnabled = enabled; + if (!enabled) + _nodeState = RoleConnectionState.Disabled; + else if (_nodeState == RoleConnectionState.Disabled) + _nodeState = RoleConnectionState.Idle; + RebuildSnapshot(); + } + + /// Reset to idle state. + public void Reset() + { + _operatorState = RoleConnectionState.Idle; + _nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled; + _operatorError = null; + _nodeError = null; + RebuildSnapshot(); + } + + /// Start the node sub-FSM in Connecting state. + public void StartNodeConnecting() + { + if (_nodeState is RoleConnectionState.Idle or RoleConnectionState.Error) + { + _nodeState = RoleConnectionState.Connecting; + _nodeError = null; + RebuildSnapshot(); + } + } + + /// Update the operator device ID in the snapshot. + internal void SetOperatorDeviceId(string? deviceId) + { + Current = Current with { OperatorDeviceId = deviceId }; + } + + /// Update node info (device ID, pairing status, optional request ID) in the snapshot. + internal void SetNodeInfo(string? deviceId, OpenClaw.Shared.PairingStatus pairingStatus, string? pairingRequestId = null) + { + Current = Current with + { + NodeDeviceId = deviceId, + NodePairingStatus = pairingStatus, + NodePairingRequestId = pairingRequestId ?? Current.NodePairingRequestId + }; + } + + /// Update the operator pairing request ID in the snapshot. + internal void SetOperatorPairingRequestId(string? requestId) + { + Current = Current with { OperatorPairingRequestId = requestId }; + } + + private void ApplyTransition(ConnectionTrigger trigger, string? detail) + { + switch (trigger) + { + // ─── Operator transitions ─── + case ConnectionTrigger.ConnectRequested: + _operatorState = RoleConnectionState.Connecting; + _operatorError = null; + break; + + case ConnectionTrigger.ConnectRequestSent: + case ConnectionTrigger.ChallengeReceived: + case ConnectionTrigger.WebSocketConnected: + // Stay in Connecting — these are sub-steps of the connect sequence + break; + + case ConnectionTrigger.HandshakeSucceeded: + _operatorState = RoleConnectionState.Connected; + _operatorError = null; + break; + + case ConnectionTrigger.PairingPending: + _operatorState = RoleConnectionState.PairingRequired; + break; + + case ConnectionTrigger.PairingApproved: + _operatorState = RoleConnectionState.Connecting; + break; + + case ConnectionTrigger.PairingRejected: + _operatorState = RoleConnectionState.Error; + _operatorError = detail ?? "Pairing rejected"; + break; + + case ConnectionTrigger.AuthenticationFailed: + _operatorState = RoleConnectionState.Error; + _operatorError = detail ?? "Authentication failed"; + break; + + case ConnectionTrigger.RateLimited: + _operatorState = RoleConnectionState.Error; + _operatorError = detail ?? "Rate limited"; + break; + + case ConnectionTrigger.WebSocketDisconnected: + if (_operatorState == RoleConnectionState.PairingRequired) + { + // Gateway closes WebSocket after PAIRING_REQUIRED — stay in PairingRequired + // (don't transition to Error; user needs to approve then reconnect) + } + else + { + _operatorState = RoleConnectionState.Connecting; + _operatorError = null; + } + break; + + case ConnectionTrigger.WebSocketError: + _operatorState = RoleConnectionState.Error; + _operatorError = detail ?? "WebSocket error"; + break; + + case ConnectionTrigger.DisconnectRequested: + case ConnectionTrigger.Disposed: + _operatorState = RoleConnectionState.Idle; + _nodeState = _nodeEnabled ? RoleConnectionState.Idle : RoleConnectionState.Disabled; + _operatorError = null; + _nodeError = null; + break; + + case ConnectionTrigger.ReconnectScheduled: + _operatorState = RoleConnectionState.Connecting; + _operatorError = null; + break; + + case ConnectionTrigger.ReconnectSuppressed: + // No-op; stay in Error + break; + + case ConnectionTrigger.Cancelled: + _operatorState = RoleConnectionState.Idle; + _operatorError = null; + break; + + // ─── Node transitions ─── + case ConnectionTrigger.NodeConnected: + _nodeState = RoleConnectionState.Connected; + _nodeError = null; + break; + + case ConnectionTrigger.NodeDisconnected: + _nodeState = RoleConnectionState.Idle; + _nodeError = null; + break; + + case ConnectionTrigger.NodePairingRequired: + _nodeState = RoleConnectionState.PairingRequired; + break; + + case ConnectionTrigger.NodePaired: + _nodeState = RoleConnectionState.Connected; + _nodeError = null; + break; + + case ConnectionTrigger.NodePairingRejected: + _nodeState = RoleConnectionState.PairingRejected; + _nodeError = detail ?? "Node pairing rejected"; + break; + + case ConnectionTrigger.NodeError: + _nodeState = RoleConnectionState.Error; + _nodeError = detail ?? "Node error"; + break; + + case ConnectionTrigger.NodeRateLimited: + _nodeState = RoleConnectionState.RateLimited; + _nodeError = detail ?? "Node rate limited"; + break; + } + } + + private void RebuildSnapshot() + { + Current = Current with + { + OverallState = GatewayConnectionSnapshot.DeriveOverall(_operatorState, _nodeState, _nodeEnabled), + OperatorState = _operatorState, + OperatorError = _operatorError, + OperatorPairingRequired = _operatorState == RoleConnectionState.PairingRequired, + // Clear requestId when no longer in PairingRequired to prevent stale reads + OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired + ? Current.OperatorPairingRequestId : null, + NodeState = _nodeState, + NodeError = _nodeError, + // Clear requestId when no longer in PairingRequired to prevent stale reads + NodePairingRequestId = _nodeState == RoleConnectionState.PairingRequired + ? Current.NodePairingRequestId : null, + NodePairingStatus = _nodeState switch + { + RoleConnectionState.PairingRequired => OpenClaw.Shared.PairingStatus.Pending, + RoleConnectionState.PairingRejected => OpenClaw.Shared.PairingStatus.Rejected, + RoleConnectionState.Connected => OpenClaw.Shared.PairingStatus.Paired, + _ => OpenClaw.Shared.PairingStatus.Unknown + } + }; + } +} diff --git a/src/OpenClaw.Connection/ConnectionTrigger.cs b/src/OpenClaw.Connection/ConnectionTrigger.cs new file mode 100644 index 000000000..34adfd1f7 --- /dev/null +++ b/src/OpenClaw.Connection/ConnectionTrigger.cs @@ -0,0 +1,35 @@ +namespace OpenClaw.Connection; + +/// +/// Triggers that drive operator and node sub-FSM transitions. +/// +internal enum ConnectionTrigger +{ + // ─── Operator lifecycle ─── + ConnectRequested, + ConnectRequestSent, + ChallengeReceived, + WebSocketConnected, + HandshakeSucceeded, + PairingPending, + PairingApproved, + PairingRejected, + AuthenticationFailed, + RateLimited, + WebSocketDisconnected, + WebSocketError, + DisconnectRequested, + ReconnectScheduled, + ReconnectSuppressed, + Cancelled, + Disposed, + + // ─── Node lifecycle (independent sub-FSM) ─── + NodeConnected, + NodeDisconnected, + NodePairingRequired, + NodePaired, + NodePairingRejected, + NodeError, + NodeRateLimited +} diff --git a/src/OpenClaw.Connection/CredentialResolver.cs b/src/OpenClaw.Connection/CredentialResolver.cs new file mode 100644 index 000000000..17da24646 --- /dev/null +++ b/src/OpenClaw.Connection/CredentialResolver.cs @@ -0,0 +1,70 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Canonical credential resolver implementing the northstar resolution order. +/// +/// Operator order: DeviceToken → SharedGatewayToken → BootstrapToken → null. +/// Node order: NodeDeviceToken → SharedGatewayToken → BootstrapToken → null. +/// +/// +/// Critical invariant: a stored device token ALWAYS wins over shared/bootstrap tokens. +/// Returning a bootstrap token for a paired device would downgrade scopes and may +/// trigger unnecessary re-pairing. +/// +/// +public sealed class CredentialResolver : ICredentialResolver +{ + public const string SourceDeviceToken = "identity.DeviceToken"; + public const string SourceNodeDeviceToken = "identity.NodeDeviceToken"; + public const string SourceSharedGatewayToken = "record.SharedGatewayToken"; + public const string SourceBootstrapToken = "record.BootstrapToken"; + + private readonly IDeviceIdentityReader _identityReader; + + public CredentialResolver(IDeviceIdentityReader identityReader) + { + _identityReader = identityReader ?? throw new ArgumentNullException(nameof(identityReader)); + } + + public GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath) + { + ArgumentNullException.ThrowIfNull(record); + + // 1. Paired device token — highest priority, never downgrade + var storedToken = _identityReader.TryReadStoredDeviceToken(identityPath); + if (!string.IsNullOrWhiteSpace(storedToken)) + return new GatewayCredential(storedToken!, false, SourceDeviceToken); + + // 2. Shared gateway token — works for any device, full scopes + if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + return new GatewayCredential(record.SharedGatewayToken!, false, SourceSharedGatewayToken); + + // 3. Bootstrap token — one-time setup, limited scopes + if (!string.IsNullOrWhiteSpace(record.BootstrapToken)) + return new GatewayCredential(record.BootstrapToken!, true, SourceBootstrapToken); + + return null; + } + + public GatewayCredential? ResolveNode(GatewayRecord record, string identityPath) + { + ArgumentNullException.ThrowIfNull(record); + + // 1. Paired node token — highest priority + var storedToken = _identityReader.TryReadStoredNodeDeviceToken(identityPath); + if (!string.IsNullOrWhiteSpace(storedToken)) + return new GatewayCredential(storedToken!, false, SourceNodeDeviceToken); + + // 2. Shared gateway token + if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + return new GatewayCredential(record.SharedGatewayToken!, false, SourceSharedGatewayToken); + + // 3. Bootstrap token + if (!string.IsNullOrWhiteSpace(record.BootstrapToken)) + return new GatewayCredential(record.BootstrapToken!, true, SourceBootstrapToken); + + return null; + } +} diff --git a/src/OpenClaw.Connection/DeviceIdentityStore.cs b/src/OpenClaw.Connection/DeviceIdentityStore.cs new file mode 100644 index 000000000..1c491e8bf --- /dev/null +++ b/src/OpenClaw.Connection/DeviceIdentityStore.cs @@ -0,0 +1,68 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Implementation of IDeviceIdentityStore that delegates to DeviceIdentity. +/// Used by the manager to write device tokens received from the gateway. +/// +public sealed class DeviceIdentityStore : IDeviceIdentityStore +{ + private readonly IOpenClawLogger _logger; + + public DeviceIdentityStore(IOpenClawLogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public void StoreToken(string identityPath, string token, string[]? scopes, string role) + { + try + { + var identity = new DeviceIdentity(identityPath, _logger); + identity.Initialize(); + identity.StoreDeviceTokenForRole(role, token, scopes); + _logger.Info($"[IdentityStore] Stored {role} device token at {identityPath}"); + } + catch (Exception ex) + { + _logger.Error($"[IdentityStore] Failed to store {role} device token: {ex.Message}"); + } + } + + /// + /// Clear stored device tokens from an identity file, keeping the keypair intact. + /// Strips DeviceToken, DeviceTokenScopes, NodeDeviceToken, and NodeDeviceTokenScopes + /// from the identity JSON while preserving keys, deviceId, algorithm, etc. + /// + public static void ClearStoredTokens(string identityDir, IOpenClawLogger? logger = null) + { + var keyPath = Path.Combine(identityDir, "device-key-ed25519.json"); + if (!File.Exists(keyPath)) return; + try + { + var json = File.ReadAllText(keyPath); + var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + + using var ms = new MemoryStream(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms, new System.Text.Json.JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name is "DeviceToken" or "DeviceTokenScopes" or "NodeDeviceToken" or "NodeDeviceTokenScopes") + continue; + prop.WriteTo(writer); + } + writer.WriteEndObject(); + writer.Flush(); + + File.WriteAllBytes(keyPath, ms.ToArray()); + logger?.Info($"[IdentityStore] Cleared stored device tokens from {identityDir}"); + } + catch (Exception ex) + { + logger?.Warn($"[IdentityStore] Failed to clear device tokens: {ex.Message}"); + } + } +} diff --git a/src/OpenClaw.Connection/GatewayClientFactory.cs b/src/OpenClaw.Connection/GatewayClientFactory.cs new file mode 100644 index 000000000..dc96d605e --- /dev/null +++ b/src/OpenClaw.Connection/GatewayClientFactory.cs @@ -0,0 +1,52 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Wraps behind . +/// Creates a real WebSocket-connected client instance. +/// +public sealed class GatewayClientFactory : IGatewayClientFactory +{ + public IGatewayClientLifecycle Create( + string gatewayUrl, + GatewayCredential credential, + string identityPath, + IOpenClawLogger logger) + { + var client = new OpenClawGatewayClient( + gatewayUrl, + credential.Token, + logger, + tokenIsBootstrapToken: credential.IsBootstrapToken, + identityPath: identityPath); + + return new GatewayClientLifecycleAdapter(client); + } +} + +/// +/// Adapts (which inherits from +/// ) to the interface. +/// +internal sealed class GatewayClientLifecycleAdapter : IGatewayClientLifecycle +{ + private readonly OpenClawGatewayClient _client; + + public GatewayClientLifecycleAdapter(OpenClawGatewayClient client) + { + _client = client; + // Forward events from WebSocketClientBase + _client.StatusChanged += (s, e) => StatusChanged?.Invoke(this, e); + _client.AuthenticationFailed += (s, e) => AuthenticationFailed?.Invoke(this, e); + } + + public OpenClawGatewayClient DataClient => _client; + + public event EventHandler? StatusChanged; + public event EventHandler? AuthenticationFailed; + + public Task ConnectAsync(CancellationToken ct) => _client.ConnectAsync(); + + public void Dispose() => _client.Dispose(); +} diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs new file mode 100644 index 000000000..cee42838c --- /dev/null +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -0,0 +1,1144 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// GatewayConnectionManager — single owner of connection lifecycle. +/// Phase 2.1: Shell with state machine, diagnostics, and stub lifecycle methods. +/// Real client creation is added in Step 2.2a. +/// +public sealed class GatewayConnectionManager : IGatewayConnectionManager +{ + private readonly ConnectionStateMachine _stateMachine = new(); + private readonly ConnectionDiagnostics _diagnostics; + private readonly ICredentialResolver _credentialResolver; + private readonly IGatewayClientFactory _clientFactory; + private readonly GatewayRegistry _registry; + private readonly IOpenClawLogger _logger; + private readonly IDeviceIdentityStore? _identityStore; + private readonly INodeConnector? _nodeConnector; + private readonly ISshTunnelManager? _tunnelManager; + private readonly Func? _isNodeEnabled; + private readonly IClock _clock; + private readonly Func? _shouldStartNodeConnection; + private readonly SemaphoreSlim _transitionSemaphore = new(1, 1); + + private long _generation; + private CancellationTokenSource? _operationCts; + private IGatewayClientLifecycle? _activeLifecycle; + private string? _activeIdentityPath; // identity directory for the active connection + private string? _activeGatewayRecordId; // gateway record ID for node credential resolution + private bool _disposed; + private bool _gatewayNeedsV2Signature; // remembered across reconnects + private string? _lastAutoApprovedRequestId; // prevent auto-approve loops + private string? _autoApproveInFlight; // atomic guard against concurrent approval of same requestId + + public event EventHandler? StateChanged; + public event EventHandler? DiagnosticEvent; + public event EventHandler? OperatorClientChanged; + + public GatewayConnectionManager( + ICredentialResolver credentialResolver, + IGatewayClientFactory clientFactory, + GatewayRegistry registry, + IOpenClawLogger logger, + IClock? clock = null, + IDeviceIdentityStore? identityStore = null, + INodeConnector? nodeConnector = null, + Func? isNodeEnabled = null, + ConnectionDiagnostics? diagnostics = null, + ISshTunnelManager? tunnelManager = null, + Func? shouldStartNodeConnection = null) + { + _credentialResolver = credentialResolver ?? throw new ArgumentNullException(nameof(credentialResolver)); + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _identityStore = identityStore; + _nodeConnector = nodeConnector; + _tunnelManager = tunnelManager; + _isNodeEnabled = isNodeEnabled; + _clock = clock ?? SystemClock.Instance; + _shouldStartNodeConnection = shouldStartNodeConnection; + _diagnostics = diagnostics ?? new ConnectionDiagnostics(clock: clock); + _diagnostics.EventRecorded += (_, e) => DiagnosticEvent?.Invoke(this, e); + + if (_nodeConnector != null) + { + _nodeConnector.StatusChanged += OnNodeStatusChanged; + _nodeConnector.PairingStatusChanged += OnNodePairingStatusChanged; + } + } + + // ─── State ─── + + public GatewayConnectionSnapshot CurrentSnapshot => _stateMachine.Current; + public string? ActiveGatewayUrl => _stateMachine.Current.GatewayUrl; + public IOperatorGatewayClient? OperatorClient => _activeLifecycle?.DataClient; + /// Internal access to the concrete client for auto-approve and other manager-internal operations. + internal OpenClawGatewayClient? ConcreteOperatorClient => _activeLifecycle?.DataClient; + public ConnectionDiagnostics Diagnostics => _diagnostics; + + // ─── Lifecycle ─── + + public async Task ConnectAsync(string? gatewayId = null) + { + ThrowIfDisposed(); + await _transitionSemaphore.WaitAsync(); + try + { + await ConnectCoreAsync(gatewayId); + } + finally + { + _transitionSemaphore.Release(); + } + } + + /// Core connect logic. Caller must hold . + private async Task ConnectCoreAsync(string? gatewayId = null) + { + var id = gatewayId ?? _registry.ActiveGatewayId; + if (id == null) + { + _logger.Warn("[ConnMgr] No gateway ID specified and no active gateway"); + return; + } + + var record = _registry.GetById(id); + if (record == null) + { + _logger.Warn($"[ConnMgr] Gateway {id} not found in registry"); + return; + } + + if (!_stateMachine.CanTransition(ConnectionTrigger.ConnectRequested)) + { + _logger.Warn($"[ConnMgr] Cannot connect from state {_stateMachine.Current.OperatorState}"); + return; + } + + // Cancel any in-flight operation + var gen = Interlocked.Increment(ref _generation); + var oldCts = Interlocked.Exchange(ref _operationCts, new CancellationTokenSource()); + oldCts?.Cancel(); + oldCts?.Dispose(); + + // Dispose old client + DisposeActiveClient(); + + // Update snapshot with gateway info + _stateMachine.Current = _stateMachine.Current with + { + GatewayId = record.Id, + GatewayUrl = record.Url, + GatewayName = record.FriendlyName + }; + + // Per-gateway identity directory — each gateway has its own keypair + tokens + var perGatewayIdentityDir = _registry.GetIdentityDirectory(record.Id); + if (!Directory.Exists(perGatewayIdentityDir)) + Directory.CreateDirectory(perGatewayIdentityDir); + + var credential = _credentialResolver.ResolveOperator(record, perGatewayIdentityDir); + _diagnostics.RecordCredentialResolution(credential); + _activeIdentityPath = perGatewayIdentityDir; + _activeGatewayRecordId = record.Id; + + if (credential == null) + { + _logger.Warn("[ConnMgr] No credential available for gateway"); + var prev = _stateMachine.Current.OverallState; + // Must go through Connecting → Error since AuthenticationFailed requires Connecting state + _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); + _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available"); + EmitStateChanged(prev); + return; + } + + // Transition to Connecting + var prevState = _stateMachine.Current.OverallState; + _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); + _diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState); + EmitStateChanged(prevState); + + // Create client via factory — use a diagnostic-tee logger so client handshake + // logs appear in the Connection Status window timeline. + // When SSH tunnel is configured, start the tunnel and connect to the local URL. + var connectUrl = record.Url; + if (record.SshTunnel != null && _tunnelManager != null) + { + var tunnel = record.SshTunnel; + if (string.IsNullOrWhiteSpace(tunnel.User) || string.IsNullOrWhiteSpace(tunnel.Host) || + tunnel.RemotePort is < 1 or > 65535 || tunnel.LocalPort is < 1 or > 65535) + { + _logger.Warn("[ConnMgr] SSH tunnel config is incomplete"); + _diagnostics.Record("tunnel", "SSH tunnel config is incomplete"); + var p = _stateMachine.Current.OverallState; + _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "SSH tunnel config is incomplete"); + EmitStateChanged(p); + return; + } + try + { + connectUrl = await _tunnelManager.StartAsync(tunnel, _operationCts!.Token); + _diagnostics.Record("tunnel", $"SSH tunnel started → {connectUrl}"); + } + catch (Exception ex) + { + _logger.Error($"[ConnMgr] SSH tunnel start failed: {ex.Message}"); + _diagnostics.Record("tunnel", "SSH tunnel start failed", ex.Message); + var p = _stateMachine.Current.OverallState; + _stateMachine.TryTransition(ConnectionTrigger.WebSocketError, $"SSH tunnel failed: {ex.Message}"); + EmitStateChanged(p); + return; + } + } + else if (record.SshTunnel != null) + { + // Tunnel config present but no tunnel manager — use local URL directly + connectUrl = $"ws://localhost:{record.SshTunnel.LocalPort}"; + } + var diagLogger = new DiagnosticTeeLogger(_logger, _diagnostics); + var lifecycle = _clientFactory.Create(connectUrl, credential, perGatewayIdentityDir, diagLogger); + _activeLifecycle = lifecycle; + OperatorClientChanged?.Invoke(this, new OperatorClientChangedEventArgs + { + OldClient = null, + NewClient = lifecycle.DataClient + }); + + // Subscribe to client events with generation guard + lifecycle.StatusChanged += (s, status) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = HandleOperatorStatusChangedAsync(status, gen); + }; + lifecycle.AuthenticationFailed += (s, msg) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = HandleAuthenticationFailedAsync(msg, gen); + }; + lifecycle.DataClient.HandshakeSucceeded += (s, e) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = HandleHandshakeSucceededAsync(gen); + }; + lifecycle.DataClient.DeviceTokenReceived += (s, e) => + { + if (Interlocked.Read(ref _generation) != gen) return; + HandleDeviceTokenReceived(e); + }; + lifecycle.DataClient.PairingRequired += (s, requestId) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = HandlePairingRequiredAsync(requestId, gen); + }; + lifecycle.DataClient.V2SignatureFallback += (s, _) => + { + _gatewayNeedsV2Signature = true; + }; + + // Operator-side auto-approve: the node-side WindowsNodeClient + // only sees its own pairing state. But when the gateway broadcasts + // a node.pair.requested event, the OPERATOR client gets a fresh + // NodePairListUpdated push. If our own node deviceId shows up in + // that pending list and we have approval scopes, approve it + // immediately — otherwise the user is stuck looking at a connected + // node with empty capabilities until they manually approve. + // This complements (not replaces) the node-side flow on line ~810 + // which still handles the case where the node knows it's pending + // before any operator event lands. + lifecycle.DataClient.NodePairListUpdated += (s, info) => + { + if (Interlocked.Read(ref _generation) != gen) return; + _ = TryOperatorAutoApproveOwnNodePairAsync(info, gen); + }; + + // If we already know this gateway needs v2, tell the client upfront + if (_gatewayNeedsV2Signature) + lifecycle.DataClient.UseV2Signature = true; + + // Connect (fire and forget — the event handlers will drive state transitions) + var ct = _operationCts!.Token; + _ = Task.Run(async () => + { + try + { + await lifecycle.ConnectAsync(ct); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.Error($"[ConnMgr] Connect failed: {ex.Message}"); + } + }, ct); + } + + public async Task DisconnectAsync() + { + ThrowIfDisposed(); + await _transitionSemaphore.WaitAsync(); + try + { + DisconnectCore(); + } + finally + { + _transitionSemaphore.Release(); + } + } + + /// Core disconnect logic. Caller must hold . + private void DisconnectCore() + { + var prev = _stateMachine.Current.OverallState; + DisposeActiveClient(); + _stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested); + _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + EmitStateChanged(prev); + } + + public async Task ReconnectAsync() + { + ThrowIfDisposed(); + await _transitionSemaphore.WaitAsync(); + try + { + DisconnectCore(); + await ConnectCoreAsync(); + } + finally + { + _transitionSemaphore.Release(); + } + } + + public async Task SwitchGatewayAsync(string gatewayId) + { + ThrowIfDisposed(); + await _transitionSemaphore.WaitAsync(); + try + { + DisconnectCore(); + // Stop tunnel when switching gateways — the new one may not need it. + // Use a bounded timeout to avoid blocking all connection transitions. + if (_tunnelManager?.IsActive == true) + { + try + { + var tunnelStop = _tunnelManager.StopAsync(); + if (await Task.WhenAny(tunnelStop, Task.Delay(TimeSpan.FromSeconds(5))) != tunnelStop) + _logger.Warn("[ConnMgr] Tunnel stop timed out during gateway switch"); + } + catch (Exception ex) { _logger.Warn($"[ConnMgr] Tunnel stop error on gateway switch: {ex.Message}"); } + } + _gatewayNeedsV2Signature = false; // new gateway might support v3 + _registry.SetActive(gatewayId); + await ConnectCoreAsync(gatewayId); + } + finally + { + _transitionSemaphore.Release(); + } + } + + public async Task ApplySetupCodeAsync(string setupCode) + { + ThrowIfDisposed(); + + // 1. Decode setup code + var decoded = SetupCodeDecoder.Decode(setupCode); + if (!decoded.Success || string.IsNullOrWhiteSpace(decoded.Url)) + return new SetupCodeResult(SetupCodeOutcome.InvalidCode, decoded.Error ?? "Could not decode setup code"); + + var gatewayUrl = GatewayUrlHelper.NormalizeForWebSocket(decoded.Url); + + // 2. Validate URL + if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl)) + return new SetupCodeResult(SetupCodeOutcome.InvalidUrl, "Invalid gateway URL"); + + // 3. Disconnect current gateway if any + await DisconnectAsync(); + + // New gateway URL → reset v2 signature flag (new gateway might support v3) + var isNewGateway = _registry.FindByUrl(gatewayUrl) == null; + if (isNewGateway) + _gatewayNeedsV2Signature = false; + + // 4. Create or update gateway record + var existing = _registry.FindByUrl(gatewayUrl); + var recordId = existing?.Id ?? Guid.NewGuid().ToString(); + + // Setup codes from `openclaw qr` always provide bootstrap tokens. + // Store as BootstrapToken so the credential resolver passes IsBootstrapToken=true, + // causing the client to send auth.bootstrapToken (not auth.token). + var record = (existing ?? new GatewayRecord { Id = recordId }) with + { + Url = gatewayUrl, + SharedGatewayToken = existing?.SharedGatewayToken, // preserve existing shared token if any + BootstrapToken = decoded.Token ?? existing?.BootstrapToken, + }; + _registry.AddOrUpdate(record); + _registry.SetActive(recordId); + _registry.Save(); + + // Ensure identity directory + var identityDir = _registry.GetIdentityDirectory(recordId); + if (!Directory.Exists(identityDir)) + Directory.CreateDirectory(identityDir); + + // Clear stored device tokens so we start fresh with the bootstrap token. + // The keypair (device ID) stays — only the tokens are wiped. + DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); + _diagnostics.Record("setup", $"Setup code applied for {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}"); + + // 5. Connect to new gateway + await ConnectAsync(recordId); + + return new SetupCodeResult(SetupCodeOutcome.Success, GatewayUrl: gatewayUrl); + } + + public async Task ConnectWithSharedTokenAsync( + string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null) + { + ThrowIfDisposed(); + + if (!GatewayUrlHelper.IsValidGatewayUrl(gatewayUrl)) + return new SetupCodeResult(SetupCodeOutcome.InvalidUrl, "Invalid gateway URL"); + + // Disconnect current gateway if any + await DisconnectAsync(); + + // Find or create gateway record (dedup by URL) + var existing = _registry.FindByUrl(gatewayUrl); + var recordId = existing?.Id ?? Guid.NewGuid().ToString(); + var record = (existing ?? new GatewayRecord { Id = recordId }) with + { + Url = gatewayUrl, + SharedGatewayToken = token, + SshTunnel = sshTunnel, + }; + _registry.AddOrUpdate(record); + + // Clear stored device tokens so the shared token is used + var identityDir = _registry.GetIdentityDirectory(recordId); + if (!Directory.Exists(identityDir)) + Directory.CreateDirectory(identityDir); + DeviceIdentityStore.ClearStoredTokens(identityDir, _logger); + + _registry.SetActive(recordId); + _registry.Save(); + + // Connect to the gateway + try + { + await ConnectAsync(recordId); + return new SetupCodeResult(SetupCodeOutcome.Success, GatewayUrl: gatewayUrl); + } + catch (Exception ex) + { + _logger.Error($"[ConnMgr] ConnectWithSharedToken failed: {ex.Message}"); + return new SetupCodeResult(SetupCodeOutcome.ConnectionFailed, ex.Message); + } + } + + // ─── Event Handlers ─── + + private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, long gen) + { + // Check client's pairing status directly — set synchronously before this handler runs + var isPairingPending = _activeLifecycle?.DataClient?.IsPairingRequired == true; + if (isPairingPending && status is ConnectionStatus.Disconnected or ConnectionStatus.Error) + return; + + await _transitionSemaphore.WaitAsync(); + try + { + if (Interlocked.Read(ref _generation) != gen) return; + + var prev = _stateMachine.Current.OverallState; + switch (status) + { + case ConnectionStatus.Connected: + _diagnostics.RecordWebSocketEvent("WebSocket connected"); + _stateMachine.TryTransition(ConnectionTrigger.WebSocketConnected); + break; + case ConnectionStatus.Disconnected: + _diagnostics.RecordWebSocketEvent("WebSocket disconnected"); + // Don't overwrite PairingRequired — gateway closes socket after pairing required + if (_stateMachine.Current.OperatorState != RoleConnectionState.PairingRequired) + _stateMachine.TryTransition(ConnectionTrigger.WebSocketDisconnected); + break; + case ConnectionStatus.Error: + _diagnostics.RecordWebSocketEvent("WebSocket error"); + if (_stateMachine.Current.OperatorState != RoleConnectionState.PairingRequired) + _stateMachine.TryTransition(ConnectionTrigger.WebSocketError, "Transport error"); + break; + case ConnectionStatus.Connecting: + _diagnostics.RecordWebSocketEvent("WebSocket connecting"); + break; + } + EmitStateChanged(prev); + } + finally + { + _transitionSemaphore.Release(); + } + } + + private async Task HandleAuthenticationFailedAsync(string message, long gen) + { + await _transitionSemaphore.WaitAsync(); + try + { + if (Interlocked.Read(ref _generation) != gen) return; + + var prev = _stateMachine.Current.OverallState; + _diagnostics.Record("error", "Authentication failed", message); + _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message); + EmitStateChanged(prev); + } + finally + { + _transitionSemaphore.Release(); + } + } + + private async Task HandleHandshakeSucceededAsync(long gen) + { + await _transitionSemaphore.WaitAsync(); + try + { + if (Interlocked.Read(ref _generation) != gen) return; + + var prev = _stateMachine.Current.OverallState; + _diagnostics.Record("state", "Handshake succeeded (hello-ok)"); + _stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded); + _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + + // Update device ID from client + if (_activeLifecycle?.DataClient is { } client) + { + _stateMachine.SetOperatorDeviceId(client.OperatorDeviceId); + } + + EmitStateChanged(prev); + + // Stamp LastConnected so auto-reconnect on next startup can use this gateway. + // Uses the atomic Update helper to avoid overwriting concurrent registry changes. + if (_activeGatewayRecordId != null) + { + try + { + _registry.Update(_activeGatewayRecordId, r => r with { LastConnected = _clock.UtcNow }); + _registry.Save(); + _diagnostics.Record("state", "Stamped LastConnected on gateway record"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Failed to stamp LastConnected: {ex.Message}"); + } + } + } + finally + { + _transitionSemaphore.Release(); + } + + // Start node connection outside the semaphore to avoid deadlocks + if (_nodeConnector != null && ShouldStartNodeConnection()) + { + await StartNodeConnectionAsync(); + } + } + + private void HandleDeviceTokenReceived(DeviceTokenReceivedEventArgs e) + { + _diagnostics.Record("credential", $"Device token received for {e.Role}", + $"Scopes={string.Join(",", e.Scopes ?? [])}"); + + if (_identityStore != null && _activeIdentityPath != null) + { + try + { + _identityStore.StoreToken(_activeIdentityPath, e.Token, e.Scopes, e.Role); + _logger.Info($"[ConnMgr] Persisted {e.Role} device token via identity store"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Failed to persist {e.Role} device token: {ex.Message}"); + } + } + + // Clear bootstrap token after NODE gets its device token — both roles are now paired. + // Don't clear after operator: the node still needs bootstrap for its role-upgrade pairing. + if (e.Role == "node" && _activeGatewayRecordId != null) + { + var record = _registry.GetById(_activeGatewayRecordId); + if (record?.BootstrapToken != null) + { + _registry.AddOrUpdate(record with { BootstrapToken = null }); + _registry.Save(); + _diagnostics.Record("credential", "Cleared bootstrap token — both roles paired"); + } + } + } + + private async Task HandlePairingRequiredAsync(string? requestId, long gen) + { + await _transitionSemaphore.WaitAsync(); + try + { + if (Interlocked.Read(ref _generation) != gen) return; + + var prev = _stateMachine.Current.OverallState; + _diagnostics.Record("pairing", $"Pairing required — waiting for approval (requestId={requestId})"); + _stateMachine.TryTransition(ConnectionTrigger.PairingPending); + // Store requestId in snapshot so setup flows can use it for explicit approval + _stateMachine.SetOperatorPairingRequestId(requestId); + _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + EmitStateChanged(prev); + } + finally + { + _transitionSemaphore.Release(); + } + } + + // ─── Node Connection ─── + + /// + /// Drive the node connection for the active gateway and await its terminal state. + /// See for contract. + /// + public async Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + // Honor a pre-canceled token before any side effects (Hanselman review #4). + cancellationToken.ThrowIfCancellationRequested(); + + if (_nodeConnector == null) + throw new InvalidOperationException("No node connector is configured on the manager."); + + var snapshot = _stateMachine.Current; + if (snapshot.OperatorState != RoleConnectionState.Connected) + { + throw new InvalidOperationException( + $"Operator must be Connected before EnsureNodeConnectedAsync (current: {snapshot.OperatorState})."); + } + + if (_activeGatewayRecordId == null || _activeIdentityPath == null) + throw new InvalidOperationException("No active gateway is configured."); + + // Already paired? short-circuit. (Idempotent — safe to call repeatedly.) + if (snapshot.NodeState == RoleConnectionState.Connected + && snapshot.NodePairingStatus == PairingStatus.Paired) + { + return; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Handler(object? _, GatewayConnectionSnapshot s) + { + switch (s.NodeState) + { + case RoleConnectionState.Connected + when s.NodePairingStatus == PairingStatus.Paired: + tcs.TrySetResult(true); + break; + case RoleConnectionState.PairingRejected: + tcs.TrySetException(new InvalidOperationException( + s.NodeError ?? "Node pairing was rejected by the gateway.")); + break; + case RoleConnectionState.Error: + tcs.TrySetException(new InvalidOperationException( + s.NodeError ?? "Node connection failed.")); + break; + // PairingRequired / Connecting / Idle — keep waiting; the manager's + // existing auto-approve flow (OnNodePairingStatusChanged) handles the + // node.pair.approve case when operator has admin/pairing scope. The + // role-upgrade pending-device-pair case surfaces as a timeout (the + // gateway parks the connect without responding) — caller catches and + // runs the WSL CLI device-approver before retrying. + } + } + + StateChanged += Handler; + try + { + var startAttempted = await StartNodeConnectionAsync(); + + if (!startAttempted) + { + tcs.TrySetException(new InvalidOperationException( + "Node connection could not be started — see ConnectionDiagnostics for the credential/record-resolution failure.")); + } + else + { + // Re-evaluate state in case the connector reached terminal state synchronously + // (test connectors may; production NodeConnector is async). + Handler(this, _stateMachine.Current); + } + + // Hanselman review #3: only apply the default 35s timeout when the caller + // didn't supply a cancellable token. A caller that DOES pass one is signaling + // they own the deadline (e.g. setup engine with its own retry budget). + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (!cancellationToken.CanBeCanceled) + { + cts.CancelAfter(TimeSpan.FromSeconds(35)); + } + + try + { + await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("Timed out waiting for the node to connect and pair with the gateway."); + } + } + finally + { + StateChanged -= Handler; + } + } + + private bool ShouldStartNodeConnection() + { + if (_activeGatewayRecordId == null || _activeIdentityPath == null) + return _isNodeEnabled?.Invoke() ?? false; + + var record = _registry.GetById(_activeGatewayRecordId); + if (record == null) + return false; + + if (_shouldStartNodeConnection != null) + return _shouldStartNodeConnection(record, _activeIdentityPath); + + return _isNodeEnabled?.Invoke() ?? false; + } + + private async Task StartNodeConnectionAsync() + { + if (_nodeConnector == null || _activeGatewayRecordId == null || _activeIdentityPath == null) return false; + + var record = _registry.GetById(_activeGatewayRecordId); + if (record == null) + { + _logger.Warn("[ConnMgr] Cannot start node — gateway record not found"); + return false; + } + + // Use root identity path — clients always read/write from root, not per-gateway + var nodeCredential = _credentialResolver.ResolveNode(record, _activeIdentityPath!); + if (nodeCredential == null) + { + _logger.Warn("[ConnMgr] No node credential available — skipping node connection"); + _diagnostics.Record("node", "No node credential available"); + return false; + } + + // Mark node as enabled in the state machine so UI reflects node state + // State machine is not thread-safe — acquire semaphore for mutation + await _transitionSemaphore.WaitAsync(); + try + { + _stateMachine.SetNodeEnabled(true); + } + finally + { + _transitionSemaphore.Release(); + } + + var nodeConnectUrl = record.SshTunnel != null + ? $"ws://localhost:{record.SshTunnel.LocalPort}" + : record.Url; + + _diagnostics.Record("node", $"Starting node connection to {nodeConnectUrl}", + $"Credential source: {nodeCredential.Source}"); + + try + { + await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, _activeIdentityPath, + useV2Signature: _gatewayNeedsV2Signature); + } + catch (Exception ex) + { + _logger.Error($"[ConnMgr] Node connect failed: {ex.Message}"); + _diagnostics.Record("node", "Node connect failed", ex.Message); + } + + return true; + } + + private async void OnNodeStatusChanged(object? sender, ConnectionStatus status) + { + _diagnostics.Record("node", $"Node status: {status}"); + + // Check connector's pairing status directly — it's set synchronously + // before this handler runs, so it's always up-to-date + var connectorPairingStatus = _nodeConnector?.PairingStatus; + var isPairingPending = connectorPairingStatus == PairingStatus.Pending; + + if (isPairingPending && status is ConnectionStatus.Disconnected or ConnectionStatus.Error) + return; + + await _transitionSemaphore.WaitAsync(); + try + { + var prev = _stateMachine.Current.OverallState; + switch (status) + { + case ConnectionStatus.Connected: + _stateMachine.TryTransition(ConnectionTrigger.NodeConnected); + break; + case ConnectionStatus.Connecting: + _stateMachine.StartNodeConnecting(); + break; + case ConnectionStatus.Disconnected: + if (_stateMachine.Current.NodeState != RoleConnectionState.PairingRequired) + _stateMachine.TryTransition(ConnectionTrigger.NodeDisconnected); + break; + case ConnectionStatus.Error: + if (_stateMachine.Current.NodeState != RoleConnectionState.PairingRequired) + _stateMachine.TryTransition(ConnectionTrigger.NodeError, "Node transport error"); + break; + } + + // Update node state in snapshot + if (_nodeConnector != null) + { + _stateMachine.SetNodeInfo(_nodeConnector.NodeDeviceId, _nodeConnector.PairingStatus); + } + + EmitStateChanged(prev); + } + finally + { + _transitionSemaphore.Release(); + } + } + + private async void OnNodePairingStatusChanged(object? sender, PairingStatusEventArgs e) + { + _diagnostics.Record("node", $"Node pairing: {e.Status}"); + + await _transitionSemaphore.WaitAsync(); + try + { + var prev = _stateMachine.Current.OverallState; + switch (e.Status) + { + case PairingStatus.Paired: + _stateMachine.TryTransition(ConnectionTrigger.NodePaired); + break; + case PairingStatus.Pending: + _stateMachine.TryTransition(ConnectionTrigger.NodePairingRequired); + break; + case PairingStatus.Rejected: + _stateMachine.TryTransition(ConnectionTrigger.NodePairingRejected); + break; + } + + // Update snapshot + if (_nodeConnector != null) + { + _stateMachine.SetNodeInfo(_nodeConnector.NodeDeviceId, _nodeConnector.PairingStatus, e.RequestId); + } + + EmitStateChanged(prev); + } + finally + { + _transitionSemaphore.Release(); + } + + // Auto-approve node pairing if operator has admin/pairing scope. + // _autoApproveInFlight is a CAS guard scoped to JUST the approve RPC — + // we release it before the reconnect delay so unrelated approvals + // (different requestIds) aren't starved while we wait for the gateway + // and node-reconnect handshake to settle (which can take 5–30s on + // first connect via WSL cold-start). + if (e.Status == PairingStatus.Pending && !string.IsNullOrWhiteSpace(e.RequestId) + && e.RequestId != _lastAutoApprovedRequestId) + { + if (Interlocked.CompareExchange(ref _autoApproveInFlight, e.RequestId, null) != null) + { + return; + } + + var approvalGeneration = Interlocked.Read(ref _generation); + bool attemptedApprove = false; + bool approved = false; + try + { + var operatorClient = _activeLifecycle?.DataClient; + if (operatorClient?.IsConnectedToGateway == true) + { + var scopes = operatorClient.GrantedOperatorScopes; + var canApprove = OperatorScopeHelper.CanApproveDevices(scopes); + + if (canApprove) + { + _diagnostics.Record("node", $"Auto-approving node pairing (requestId={e.RequestId})"); + try + { + attemptedApprove = true; + approved = await operatorClient.NodePairApproveAsync(e.RequestId); + if (!approved) + _diagnostics.Record("node", "Node auto-approval failed"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Node auto-approve failed: {ex.Message}"); + _diagnostics.Record("node", $"Auto-approve error: {ex.Message}"); + } + } + } + } + finally + { + // Only dedupe after an actual approve attempt. If the operator + // client was disconnected or lacked scope, the operator-side + // NodePairListUpdated path must still be able to approve this + // same requestId once the operator is ready. + if (attemptedApprove && Interlocked.Read(ref _generation) == approvalGeneration) + _lastAutoApprovedRequestId = e.RequestId; + Interlocked.Exchange(ref _autoApproveInFlight, null); + } + + // Post-approve reconnect happens OUTSIDE the CAS guard so it + // doesn't block unrelated approvals. + if (approved) + { + _diagnostics.Record("node", "Node pairing auto-approved — reconnecting node"); + await Task.Delay(1000); // brief delay for gateway to process + if (Interlocked.Read(ref _generation) == approvalGeneration) + await StartNodeConnectionAsync(); + } + } + } + + /// + /// Operator-side auto-approve. When the gateway pushes + /// and there is a + /// pending entry for our OWN node's deviceId, approve it. The node-side + /// auto-approve at handles the + /// case where the node already knows it is pending; this method handles + /// the case where the node is device-paired (its WindowsNodeClient sees + /// itself as Paired) but its node-sub-pairing hasn't been approved yet — + /// the only signal for that case is the operator-side broadcast. + /// + private async Task TryOperatorAutoApproveOwnNodePairAsync(PairingListInfo? info, long gen) + { + if (info?.Pending == null || info.Pending.Count == 0) return; + + var ownNodeId = _nodeConnector?.NodeDeviceId; + if (string.IsNullOrWhiteSpace(ownNodeId)) return; + + var operatorClient = _activeLifecycle?.DataClient; + if (operatorClient?.IsConnectedToGateway != true) return; + if (!OperatorScopeHelper.CanApproveDevices(operatorClient.GrantedOperatorScopes)) return; + + // Track whether ANY approve succeeded so we know to schedule one + // reconnect at the end (rather than reconnecting per-entry, which + // would race with itself). + string? lastApprovedRequestId = null; + + foreach (var req in info.Pending) + { + if (Interlocked.Read(ref _generation) != gen) return; + if (string.IsNullOrWhiteSpace(req.RequestId)) continue; + if (req.RequestId == _lastAutoApprovedRequestId) continue; + if (!string.Equals(req.NodeId, ownNodeId, StringComparison.OrdinalIgnoreCase)) continue; + + // CAS guard scoped to JUST the approve RPC. Release before the + // post-approve reconnect so unrelated approvals are not starved + // (e.g. another own-node pending with a different requestId in + // the same or next snapshot). + if (Interlocked.CompareExchange(ref _autoApproveInFlight, req.RequestId, null) != null) + continue; + + bool approved = false; + try + { + _diagnostics.Record("node", $"Operator-side auto-approving own node pairing (requestId={req.RequestId})"); + try + { + approved = await operatorClient.NodePairApproveAsync(req.RequestId); + if (!approved) + _diagnostics.Record("node", "Operator-side node pair approval rejected by gateway"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Operator-side node auto-approve failed: {ex.Message}"); + _diagnostics.Record("node", $"Operator-side auto-approve error: {ex.Message}"); + } + } + finally + { + // Always record the requestId — both on success (prevent + // re-approving the same id after the gateway re-broadcasts) + // and on failure (prevent a spin loop on a rejected id). + // Re-check generation: if a reconnect happened during the + // await above, DisposeActiveClient already cleared + // _lastAutoApprovedRequestId for the new generation; we must + // not overwrite that null with a stale id from the old gen. + if (Interlocked.Read(ref _generation) == gen) + _lastAutoApprovedRequestId = req.RequestId; + Interlocked.Exchange(ref _autoApproveInFlight, null); + } + + if (approved) + { + lastApprovedRequestId = req.RequestId; + // Continue scanning so a second own-pending in the same + // snapshot (e.g. stale requestId from prior session) also + // gets attempted — broken only by an explicit failure to + // approve, which we still try the next entry for. + } + // On failure/rejection, fall through to next own-pending entry + // rather than break — the gateway may not re-broadcast if the + // approve frame round-tripped and was rejected mid-flight. + } + + // Single reconnect after the snapshot is fully processed. + if (lastApprovedRequestId is not null) + { + _diagnostics.Record("node", $"Operator-side approved {lastApprovedRequestId} — reconnecting node so caps propagate"); + await Task.Delay(1000); + if (Interlocked.Read(ref _generation) == gen) + await StartNodeConnectionAsync(); + } + } + + // ─── Helpers ─── + + private void EmitStateChanged(OverallConnectionState previousOverall) + { + var snapshot = _stateMachine.Current; + // Always fire when any part of the snapshot changed — not just OverallState. + // Node sub-state changes (e.g. Idle→PairingRequired) may not change OverallState + // but the UI still needs to update. + StateChanged?.Invoke(this, snapshot); + } + + private void DisposeActiveClient() + { + // Disconnect node first — run on threadpool to avoid sync context deadlocks + if (_nodeConnector != null) + { + try { Task.Run(() => _nodeConnector.DisconnectAsync()).Wait(TimeSpan.FromSeconds(2)); } + catch (Exception ex) { _logger.Warn($"[ConnMgr] Node disconnect error: {ex.Message}"); } + } + + var old = _activeLifecycle; + _activeLifecycle = null; + _activeGatewayRecordId = null; + _lastAutoApprovedRequestId = null; + Interlocked.Exchange(ref _autoApproveInFlight, null); + if (old != null) + { + OperatorClientChanged?.Invoke(this, new OperatorClientChangedEventArgs + { + OldClient = old.DataClient, + NewClient = null + }); + old.Dispose(); + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // Unsubscribe from node events before disposing the semaphore + // to prevent async void handlers from crashing via ObjectDisposedException. + if (_nodeConnector != null) + { + _nodeConnector.StatusChanged -= OnNodeStatusChanged; + _nodeConnector.PairingStatusChanged -= OnNodePairingStatusChanged; + } + // Acquire semaphore briefly to ensure no in-flight reconnect/switch is mid-transition. + // Use a short timeout — if something is stuck, proceed with disposal anyway. + try { _transitionSemaphore.Wait(TimeSpan.FromSeconds(2)); } catch { } + try + { + _stateMachine.TryTransition(ConnectionTrigger.Disposed); + DisposeActiveClient(); + // Stop tunnel on app shutdown — run on threadpool with timeout to avoid stalling exit + if (_tunnelManager?.IsActive == true) + { + try { Task.Run(() => _tunnelManager.StopAsync()).Wait(TimeSpan.FromSeconds(3)); } + catch { /* shutting down — best effort */ } + } + _operationCts?.Cancel(); + _operationCts?.Dispose(); + } + finally + { + try { _transitionSemaphore.Release(); } catch { } + _transitionSemaphore.Dispose(); + } + } +} + +/// +/// Logger that tees messages to both the underlying logger and the diagnostics ring buffer. +/// Client handshake logs tagged with [HANDSHAKE] appear in the Connection Status timeline. +/// +internal sealed class DiagnosticTeeLogger : IOpenClawLogger +{ + private readonly IOpenClawLogger _inner; + private readonly ConnectionDiagnostics _diagnostics; + + public DiagnosticTeeLogger(IOpenClawLogger inner, ConnectionDiagnostics diagnostics) + { + _inner = inner; + _diagnostics = diagnostics; + } + + public void Info(string message) + { + _inner.Info(message); + // Forward handshake-related and connection-relevant messages to timeline + if (message.Contains("[HANDSHAKE]") || message.Contains("challenge") || + message.Contains("hello-ok") || message.Contains("Handshake") || + message.Contains(" role=") || message.Contains(" scopes=") || + message.Contains(" deviceId=") || message.Contains(" nonce=") || + message.Contains(" signedAt=") || message.Contains(" sigToken") || + message.Contains(" signature ") || message.Contains(" isBootstrap") || + message.Contains("signed:") || message.Contains("auth:") || + message.Contains("gateway connected") || message.Contains("gateway reconnecting") || + message.Contains("[NODE]")) + { + // Strip redundant [HANDSHAKE] prefix since the category tag already shows "handshake" + var clean = message.Replace("[HANDSHAKE] ", ""); + _diagnostics.Record("handshake", clean); + } + } + + public void Debug(string message) => _inner.Debug(message); + + public void Warn(string message) + { + _inner.Warn(message); + var clean = message.Replace("[HANDSHAKE] ", "").Replace("[NODE] ", ""); + _diagnostics.Record("warning", clean); + } + + public void Error(string message, Exception? ex = null) + { + _inner.Error(message, ex); + _diagnostics.Record("error", message); + } +} diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs new file mode 100644 index 000000000..fb98be58b --- /dev/null +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -0,0 +1,93 @@ +namespace OpenClaw.Connection; + +/// +/// Immutable, cross-thread-safe representation of the entire connection +/// state at a point in time. Safe to cache, compare, and pass between threads. +/// +public sealed record GatewayConnectionSnapshot +{ + // ─── Overall ─── + public OverallConnectionState OverallState { get; init; } + + // ─── Operator ─── + public RoleConnectionState OperatorState { get; init; } + public string? OperatorError { get; init; } + public bool OperatorPairingRequired { get; init; } + public string? OperatorDeviceId { get; init; } + /// + /// The requestId returned by the gateway when operator pairing is required. + /// Used by setup flows to approve the specific pairing request via CLI. + /// + public string? OperatorPairingRequestId { get; init; } + + // ─── Node ─── + public RoleConnectionState NodeState { get; init; } + public string? NodeError { get; init; } + public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } + public string? NodeDeviceId { get; init; } + /// + /// The requestId returned by the gateway when node pairing is required. + /// Used by the connection page to show the correct approval command. + /// + public string? NodePairingRequestId { get; init; } + + // ─── Gateway ─── + public string? GatewayId { get; init; } + public string? GatewayUrl { get; init; } + public string? GatewayName { get; init; } + + // ─── Derived ─── + public bool IsFullyConnected => + OperatorState == RoleConnectionState.Connected && + NodeState == RoleConnectionState.Connected; + + public static GatewayConnectionSnapshot Idle { get; } = new() + { + OverallState = OverallConnectionState.Idle, + OperatorState = RoleConnectionState.Idle, + NodeState = RoleConnectionState.Idle, + NodePairingStatus = OpenClaw.Shared.PairingStatus.Unknown + }; + + /// + /// Derive the overall connection state from operator and node sub-states. + /// + public static OverallConnectionState DeriveOverall( + RoleConnectionState op, RoleConnectionState node, bool nodeEnabled) + { + if (op == RoleConnectionState.Error) + return OverallConnectionState.Error; + + if (op == RoleConnectionState.PairingRequired) + return OverallConnectionState.PairingRequired; + + if (op == RoleConnectionState.Connecting) + return OverallConnectionState.Connecting; + + // From here, operator is Connected. + + if (op == RoleConnectionState.Connected && nodeEnabled && + (node == RoleConnectionState.Error || + node == RoleConnectionState.PairingRejected || + node == RoleConnectionState.RateLimited)) + return OverallConnectionState.Degraded; + + if (op == RoleConnectionState.Connected && + node == RoleConnectionState.PairingRequired) + return OverallConnectionState.PairingRequired; + + if (op == RoleConnectionState.Connected && + nodeEnabled && node == RoleConnectionState.Connecting) + return OverallConnectionState.Connecting; + + if (op == RoleConnectionState.Connected && + (node == RoleConnectionState.Connected || !nodeEnabled || + node == RoleConnectionState.Disabled)) + return OverallConnectionState.Ready; + + if (op == RoleConnectionState.Connected) + return OverallConnectionState.Connected; + + return OverallConnectionState.Idle; + } +} diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs new file mode 100644 index 000000000..d4df48fca --- /dev/null +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -0,0 +1,46 @@ +namespace OpenClaw.Connection; + +/// +/// Immutable record representing a known gateway endpoint. +/// Stored in gateways.json via . +/// +public sealed record GatewayRecord +{ + /// Stable GUID, primary key. + public string Id { get; init; } = ""; + + /// Gateway WebSocket URL (e.g. wss://gateway.example.com). + public string Url { get; init; } = ""; + + /// User-facing label (e.g. "Home Gateway"). + public string? FriendlyName { get; init; } + + /// Long-lived shared token for any device. + public string? SharedGatewayToken { get; init; } + + /// One-time bootstrap token for first-time pairing. + public string? BootstrapToken { get; init; } + + /// Last successful connection time. + public DateTime? LastConnected { get; init; } + + /// True for gateways provisioned locally (localhost/WSL). + public bool IsLocal { get; init; } + + /// Per-gateway SSH tunnel configuration. Null if no tunnel needed. + public SshTunnelConfig? SshTunnel { get; init; } + + /// + /// Identity directory name, deterministically derived from Id. + /// GUIDs are path-safe and guarantee uniqueness even if URLs change. + /// + public string IdentityDirName => Id; +} + +/// Per-gateway SSH tunnel configuration. +public sealed record SshTunnelConfig( + string User, + string Host, + int RemotePort, + int LocalPort, + bool IncludeBrowserProxyForward = false); diff --git a/src/OpenClaw.Connection/GatewayRegistry.cs b/src/OpenClaw.Connection/GatewayRegistry.cs new file mode 100644 index 000000000..84d280ee3 --- /dev/null +++ b/src/OpenClaw.Connection/GatewayRegistry.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Pure data catalog of known gateway endpoints. Persistence only — no runtime state. +/// Thread-safe: lock-protected internal list; events fire outside the lock. +/// +public sealed class GatewayRegistry +{ + private readonly object _lock = new(); + private readonly string _filePath; + private readonly string _gatewaysDir; + private readonly IFileSystem _fs; + private List _records = []; + private string? _activeId; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public event EventHandler? Changed; + + /// + /// Create a GatewayRegistry backed by the given data directory. + /// + /// Root data directory (e.g. %APPDATA%/OpenClawTray). + /// Filesystem abstraction for testability. + public GatewayRegistry(string dataDir, IFileSystem? fs = null) + { + _fs = fs ?? RealFileSystem.Instance; + _filePath = Path.Combine(dataDir, "gateways.json"); + _gatewaysDir = Path.Combine(dataDir, "gateways"); + } + + // ─── Query ─── + + public IReadOnlyList GetAll() + { + lock (_lock) return _records.ToList(); + } + + public GatewayRecord? GetById(string id) + { + lock (_lock) return _records.Find(r => r.Id == id); + } + + public GatewayRecord? GetActive() + { + lock (_lock) return _activeId != null ? _records.Find(r => r.Id == _activeId) : null; + } + + public string? ActiveGatewayId + { + get { lock (_lock) return _activeId; } + } + + /// + /// Returns the identity directory path for a given gateway ID. + /// + public string GetIdentityDirectory(string gatewayId) + { + return Path.Combine(_gatewaysDir, gatewayId); + } + + // ─── Mutate ─── + + public GatewayRecord AddOrUpdate(GatewayRecord record) + { + List snapshot; + lock (_lock) + { + var idx = _records.FindIndex(r => r.Id == record.Id); + if (idx >= 0) + _records[idx] = record; + else + _records.Add(record); + snapshot = _records.ToList(); + } + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + return record; + } + + public void Remove(string id) + { + List snapshot; + lock (_lock) + { + _records.RemoveAll(r => r.Id == id); + if (_activeId == id) _activeId = null; + snapshot = _records.ToList(); + } + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + } + + public void SetActive(string gatewayId) + { + lock (_lock) _activeId = gatewayId; + } + + /// + /// Atomically update a record in-place. The runs under + /// the registry lock so concurrent writes (e.g. clearing BootstrapToken while + /// stamping LastConnected) don't overwrite each other. + /// Returns the updated record, or null if the record was not found. + /// + public GatewayRecord? Update(string id, Func updater) + { + GatewayRecord? updated; + List snapshot; + lock (_lock) + { + var idx = _records.FindIndex(r => r.Id == id); + if (idx < 0) return null; + updated = updater(_records[idx]); + ArgumentNullException.ThrowIfNull(updated, nameof(updater)); + _records[idx] = updated; + snapshot = _records.ToList(); + } + Changed?.Invoke(this, new GatewayRegistryChangedEventArgs(snapshot)); + return updated; + } + + // ─── Persistence ─── + + public void Save() + { + RegistryData data; + lock (_lock) + { + data = new RegistryData { Gateways = _records.ToList(), ActiveId = _activeId }; + } + var json = JsonSerializer.Serialize(data, s_jsonOptions); + + var dir = Path.GetDirectoryName(_filePath); + if (dir != null && !_fs.DirectoryExists(dir)) + _fs.CreateDirectory(dir); + + // Atomic write: temp file then rename + var tempPath = _filePath + ".tmp"; + _fs.WriteAllText(tempPath, json); + // On Windows, File.Move with overwrite works as atomic rename + File.Move(tempPath, _filePath, overwrite: true); + } + + public void Load() + { + if (!_fs.FileExists(_filePath)) + return; + + try + { + var json = _fs.ReadAllText(_filePath); + var data = JsonSerializer.Deserialize(json, s_jsonOptions); + if (data != null) + { + lock (_lock) + { + _records = data.Gateways ?? []; + _activeId = data.ActiveId; + } + } + } + catch (JsonException) + { + // Corrupted file — start fresh + } + } + + /// + /// Find a gateway record by URL. Used during migration and setup code apply. + /// + public GatewayRecord? FindByUrl(string url) + { + lock (_lock) return _records.Find(r => + string.Equals(r.Url, url, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Migrate credentials from legacy SettingsManager fields to GatewayRecord. + /// Idempotent: skips if a record for the same URL already exists. + /// Identity file is COPIED (not moved) for rollback safety. + /// + public bool MigrateFromSettings( + string? gatewayUrl, + string? token, + string? bootstrapToken, + bool useSshTunnel, + string? sshUser, + string? sshHost, + int sshRemotePort, + int sshLocalPort, + string settingsDir, + IOpenClawLogger? logger = null) + { + if (string.IsNullOrWhiteSpace(gatewayUrl)) + return false; + + // Idempotent: don't duplicate if already migrated + if (FindByUrl(gatewayUrl) != null) + { + logger?.Info($"[Registry] Migration skipped — record already exists for {gatewayUrl}"); + return false; + } + + var id = Guid.NewGuid().ToString(); + var record = new GatewayRecord + { + Id = id, + Url = gatewayUrl, + IsLocal = LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl), + SharedGatewayToken = string.IsNullOrWhiteSpace(bootstrapToken) ? token : null, + BootstrapToken = !string.IsNullOrWhiteSpace(bootstrapToken) ? bootstrapToken : null, + SshTunnel = useSshTunnel + ? new SshTunnelConfig(sshUser ?? "", sshHost ?? "", sshRemotePort, sshLocalPort) + : null + }; + + AddOrUpdate(record); + SetActive(id); + + // Copy identity file to per-gateway directory (rollback safe — original stays) + var legacyIdentity = Path.Combine(settingsDir, "device-key-ed25519.json"); + var newIdentityDir = GetIdentityDirectory(id); + if (File.Exists(legacyIdentity)) + { + try + { + if (!Directory.Exists(newIdentityDir)) + Directory.CreateDirectory(newIdentityDir); + var dest = Path.Combine(newIdentityDir, "device-key-ed25519.json"); + if (!File.Exists(dest)) + File.Copy(legacyIdentity, dest, overwrite: false); + logger?.Info($"[Registry] Identity file copied to {newIdentityDir}"); + } + catch (Exception ex) + { + logger?.Warn($"[Registry] Failed to copy identity file: {ex.Message}"); + } + } + + Save(); + logger?.Info($"[Registry] Migrated gateway {gatewayUrl} → record {id}"); + return true; + } + + private sealed class RegistryData + { + public List? Gateways { get; set; } + public string? ActiveId { get; set; } + } +} + +public sealed class GatewayRegistryChangedEventArgs : EventArgs +{ + public IReadOnlyList Records { get; } + public GatewayRegistryChangedEventArgs(IReadOnlyList records) + { + Records = records; + } +} diff --git a/src/OpenClaw.Connection/ICredentialResolver.cs b/src/OpenClaw.Connection/ICredentialResolver.cs new file mode 100644 index 000000000..c8c56b320 --- /dev/null +++ b/src/OpenClaw.Connection/ICredentialResolver.cs @@ -0,0 +1,27 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Resolved gateway credential plus metadata about which source was used. +/// +public sealed record GatewayCredential(string Token, bool IsBootstrapToken, string Source); + +/// +/// Resolves the best activation credential for connecting to a gateway. +/// There is one canonical resolution path per role — no other code resolves credentials. +/// +public interface ICredentialResolver +{ + /// + /// Resolve the best operator activation credential for the given gateway record. + /// Returns null if no credential is available. + /// + GatewayCredential? ResolveOperator(GatewayRecord record, string identityPath); + + /// + /// Resolve the best node activation credential for the given gateway record. + /// Returns null if no credential is available. + /// + GatewayCredential? ResolveNode(GatewayRecord record, string identityPath); +} diff --git a/src/OpenClaw.Connection/IGatewayClientFactory.cs b/src/OpenClaw.Connection/IGatewayClientFactory.cs new file mode 100644 index 000000000..2e7d20301 --- /dev/null +++ b/src/OpenClaw.Connection/IGatewayClientFactory.cs @@ -0,0 +1,39 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Lifecycle interface for a gateway client instance, owned exclusively by the manager. +/// Not exposed to UI consumers. Testable via mock implementations. +/// +public interface IGatewayClientLifecycle : IDisposable +{ + /// The underlying client for data access (events, requests). Cast-safe to OpenClawGatewayClient. + OpenClawGatewayClient DataClient { get; } + + /// Raised when the WebSocket transport status changes. + event EventHandler StatusChanged; + + /// Raised when gateway rejects authentication. + event EventHandler AuthenticationFailed; + + /// Connect the WebSocket and begin the handshake. + Task ConnectAsync(CancellationToken ct); +} + +/// +/// Factory for creating configured gateway client instances. +/// Stateless — creates instances but does not manage their lifecycle. +/// +public interface IGatewayClientFactory +{ + /// + /// Create a gateway client for the given URL and credential. + /// The manager owns the returned lifecycle handle. + /// + IGatewayClientLifecycle Create( + string gatewayUrl, + GatewayCredential credential, + string identityPath, + IOpenClawLogger logger); +} diff --git a/src/OpenClaw.Connection/IGatewayConnectionManager.cs b/src/OpenClaw.Connection/IGatewayConnectionManager.cs new file mode 100644 index 000000000..26ed18989 --- /dev/null +++ b/src/OpenClaw.Connection/IGatewayConnectionManager.cs @@ -0,0 +1,59 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Single owner of the complete connection lifecycle for the active gateway. +/// Manages operator connection, node connection, credential resolution, +/// state transitions, and diagnostics. +/// +public interface IGatewayConnectionManager : IDisposable +{ + // ─── State ─── + GatewayConnectionSnapshot CurrentSnapshot { get; } + string? ActiveGatewayUrl { get; } + + // ─── Events ─── + event EventHandler StateChanged; + event EventHandler DiagnosticEvent; + event EventHandler OperatorClientChanged; + + // ─── Lifecycle ─── + Task ConnectAsync(string? gatewayId = null); + Task DisconnectAsync(); + Task ReconnectAsync(); + Task SwitchGatewayAsync(string gatewayId); + + /// + /// Drive the node connection for the active gateway and await its terminal state. + /// Operator must already be Connected (caller responsibility — usually the easy-button + /// setup engine, which only invokes this after PairOperator completes). + /// + /// Behavior: + /// - If the node is already Connected + Paired, returns immediately. + /// - Otherwise calls the internal node-start path (bypassing the + /// auto-start shouldStartNodeConnection gate) and waits for the + /// manager's snapshot to reach NodeState=Connected, NodePairingStatus=Paired. + /// - Throws if the operator is not connected + /// or there is no node connector wired. + /// - Throws on terminal node failure + /// (Error / PairingRejected) with the snapshot's error message. + /// - Throws after the default 35s window if the + /// caller did not pass a cancellation token; respects the caller's token otherwise. + /// + /// + Task EnsureNodeConnectedAsync(CancellationToken cancellationToken = default); + + // ─── Setup ─── + Task ApplySetupCodeAsync(string setupCode); + Task ConnectWithSharedTokenAsync(string gatewayUrl, string token, SshTunnelConfig? sshTunnel = null); + + // ─── Operator Client Access ─── + /// + /// The active operator client for data requests. Null when disconnected. + /// + IOperatorGatewayClient? OperatorClient { get; } + + // ─── Diagnostics ─── + ConnectionDiagnostics Diagnostics { get; } +} diff --git a/src/OpenClaw.Connection/INodeConnector.cs b/src/OpenClaw.Connection/INodeConnector.cs new file mode 100644 index 000000000..c42092edf --- /dev/null +++ b/src/OpenClaw.Connection/INodeConnector.cs @@ -0,0 +1,50 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Manages the node-side connection for a given gateway. +/// Owns the WindowsNodeClient lifecycle but delegates capability +/// setup to NodeService (which has WinUI dependencies). +/// +public interface INodeConnector : IDisposable +{ + // ─── State ─── + bool IsConnected { get; } + PairingStatus PairingStatus { get; } + string? NodeDeviceId { get; } + NodeConnectionMode Mode { get; } + + // ─── Events ─── + event EventHandler StatusChanged; + event EventHandler PairingStatusChanged; + + /// + /// Raised right after a new is constructed + /// but BEFORE its ConnectAsync() call. Subscribers (typically + /// NodeService) must register the node's capabilities on the new + /// client synchronously so the outbound "connect" handshake includes + /// populated caps/commands arrays — otherwise the gateway + /// sees the node as having no advertised commands. + /// + /// Fires on first connect AND on every reconnect (the connector destroys + /// and re-creates the client on every ). + /// + event EventHandler ClientCreated; + + // ─── Lifecycle ─── + Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string identityPath, bool useV2Signature = false); + Task DisconnectAsync(); +} + +public sealed class NodeClientCreatedEventArgs : EventArgs +{ + public NodeClientCreatedEventArgs(WindowsNodeClient client, string? bearerToken) + { + Client = client; + BearerToken = bearerToken; + } + + public WindowsNodeClient Client { get; } + public string? BearerToken { get; } +} diff --git a/src/OpenClaw.Connection/ISshTunnelManager.cs b/src/OpenClaw.Connection/ISshTunnelManager.cs new file mode 100644 index 000000000..de1df2c43 --- /dev/null +++ b/src/OpenClaw.Connection/ISshTunnelManager.cs @@ -0,0 +1,13 @@ +namespace OpenClaw.Connection; + +/// +/// Manages an SSH tunnel lifecycle for a gateway connection. +/// Wraps the existing SshTunnelService behind a clean interface. +/// +public interface ISshTunnelManager : IDisposable +{ + bool IsActive { get; } + Task StartAsync(SshTunnelConfig config, CancellationToken ct); + Task StopAsync(); + string? LocalTunnelUrl { get; } +} diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs new file mode 100644 index 000000000..dce34289a --- /dev/null +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -0,0 +1,96 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Resolves operator credentials for user-facing surfaces such as chat. +/// Unlike the connection credential resolver which prefers DeviceToken (WebSocket auth), +/// this resolver prefers SharedGatewayToken because HTTP surfaces (chat URL ?token=) +/// authenticate via the shared token, not the per-device WebSocket token. +/// +public static class InteractiveGatewayCredentialResolver +{ + public static bool TryResolve( + GatewayRegistry? registry, + string settingsDirectory, + IDeviceIdentityReader identityReader, + string? effectiveGatewayUrl, + string? legacyToken, + string? legacyBootstrapToken, + out InteractiveGatewayCredential? credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(settingsDirectory); + ArgumentNullException.ThrowIfNull(identityReader); + + var active = registry?.GetActive(); + if (active != null && !string.IsNullOrWhiteSpace(active.Url)) + { + // For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken. + // DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken + // is for HTTP ?token= auth which the chat/dashboard endpoints expect. + if (!string.IsNullOrWhiteSpace(active.SharedGatewayToken)) + { + credential = new InteractiveGatewayCredential( + active.Url, + active.SharedGatewayToken!, + false, + CredentialResolver.SourceSharedGatewayToken); + return true; + } + + // Fall back to standard credential resolution (DeviceToken → Bootstrap) + var resolver = new CredentialResolver(identityReader); + var resolved = resolver.ResolveOperator(active, registry!.GetIdentityDirectory(active.Id)); + if (resolved != null) + { + credential = new InteractiveGatewayCredential( + active.Url, + resolved.Token, + resolved.IsBootstrapToken, + resolved.Source); + return true; + } + + if (!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase)) + { + credential = null; + return false; + } + } + + var gatewayUrl = effectiveGatewayUrl; + if (string.IsNullOrWhiteSpace(gatewayUrl)) + { + credential = null; + return false; + } + + var legacyRecord = new GatewayRecord + { + Id = "legacy-settings", + Url = gatewayUrl, + SharedGatewayToken = legacyToken, + BootstrapToken = legacyBootstrapToken + }; + var resolver2 = new CredentialResolver(identityReader); + var legacyCredential = resolver2.ResolveOperator(legacyRecord, settingsDirectory); + if (legacyCredential == null) + { + credential = null; + return false; + } + + credential = new InteractiveGatewayCredential( + gatewayUrl, + legacyCredential.Token, + legacyCredential.IsBootstrapToken, + legacyCredential.Source); + return true; + } +} + +public sealed record InteractiveGatewayCredential( + string GatewayUrl, + string Token, + bool IsBootstrapToken, + string Source); diff --git a/src/OpenClaw.Connection/NodeConnectionMode.cs b/src/OpenClaw.Connection/NodeConnectionMode.cs new file mode 100644 index 000000000..0b51ec514 --- /dev/null +++ b/src/OpenClaw.Connection/NodeConnectionMode.cs @@ -0,0 +1,14 @@ +namespace OpenClaw.Connection; + +/// +/// Node connection modes. Determines how the node participates. +/// +public enum NodeConnectionMode +{ + /// Normal: connect to gateway via WebSocket as node. + Gateway, + /// Local-only: expose capabilities via MCP HTTP, no WebSocket. + McpOnly, + /// Node mode off. + Disabled +} diff --git a/src/OpenClaw.Connection/NodeConnector.cs b/src/OpenClaw.Connection/NodeConnector.cs new file mode 100644 index 000000000..9366cfcc3 --- /dev/null +++ b/src/OpenClaw.Connection/NodeConnector.cs @@ -0,0 +1,122 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Lightweight node connector that creates and manages a WindowsNodeClient. +/// Capability setup (canvas, screen capture, etc.) is handled by NodeService, +/// which has WinUI dependencies and remains in App.xaml.cs for now. +/// +public sealed class NodeConnector : INodeConnector +{ + private readonly IOpenClawLogger _logger; + private readonly ConnectionDiagnostics? _diagnostics; + private WindowsNodeClient? _client; + private bool _disposed; + + public event EventHandler? StatusChanged; + public event EventHandler? PairingStatusChanged; + public event EventHandler? ClientCreated; + + public NodeConnector(IOpenClawLogger logger, ConnectionDiagnostics? diagnostics = null) + { + _logger = logger; + _diagnostics = diagnostics; + } + + public bool IsConnected => _client?.IsConnected ?? false; + public PairingStatus PairingStatus => _client switch + { + null => PairingStatus.Unknown, + { IsPaired: true } => PairingStatus.Paired, + { IsPendingApproval: true } => PairingStatus.Pending, + _ => PairingStatus.Unknown + }; + public string? NodeDeviceId => _client?.FullDeviceId; + public NodeConnectionMode Mode { get; private set; } = NodeConnectionMode.Disabled; + + /// The underlying node client, for capability registration by NodeService. + public WindowsNodeClient? Client => _client; + + public async Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string identityPath, bool useV2Signature = false) + { + if (_disposed) return; + + DisconnectInternal(); + + Mode = NodeConnectionMode.Gateway; + _logger.Info($"[NodeConnector] Connecting to {gatewayUrl}"); + + // Use a diagnostic tee logger so node handshake logs appear in the Connection Status timeline + IOpenClawLogger nodeLogger = _diagnostics != null + ? new DiagnosticTeeLogger(_logger, _diagnostics) + : _logger; + + _client = new WindowsNodeClient( + gatewayUrl, + credential.IsBootstrapToken ? "" : credential.Token, + identityPath, + nodeLogger, + bootstrapToken: credential.IsBootstrapToken ? credential.Token : null); + + // Share v2 signature flag from operator — avoid wasting a roundtrip on v3 + if (useV2Signature) + _client.UseV2Signature = true; + + // CRITICAL: fire ClientCreated BEFORE await _client.ConnectAsync() so subscribers + // (NodeService) can register capabilities synchronously. WindowsNodeClient + // serializes _registration.Capabilities/Commands into the outbound "connect" + // message during the connect handshake — registering after that point means + // the gateway sees an empty caps array for this session. + try + { + ClientCreated?.Invoke( + this, + new NodeClientCreatedEventArgs( + _client, + credential.IsBootstrapToken ? null : credential.Token)); + } + catch (Exception ex) + { + _logger.Warn($"[NodeConnector] ClientCreated handler threw: {ex.Message}"); + _diagnostics?.Record("node", "ClientCreated handler failed; node may connect without capabilities", ex.Message); + } + + _client.StatusChanged += (s, e) => StatusChanged?.Invoke(this, e); + _client.PairingStatusChanged += (s, e) => PairingStatusChanged?.Invoke(this, e); + + try + { + await _client.ConnectAsync(); + } + catch (Exception ex) + { + _logger.Error($"[NodeConnector] Connect failed: {ex.Message}"); + } + } + + public Task DisconnectAsync() + { + DisconnectInternal(); + return Task.CompletedTask; + } + + private void DisconnectInternal() + { + var old = _client; + _client = null; + if (old != null) + { + try { old.Dispose(); } + catch (Exception ex) { _logger.Warn($"[NodeConnector] Dispose error: {ex.Message}"); } + } + Mode = NodeConnectionMode.Disabled; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DisconnectInternal(); + } +} diff --git a/src/OpenClaw.Connection/OpenClaw.Connection.csproj b/src/OpenClaw.Connection/OpenClaw.Connection.csproj new file mode 100644 index 000000000..d7475c13b --- /dev/null +++ b/src/OpenClaw.Connection/OpenClaw.Connection.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + OpenClaw.Connection + + + + + + + + + + + + diff --git a/src/OpenClaw.Connection/OperatorClientChangedEventArgs.cs b/src/OpenClaw.Connection/OperatorClientChangedEventArgs.cs new file mode 100644 index 000000000..2f788dde2 --- /dev/null +++ b/src/OpenClaw.Connection/OperatorClientChangedEventArgs.cs @@ -0,0 +1,12 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Event args for when the operator client instance changes (connect, disconnect, reconnect, gateway switch). +/// +public sealed class OperatorClientChangedEventArgs : EventArgs +{ + public IOperatorGatewayClient? OldClient { get; init; } + public IOperatorGatewayClient? NewClient { get; init; } +} diff --git a/src/OpenClaw.Connection/OperatorScopeHelper.cs b/src/OpenClaw.Connection/OperatorScopeHelper.cs new file mode 100644 index 000000000..d3daaf7d3 --- /dev/null +++ b/src/OpenClaw.Connection/OperatorScopeHelper.cs @@ -0,0 +1,9 @@ +namespace OpenClaw.Connection; + +public static class OperatorScopeHelper +{ + public static bool CanApproveDevices(IReadOnlyList grantedScopes) => + grantedScopes.Any(s => + s.Equals("operator.admin", StringComparison.OrdinalIgnoreCase) || + s.Equals("operator.pairing", StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/OpenClaw.Connection/OverallConnectionState.cs b/src/OpenClaw.Connection/OverallConnectionState.cs new file mode 100644 index 000000000..66748c271 --- /dev/null +++ b/src/OpenClaw.Connection/OverallConnectionState.cs @@ -0,0 +1,32 @@ +namespace OpenClaw.Connection; + +/// +/// Overall derived connection state combining operator and node sub-states. +/// This is NOT a state machine — it's derived from the two role sub-FSMs. +/// +public enum OverallConnectionState +{ + /// No gateway configured or selected. + Idle, + + /// At least one role is connecting. + Connecting, + + /// Operator connected (node may still be connecting). + Connected, + + /// Both operator and node connected and paired. + Ready, + + /// Operator connected but node in error/rejected (functional but impaired). + Degraded, + + /// One or both roles need pairing approval. + PairingRequired, + + /// Unrecoverable error state (operator down). + Error, + + /// Teardown in progress. + Disconnecting +} diff --git a/src/OpenClaw.Connection/RetryPolicy.cs b/src/OpenClaw.Connection/RetryPolicy.cs new file mode 100644 index 000000000..a1067de51 --- /dev/null +++ b/src/OpenClaw.Connection/RetryPolicy.cs @@ -0,0 +1,34 @@ +namespace OpenClaw.Connection; + +/// +/// Retry/backoff policy per error category. +/// +internal static class RetryPolicy +{ + public static readonly int[] StandardBackoffMs = [1000, 2000, 4000, 8000, 15000, 30000, 60000]; + public static readonly int[] RateLimitBackoffMs = [30000, 60000, 120000, 300000]; + public static readonly int[] ServerCloseBackoffMs = [1000, 2000, 4000]; + + public static bool ShouldRetry(ConnectionErrorCategory category) => category switch + { + ConnectionErrorCategory.AuthFailure => false, + ConnectionErrorCategory.PairingPending => false, + ConnectionErrorCategory.PairingRejected => false, + ConnectionErrorCategory.ProtocolMismatch => false, + ConnectionErrorCategory.Cancelled => false, + ConnectionErrorCategory.Disposed => false, + _ => true + }; + + public static int GetBackoffMs(ConnectionErrorCategory category, int attempt) + { + var backoffs = category switch + { + ConnectionErrorCategory.RateLimited => RateLimitBackoffMs, + ConnectionErrorCategory.ServerClose => ServerCloseBackoffMs, + _ => StandardBackoffMs + }; + var idx = Math.Min(attempt, backoffs.Length - 1); + return backoffs[idx]; + } +} diff --git a/src/OpenClaw.Connection/RoleConnectionState.cs b/src/OpenClaw.Connection/RoleConnectionState.cs new file mode 100644 index 000000000..f886109de --- /dev/null +++ b/src/OpenClaw.Connection/RoleConnectionState.cs @@ -0,0 +1,20 @@ +namespace OpenClaw.Connection; + +/// +/// Per-role connection state for operator or node sub-FSM. +/// Each role maintains its own independent state. +/// +public enum RoleConnectionState +{ + Idle, + Connecting, + Connected, + PairingRequired, + /// Explicitly rejected — distinct from PairingRequired. + PairingRejected, + /// Temporarily throttled — will retry after cooldown. + RateLimited, + Error, + /// Node mode disabled in settings. + Disabled +} diff --git a/src/OpenClaw.Connection/SettingsChangeImpact.cs b/src/OpenClaw.Connection/SettingsChangeImpact.cs new file mode 100644 index 000000000..fac031c55 --- /dev/null +++ b/src/OpenClaw.Connection/SettingsChangeImpact.cs @@ -0,0 +1,66 @@ +namespace OpenClaw.Connection; + +/// +/// Classifies what changed between two settings snapshots to determine +/// the minimum reconnect action needed. +/// +public enum SettingsChangeImpact +{ + /// No meaningful change. + NoOp, + /// UI-only change (nav pane, notifications, etc.) — no reconnect. + UiOnly, + /// Node capability toggled — reload capabilities, no full reconnect. + CapabilityReload, + /// EnableNodeMode toggled — reconnect node only. + NodeReconnectRequired, + /// SSH tunnel config changed — full reconnect. + OperatorReconnectRequired, + /// Gateway URL changed — full tear down and reconnect. + FullReconnectRequired +} + +/// +/// Classifies settings changes to determine minimum reconnect action. +/// +public static class SettingsChangeClassifier +{ + public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, ConnectionSettingsSnapshot? next) + { + if (prev == null || next == null) + return SettingsChangeImpact.FullReconnectRequired; + + // Gateway URL changed → full reconnect + if (!string.Equals(prev.GatewayUrl, next.GatewayUrl, StringComparison.OrdinalIgnoreCase)) + return SettingsChangeImpact.FullReconnectRequired; + + // SSH tunnel config changed → operator reconnect + if (prev.UseSshTunnel != next.UseSshTunnel || + prev.SshTunnelUser != next.SshTunnelUser || + prev.SshTunnelHost != next.SshTunnelHost || + prev.SshTunnelRemotePort != next.SshTunnelRemotePort || + prev.SshTunnelLocalPort != next.SshTunnelLocalPort) + return SettingsChangeImpact.OperatorReconnectRequired; + + // EnableNodeMode toggled → node reconnect + if (prev.EnableNodeMode != next.EnableNodeMode || + prev.EnableMcpServer != next.EnableMcpServer) + return SettingsChangeImpact.NodeReconnectRequired; + + // Node capability toggles → capability reload + if (prev.NodeCanvasEnabled != next.NodeCanvasEnabled || + prev.NodeScreenEnabled != next.NodeScreenEnabled || + prev.NodeCameraEnabled != next.NodeCameraEnabled || + prev.NodeLocationEnabled != next.NodeLocationEnabled || + prev.NodeBrowserProxyEnabled != next.NodeBrowserProxyEnabled || + prev.NodeSttEnabled != next.NodeSttEnabled || + prev.NodeTtsEnabled != next.NodeTtsEnabled) + return SettingsChangeImpact.CapabilityReload; + + // Check if anything else changed (UI-only changes) + if (prev.FullSettingsJson != next.FullSettingsJson) + return SettingsChangeImpact.UiOnly; + + return SettingsChangeImpact.NoOp; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs b/src/OpenClaw.Connection/SetupCodeDecoder.cs similarity index 90% rename from src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs rename to src/OpenClaw.Connection/SetupCodeDecoder.cs index 74af301e8..4037151e6 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs +++ b/src/OpenClaw.Connection/SetupCodeDecoder.cs @@ -3,11 +3,10 @@ using System.Text.Json; using OpenClaw.Shared; -namespace OpenClawTray.Onboarding.Services; +namespace OpenClaw.Connection; /// -/// Decodes base64url-encoded setup codes into gateway URL and token. -/// Extracted from ConnectionPage for testability. +/// Decodes upstream OpenClaw setup codes into gateway URL and bootstrap token fields. /// public static class SetupCodeDecoder { @@ -24,10 +23,10 @@ public static DecodeResult Decode(string setupCode) string json; try { - // Base64url decode: replace URL-safe chars, add padding var b64 = setupCode.Trim().Replace('-', '+').Replace('_', '/'); var pad = b64.Length % 4; - if (pad > 0) b64 += new string('=', 4 - pad); + if (pad > 0) + b64 += new string('=', 4 - pad); json = Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } diff --git a/src/OpenClaw.Connection/SetupCodeResult.cs b/src/OpenClaw.Connection/SetupCodeResult.cs new file mode 100644 index 000000000..f6e901007 --- /dev/null +++ b/src/OpenClaw.Connection/SetupCodeResult.cs @@ -0,0 +1,18 @@ +namespace OpenClaw.Connection; + +/// +/// Result of applying a setup code. +/// +public sealed record SetupCodeResult( + SetupCodeOutcome Outcome, + string? ErrorMessage = null, + string? GatewayUrl = null); + +public enum SetupCodeOutcome +{ + Success, + InvalidCode, + InvalidUrl, + ConnectionFailed, + AlreadyConnected +} diff --git a/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs similarity index 86% rename from src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs rename to src/OpenClaw.Connection/SshTunnelService.cs index 88bc8812d..e6729c1ad 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -2,12 +2,12 @@ using System; using System.Diagnostics; -namespace OpenClawTray.Services; +namespace OpenClaw.Connection; /// /// Manages an SSH local port-forward process for gateway access. /// -public sealed class SshTunnelService : IDisposable +public sealed class SshTunnelService : ISshTunnelManager { private readonly IOpenClawLogger _logger; private Process? _process; @@ -23,6 +23,8 @@ public SshTunnelService(IOpenClawLogger logger) } public bool IsRunning => _process is { HasExited: false }; + public bool IsActive => IsRunning; + public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null; public string? CurrentUser { get; private set; } public string? CurrentHost { get; private set; } public int CurrentRemotePort { get; private set; } @@ -33,38 +35,24 @@ public SshTunnelService(IOpenClawLogger logger) public string? LastError { get; private set; } public TunnelStatus Status { get; private set; } = TunnelStatus.NotConfigured; + public SshTunnelSnapshot CreateSnapshot() => new( + IsRunning, + CurrentUser, + CurrentHost, + CurrentRemotePort, + CurrentLocalPort, + CurrentBrowserProxyRemotePort, + CurrentBrowserProxyLocalPort, + StartedAtUtc, + LastError, + Status); + public void MarkRestarting(int exitCode) { Status = TunnelStatus.Restarting; LastError = $"SSH tunnel exited unexpectedly with code {exitCode}; restart is scheduled."; } - public void EnsureStarted(SettingsManager settings) - { - if (!settings.UseSshTunnel) - { - Stop(); - LastError = null; - Status = TunnelStatus.NotConfigured; - return; - } - - var includeBrowserProxyForward = - settings.NodeBrowserProxyEnabled && - SshTunnelCommandLine.CanForwardBrowserProxyPort(settings.SshTunnelRemotePort, settings.SshTunnelLocalPort); - if (settings.NodeBrowserProxyEnabled && !includeBrowserProxyForward) - { - _logger.Warn("SSH tunnel browser proxy forward was skipped because gateway port + 2 is outside the valid TCP port range."); - } - - EnsureStarted( - settings.SshTunnelUser, - settings.SshTunnelHost, - settings.SshTunnelRemotePort, - settings.SshTunnelLocalPort, - includeBrowserProxyForward); - } - public void EnsureStarted(string user, string host, int remotePort, int localPort) => EnsureStarted(user, host, remotePort, localPort, includeBrowserProxyForward: false); @@ -127,6 +115,13 @@ public void Stop() } } + public void ResetNotConfigured() + { + Stop(); + LastError = null; + Status = TunnelStatus.NotConfigured; + } + private void StartProcess(string user, string host, int remotePort, int localPort, bool includeBrowserProxyForward) { var psi = new ProcessStartInfo @@ -225,4 +220,17 @@ public void Dispose() { Stop(); } + + public Task StartAsync(SshTunnelConfig config, CancellationToken ct) + { + EnsureStarted(config.User, config.Host, config.RemotePort, config.LocalPort, config.IncludeBrowserProxyForward); + var localUrl = $"ws://localhost:{config.LocalPort}"; + return Task.FromResult(localUrl); + } + + public Task StopAsync() + { + Stop(); + return Task.CompletedTask; + } } diff --git a/src/OpenClaw.Connection/SshTunnelSnapshot.cs b/src/OpenClaw.Connection/SshTunnelSnapshot.cs new file mode 100644 index 000000000..060f7196a --- /dev/null +++ b/src/OpenClaw.Connection/SshTunnelSnapshot.cs @@ -0,0 +1,18 @@ +using OpenClaw.Shared; + +namespace OpenClaw.Connection; + +/// +/// Immutable snapshot of SSH tunnel state for UI consumers. +/// +public sealed record SshTunnelSnapshot( + bool IsRunning, + string? CurrentUser, + string? CurrentHost, + int CurrentRemotePort, + int CurrentLocalPort, + int CurrentBrowserProxyRemotePort, + int CurrentBrowserProxyLocalPort, + DateTime? StartedAtUtc, + string? LastError, + TunnelStatus Status); diff --git a/src/OpenClaw.SetupPreview/App.xaml b/src/OpenClaw.SetupPreview/App.xaml new file mode 100644 index 000000000..b9ce62801 --- /dev/null +++ b/src/OpenClaw.SetupPreview/App.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/src/OpenClaw.SetupPreview/App.xaml.cs b/src/OpenClaw.SetupPreview/App.xaml.cs new file mode 100644 index 000000000..0ac1398d9 --- /dev/null +++ b/src/OpenClaw.SetupPreview/App.xaml.cs @@ -0,0 +1,25 @@ +using Microsoft.UI.Xaml; + +namespace OpenClaw.SetupPreview; + +/// +/// Minimal WinUI 3 Application bootstrap for the standalone V2 setup preview. +/// Constructs the single PreviewWindow and activates it. All capture-mode +/// behaviour (env-var gated headless rendering + PNG export + exit) lives +/// inside the window itself so this class stays trivially small. +/// +public partial class App : Application +{ + private PreviewWindow? _window; + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + _window = new PreviewWindow(); + _window.Activate(); + } +} diff --git a/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj b/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj new file mode 100644 index 000000000..8e06e0a70 --- /dev/null +++ b/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj @@ -0,0 +1,61 @@ + + + + WinExe + net10.0-windows10.0.22621.0 + true + enable + enable + OpenClaw.SetupPreview + OpenClaw.SetupPreview + x64;ARM64 + win-x64;win-arm64 + None + true + app.manifest + en-US + + $(DefineConstants);DISABLE_XAML_GENERATED_MAIN + + + + win-x64 + + + win-arm64 + + + + + + + + + + + + + + + + + + + + + + + true + $(OutputPath)runtimes\win-arm64\native\WebView2Loader.dll + $(OutputPath)runtimes\win-x64\native\WebView2Loader.dll + + + + + diff --git a/src/OpenClaw.SetupPreview/PreviewWindow.cs b/src/OpenClaw.SetupPreview/PreviewWindow.cs new file mode 100644 index 000000000..4c60f7c1d --- /dev/null +++ b/src/OpenClaw.SetupPreview/PreviewWindow.cs @@ -0,0 +1,592 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading.Tasks; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Hosting; +using OpenClawTray.Onboarding.V2; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; +using WinUIEx; + +namespace OpenClaw.SetupPreview; + +/// +/// Standalone preview window for the V2 onboarding redesign. +/// +/// Two modes of operation, selected by env vars: +/// +/// * Interactive (default): a normal window, intended for live design +/// iteration. Future work in the fake-services todo wires up the F1 +/// debug overlay (start page, locale, scenarios, replay). +/// +/// * Headless capture: when OPENCLAW_PREVIEW_CAPTURE=1, the window +/// appears at the requested size, mounts the V2 tree against the +/// requested page (OPENCLAW_PREVIEW_PAGE), waits for first composition +/// plus a quiescent frame, captures the root grid via +/// RenderTargetBitmap, writes the PNG to OPENCLAW_PREVIEW_CAPTURE_PATH, +/// and exits with code 0. On failure the exit code is 1 and a JSON +/// error file is written next to the requested PNG path. This is the +/// same RenderTargetBitmap mechanism the existing OnboardingWindow uses +/// for OPENCLAW_VISUAL_TEST=1, factored to fit a one-shot exe. +/// +/// The window is intentionally fixed-size so that the captured PNG always +/// has the same pixel dimensions for a given DPI — the visual-diff tool +/// relies on this stability. +/// +internal sealed class PreviewWindow : WindowEx +{ + /// + /// Logical preview window size in DIPs. Picked to closely match the + /// designer mocks (which are exported at 2010×2472; aspect 0.813). + /// 720 × 885 → aspect 0.813, identical to the design canvas, so the + /// rendered PNG can be diffed pixel-for-pixel against the references. + /// + private const int PreviewWidthDip = 720; + private const int PreviewHeightDip = 885; + + /// Height in DIPs of the custom XAML title bar (lobster + "OpenClaw Setup"). + private const int TitleBarHeight = 40; + + private readonly Grid _rootGrid; + private readonly FunctionalHostControl _host; + private readonly OnboardingV2State _state; + private readonly DispatcherQueue _dispatcherQueue; + + // Capture-mode configuration. + private readonly bool _captureMode; + private readonly string? _capturePath; + private bool _captureCompleted; + + // Theme-aware chrome elements (mutated by ApplyTheme when the user / system theme changes). + private Grid? _titleBar; + private TextBlock? _titleText; + + // System-theme tracking. Stored as fields (not locals) so the + // ColorValuesChanged subscription can be unhooked on window close — + // otherwise the COM event holds a strong reference to the lambda + // (and via it the window), preventing GC. + private Windows.UI.ViewManagement.UISettings? _themeUiSettings; + private Windows.Foundation.TypedEventHandler? _themeColorValuesChangedHandler; + + public PreviewWindow() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _state = new OnboardingV2State(); + + // Non-functional preview defaults: show the Node-Mode-Active warning + // on the All Set page (the design's default state). Env vars below + // can override this for capture scenarios that test the no-node variant. + _state.NodeModeActive = true; + + ApplyEnvOverrides(_state); + _captureMode = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_CAPTURE") == "1"; + _capturePath = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_CAPTURE_PATH"); + + // In headless capture mode, suppress all V2 entrance/idle animations + // so RenderTargetBitmap never snapshots an in-flight transform. + OpenClawTray.Onboarding.V2.V2Animations.DisableForCapture = _captureMode; + + Title = "OpenClaw Setup"; + ExtendsContentIntoTitleBar = true; + + // Use a flat dark background that matches the designer mocks + // (#202020) instead of MicaBackdrop. RenderTargetBitmap does not + // see Mica composition (it lives below the XAML layer), so the + // captures would otherwise show transparent/black behind the UI. + // A solid color guarantees byte-identical captures across runs. + SystemBackdrop = null; + + this.SetWindowSize(PreviewWidthDip, PreviewHeightDip); + this.CenterOnScreen(); + if (AppWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + presenter.IsMaximizable = false; + } + + // Force Windows 11 rounded corners on the window. Setting + // SystemBackdrop = null above unhooks the default Mica path that + // normally rounds the frame, leaving square corners. DWM's + // WINDOW_CORNER_PREFERENCE attribute (Windows 11 build 22000+) + // restores the rounded look without bringing back the Mica fill. + TryApplyRoundedCorners(); + + // Make the system min/max/close buttons follow the current theme below; + // the actual button colours are applied in ApplyTheme(). + + _host = new FunctionalHostControl(); + _host.Mount(ctx => + { + var (s, _) = ctx.UseState(_state); + return Factories.Component(s); + }); + + _rootGrid = new Grid + { + Background = V2Theme.WindowBackground(_state.EffectiveTheme) + }; + _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(TitleBarHeight) }); + _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + // Custom title bar: small lobster icon + "OpenClaw Setup" + // text. Reserve the right-hand inset for the system caption + // buttons. AppWindow.TitleBar.RightInset is in physical pixels; + // convert to DIPs using XamlRoot.RasterizationScale (set after + // the host has loaded). Fall back to a sensible default at 100% + // DPI (~138 DIP) until the first SizeChanged. + _titleBar = new Grid { Padding = new Thickness(14, 0, 138, 0) }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_titleBar, "OpenClaw Setup title bar"); + + var titleBar = _titleBar; + void UpdateTitleBarPadding() + { + try + { + var rightInsetPx = AppWindow?.TitleBar?.RightInset ?? 0; + var scale = _host?.XamlRoot?.RasterizationScale ?? 1.0; + if (scale <= 0) scale = 1.0; + var rightInsetDip = rightInsetPx > 0 ? rightInsetPx / scale : 138; + titleBar.Padding = new Thickness(14, 0, rightInsetDip, 0); + } + catch + { + // Non-fatal: leave the fallback padding. + } + } + AppWindow.Changed += (_, _) => UpdateTitleBarPadding(); + var lobster = new Image + { + Source = new BitmapImage(new Uri("ms-appx:///Assets/Setup/Lobster.png")), + Width = 14, + Height = 14, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + Stretch = Stretch.Uniform + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(lobster, "OpenClaw"); + _titleText = new TextBlock + { + Text = Title, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + }; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_titleText, "OpenClaw Setup"); + var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; + titleStack.Children.Add(lobster); + titleStack.Children.Add(_titleText); + _titleBar.Children.Add(titleStack); + Grid.SetRow(_titleBar, 0); + _rootGrid.Children.Add(_titleBar); + SetTitleBar(_titleBar); + + Grid.SetRow(_host, 1); + _rootGrid.Children.Add(_host); + Content = _rootGrid; + + _host.Loaded += (_, _) => UpdateTitleBarPadding(); + + // Wire theme resolution and re-application. The state's ThemeMode + // (System / Light / Dark) is the user's preference; EffectiveTheme + // is what the V2 pages actually render against. ApplyResolvedTheme + // reads the preference, picks Light or Dark (System => follow the + // host Application.RequestedTheme), and pushes the result back + // onto the state + the chrome. + ApplyResolvedTheme(); + if (Application.Current is { } app) + { + // No app-level RequestedTheme change event is reliably surfaced + // in unpackaged WinUI 3 apps, but UISettings raises ColorValuesChanged + // when Windows app-mode flips. Forward it. + // + // Both the UISettings instance and the handler delegate are + // stored as fields so we can unhook in Closed — without this, + // the COM event keeps a strong reference to the lambda (and + // via it, this window), preventing GC of the window when it + // closes. + try + { + _themeUiSettings = new Windows.UI.ViewManagement.UISettings(); + _themeColorValuesChangedHandler = (_, _) => + _dispatcherQueue.TryEnqueue(() => ApplyResolvedTheme()); + _themeUiSettings.ColorValuesChanged += _themeColorValuesChangedHandler; + } + catch { /* non-fatal */ } + } + + Closed += (_, _) => + { + if (_themeUiSettings is not null && _themeColorValuesChangedHandler is not null) + { + try { _themeUiSettings.ColorValuesChanged -= _themeColorValuesChangedHandler; } + catch { /* ignore */ } + } + _themeColorValuesChangedHandler = null; + _themeUiSettings = null; + }; + + // F2 cycles theme mode (System -> Light -> Dark -> System) for live design feedback. + // Only honoured in interactive mode; capture mode never sees keyboard input. + if (!_captureMode) + { + _host.KeyDown += (_, e) => + { + if (e.Key == Windows.System.VirtualKey.F2) + { + _state.ThemeMode = (V2ThemeMode)(((int)_state.ThemeMode + 1) % 3); + ApplyResolvedTheme(); + e.Handled = true; + } + }; + } + + if (_captureMode) + { + _host.Loaded += async (_, _) => + { + await CaptureAndExitAsync(); + }; + } + else + { + // Interactive preview: drive a fake stage progression so designers + // can walk through the LocalSetupProgress checklist without a real + // gateway install. Skipped if a frozen / failed stage env override + // is set (those are deterministic capture scenarios). + var hasFrozen = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE")); + var hasFail = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_FAIL_STAGE")); + if (!hasFrozen && !hasFail) + { + _host.Loaded += (_, _) => StartFakeStageProgression(); + } + } + } + + /// + /// Resolves to a concrete + /// and pushes it onto state + chrome. + /// Called on startup, on F2 cycle, and on Windows app-mode change. + /// + private void ApplyResolvedTheme() + { + var resolved = ResolveEffectiveTheme(_state.ThemeMode); + _state.EffectiveTheme = resolved; + + // Window background — V2 pages re-render through StateChanged, but the + // root Grid background is owned here so update it directly too. + _rootGrid.Background = V2Theme.WindowBackground(resolved); + + // Push the same theme into the visual tree so WinUI's built-in controls + // (ToggleSwitch thumb, ProgressRing, focus visuals) pick up matching defaults. + _rootGrid.RequestedTheme = resolved; + + if (_titleText is not null) + { + _titleText.Foreground = V2Theme.TextPrimary(resolved); + } + + if (AppWindow?.TitleBar is { } systemTitleBar) + { + // The system caption buttons (min/max/close) live above our XAML + // and need their colours set explicitly. Match the window bg so + // they blend into the chrome rather than showing a dark bar above + // a light window (or vice versa). + var bg = ((SolidColorBrush)V2Theme.WindowBackground(resolved)).Color; + var hover = ((SolidColorBrush)V2Theme.CardBackground(resolved)).Color; + var pressed = ((SolidColorBrush)V2Theme.CardBackgroundPressed(resolved)).Color; + var fg = ((SolidColorBrush)V2Theme.TextPrimary(resolved)).Color; + var inactiveFg = ((SolidColorBrush)V2Theme.TextSubtle(resolved)).Color; + systemTitleBar.ButtonBackgroundColor = bg; + systemTitleBar.ButtonInactiveBackgroundColor = bg; + systemTitleBar.ButtonForegroundColor = fg; + systemTitleBar.ButtonInactiveForegroundColor = inactiveFg; + systemTitleBar.ButtonHoverBackgroundColor = hover; + systemTitleBar.ButtonHoverForegroundColor = fg; + systemTitleBar.ButtonPressedBackgroundColor = pressed; + systemTitleBar.ButtonPressedForegroundColor = fg; + } + } + + /// + /// Resolves a user theme preference to a concrete . + /// uses + /// (UISettings-based) since + /// returns Light on unpackaged WinUI 3 apps regardless of system setting. + /// + private static ElementTheme ResolveEffectiveTheme(V2ThemeMode mode) => V2SystemTheme.Resolve(mode); + + /// + /// Apply Windows 11 rounded-corner preference via DWM. No-op (and silent) + /// on Windows 10 — the attribute simply isn't recognised. + /// + private void TryApplyRoundedCorners() + { + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + int pref = DWMWCP_ROUND; + _ = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int)); + } + catch + { + // Non-fatal: square corners are an acceptable fallback. + } + } + + private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; + private const int DWMWCP_ROUND = 2; + + [DllImport("dwmapi.dll", PreserveSig = true)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute); + + private DispatcherQueueTimer? _fakeStageTimer; + + /// + /// Walk the LocalSetupProgress checklist by promoting one row at a time: + /// the current spinner row turns into a checkmark, then the next idle + /// row spins. Loops at the end so a designer can keep watching without + /// restarting the app. ~900 ms per transition feels close to a real WSL + /// install but fast enough for design feedback. + /// + private void StartFakeStageProgression() + { + if (_fakeStageTimer is not null) return; + + // Seed: first stage spinning, the rest idle. + var stages = Enum.GetValues(); + var seed = new Dictionary(); + for (int i = 0; i < stages.Length; i++) + { + seed[stages[i]] = i == 0 + ? OnboardingV2State.LocalSetupRowState.Running + : OnboardingV2State.LocalSetupRowState.Idle; + } + _state.LocalSetupRows = seed; + + _fakeStageTimer = _dispatcherQueue.CreateTimer(); + _fakeStageTimer.Interval = TimeSpan.FromMilliseconds(900); + _fakeStageTimer.IsRepeating = true; + _fakeStageTimer.Tick += (_, _) => AdvanceFakeStage(); + _fakeStageTimer.Start(); + } + + private void AdvanceFakeStage() + { + var stages = Enum.GetValues(); + var rows = new Dictionary(_state.LocalSetupRows); + + // Find the currently-running row (if any) and promote it to Done; then + // start the next idle row spinning. If there's no idle row left, loop + // by resetting back to "stage 0 spinning, rest idle" after a brief pause. + int runningIndex = -1; + for (int i = 0; i < stages.Length; i++) + { + if (rows[stages[i]] == OnboardingV2State.LocalSetupRowState.Running) + { + runningIndex = i; + break; + } + } + + if (runningIndex == -1) + { + // Loop back to the start so the demo keeps going. + for (int i = 0; i < stages.Length; i++) + { + rows[stages[i]] = i == 0 + ? OnboardingV2State.LocalSetupRowState.Running + : OnboardingV2State.LocalSetupRowState.Idle; + } + _state.LocalSetupRows = rows; + return; + } + + rows[stages[runningIndex]] = OnboardingV2State.LocalSetupRowState.Done; + var nextIndex = runningIndex + 1; + if (nextIndex < stages.Length) + { + rows[stages[nextIndex]] = OnboardingV2State.LocalSetupRowState.Running; + } + _state.LocalSetupRows = rows; + } + + private static void ApplyEnvOverrides(OnboardingV2State state) + { + var page = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PAGE"); + if (!string.IsNullOrWhiteSpace(page) && + Enum.TryParse(page, ignoreCase: true, out var route)) + { + state.CurrentRoute = route; + } + + var theme = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_THEME"); + if (!string.IsNullOrWhiteSpace(theme) && + Enum.TryParse(theme, ignoreCase: true, out var mode)) + { + state.ThemeMode = mode; + } + + var nodeMode = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_NODE_MODE"); + if (!string.IsNullOrWhiteSpace(nodeMode)) + { + state.NodeModeActive = + nodeMode.Equals("1", StringComparison.OrdinalIgnoreCase) || + nodeMode.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + var existingGateway = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_EXISTING_GATEWAY_KIND"); + if (!string.IsNullOrWhiteSpace(existingGateway) && + Enum.TryParse(existingGateway, ignoreCase: true, out var gatewayKind)) + { + state.ExistingGateway = gatewayKind; + } + + // OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE freezes the LocalSetupProgress + // page on a specific stage: every stage strictly before this one is + // marked Done, the named stage is Running (spinner), and every stage + // strictly after is Idle. + // + // OPENCLAW_PREVIEW_FAIL_STAGE additionally marks the named stage as + // Failed (overrides the Running marking) and populates + // LocalSetupErrorMessage so the inline error card renders. + var frozen = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE"); + var failStage = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_FAIL_STAGE"); + + if (!string.IsNullOrWhiteSpace(frozen) && + Enum.TryParse(frozen, ignoreCase: true, out var frozenStage)) + { + var rows = new Dictionary(); + foreach (var stage in Enum.GetValues()) + { + if (stage < frozenStage) + { + rows[stage] = OnboardingV2State.LocalSetupRowState.Done; + } + else if (stage == frozenStage) + { + rows[stage] = OnboardingV2State.LocalSetupRowState.Running; + } + else + { + rows[stage] = OnboardingV2State.LocalSetupRowState.Idle; + } + } + state.LocalSetupRows = rows; + } + + if (!string.IsNullOrWhiteSpace(failStage) && + Enum.TryParse(failStage, ignoreCase: true, out var fStage)) + { + var rows = new Dictionary(state.LocalSetupRows); + // Mark every stage strictly before the failed one Done (in case + // the frozen stage env var was unset or set to the same stage). + foreach (var stage in Enum.GetValues()) + { + if (stage < fStage) rows[stage] = OnboardingV2State.LocalSetupRowState.Done; + else if (stage == fStage) rows[stage] = OnboardingV2State.LocalSetupRowState.Failed; + else rows[stage] = OnboardingV2State.LocalSetupRowState.Idle; + } + state.LocalSetupRows = rows; + state.LocalSetupErrorMessage = + "The OpenClaw gateway service started, but did not report ready status. Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + } + } + + private async Task CaptureAndExitAsync() + { + if (_captureCompleted) return; + _captureCompleted = true; + + try + { + // Two layout passes + a short delay so any first-render UseEffect + // mutations have time to land before we snapshot. + await Task.Yield(); + await Task.Delay(250); + + // Clear keyboard focus so the system focus visual (cyan ring) + // doesn't leak into deterministic captures. Re-enabling + // UseSystemFocusVisuals on V2 buttons (a11y improvement) means + // the first focusable in tab order would otherwise carry an + // initial focus ring. Park focus on a hidden, zero-size sentinel + // and let it settle for one more frame. + var sentinel = new ContentControl + { + IsTabStop = true, + Width = 0, + Height = 0, + Opacity = 0, + IsHitTestVisible = false, + }; + _rootGrid.Children.Add(sentinel); + sentinel.Focus(FocusState.Programmatic); + await Task.Delay(50); + + var rtb = new RenderTargetBitmap(); + await rtb.RenderAsync(_rootGrid); + var pixels = await rtb.GetPixelsAsync(); + var pixelBytes = pixels.ToArray(); + + _rootGrid.Children.Remove(sentinel); + + using var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + (uint)rtb.PixelWidth, + (uint)rtb.PixelHeight, + 96, 96, + pixelBytes); + await encoder.FlushAsync(); + + stream.Seek(0); + var reader = new DataReader(stream); + await reader.LoadAsync((uint)stream.Size); + var bytes = new byte[stream.Size]; + reader.ReadBytes(bytes); + + var path = !string.IsNullOrWhiteSpace(_capturePath) + ? _capturePath + : Path.Combine(Path.GetTempPath(), "openclaw-preview-capture.png"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllBytesAsync(path, bytes); + + Console.Out.WriteLine($"[preview] captured {rtb.PixelWidth}x{rtb.PixelHeight} -> {path}"); + ExitWithCode(0); + } + catch (Exception ex) + { + try + { + var errPath = (_capturePath ?? Path.Combine(Path.GetTempPath(), "openclaw-preview-capture.png")) + ".error.json"; + Directory.CreateDirectory(Path.GetDirectoryName(errPath)!); + var json = $"{{\"error\":{System.Text.Json.JsonSerializer.Serialize(ex.Message)},\"type\":{System.Text.Json.JsonSerializer.Serialize(ex.GetType().FullName ?? "")}}}"; + File.WriteAllText(errPath, json); + } + catch { /* best effort */ } + Console.Error.WriteLine($"[preview] capture failed: {ex}"); + ExitWithCode(1); + } + } + + private void ExitWithCode(int code) + { + // WinUI doesn't expose a clean Application.Exit(int) — the Win32 + // ExitProcess avoids racing with the dispatcher loop teardown that + // a managed Application.Exit() can leave hanging. + try { Close(); } catch { /* ignore */ } + ExitProcess((uint)code); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern void ExitProcess(uint uExitCode); + +} diff --git a/src/OpenClaw.SetupPreview/Program.cs b/src/OpenClaw.SetupPreview/Program.cs new file mode 100644 index 000000000..29a74a282 --- /dev/null +++ b/src/OpenClaw.SetupPreview/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; + +namespace OpenClaw.SetupPreview; + +/// +/// Explicit entry point for the unpackaged WinUI 3 preview exe. +/// +/// Mirrors the pattern auto-generated by EnableMsixTooling=true in MSIX +/// builds, but without requiring a Package.appxmanifest. Initializes COM +/// wrappers, installs a DispatcherQueue-backed SynchronizationContext on +/// the UI thread, then starts the WinUI Application loop with our App. +/// +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + 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.SetupPreview/Properties/launchSettings.json b/src/OpenClaw.SetupPreview/Properties/launchSettings.json new file mode 100644 index 000000000..de193eeb0 --- /dev/null +++ b/src/OpenClaw.SetupPreview/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "OpenClaw.SetupPreview": { + "commandName": "Project", + "nativeDebugging": false + }, + "Capture-Welcome": { + "commandName": "Project", + "environmentVariables": { + "OPENCLAW_PREVIEW_CAPTURE": "1", + "OPENCLAW_PREVIEW_PAGE": "Welcome", + "OPENCLAW_PREVIEW_CAPTURE_PATH": "$(MSBuildProjectDirectory)\\..\\..\\out\\v2-visual\\welcome\\actual.png" + } + } + } +} diff --git a/src/OpenClaw.SetupPreview/app.manifest b/src/OpenClaw.SetupPreview/app.manifest new file mode 100644 index 000000000..ebb1a314b --- /dev/null +++ b/src/OpenClaw.SetupPreview/app.manifest @@ -0,0 +1,15 @@ + + + + + + true/pm + PerMonitorV2 + + + + + + + + diff --git a/src/OpenClaw.Shared/Audio/AudioModels.cs b/src/OpenClaw.Shared/Audio/AudioModels.cs new file mode 100644 index 000000000..8016ead85 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/AudioModels.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared.Audio; + +/// Result of a speech-to-text transcription segment. +public sealed class TranscriptionResult +{ + public string Text { get; init; } = ""; + public TimeSpan Start { get; init; } + public TimeSpan End { get; init; } + public string Language { get; init; } = "en"; +} + +/// +/// Aggregated result of a single silence-bounded utterance — i.e. all the +/// Whisper segments produced from one VAD-bounded speech burst, combined. +/// Consumers that need "what the user said" (chat submission, stt.listen) +/// should listen for this event instead of per-segment TranscriptionResult +/// to avoid sending partial text. +/// +public sealed class UtteranceResult +{ + /// Concatenated text across all segments, single-spaced. + public string Text { get; init; } = ""; + /// Language detected on the first segment, or null if no segments. + public string? Language { get; init; } + /// Start of the first segment relative to capture start. + public TimeSpan Start { get; init; } + /// End of the last segment relative to capture start. + public TimeSpan End { get; init; } + /// Immutable snapshot of the per-segment results. + public IReadOnlyList Segments { get; init; } = Array.Empty(); +} + +/// Voice-activity detection event. +public sealed class VadEvent +{ + public bool IsSpeaking { get; init; } + public float Probability { get; init; } +} + +/// Configuration for the audio pipeline. +public sealed class AudioPipelineOptions +{ + /// Path to the Whisper GGML model file. + public string ModelPath { get; init; } = ""; + + /// Language code for STT (e.g. "en", "auto"). + public string Language { get; init; } = "auto"; + + /// Seconds of silence before a speech segment is finalized. + public float SilenceTimeoutSeconds { get; init; } = 1.5f; + + /// Optional audio device ID. Null = system default microphone. + public string? DeviceId { get; init; } + + /// VAD probability threshold (0.0–1.0). Audio above this is considered speech. + public float VadThreshold { get; init; } = 0.3f; +} + +/// Pipeline state. +public enum AudioPipelineState +{ + Stopped, + Starting, + Listening, + Processing, + Error +} diff --git a/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs b/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs new file mode 100644 index 000000000..5c9f3a5f8 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/PiperVoiceManager.cs @@ -0,0 +1,390 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Audio; + +/// +/// Manages downloads and on-disk lifecycle for Piper TTS voices. +/// +/// Each "voice" is a sherpa-onnx pre-packaged tarball that contains +/// everything needed for offline synthesis — the .onnx model, the +/// tokens.txt phoneme map, and the language-specific espeak-ng-data. +/// We use the sherpa-onnx repackaged distribution rather than the raw +/// HuggingFace Piper voices because the latter requires the user (or +/// us) to ship espeak-ng-data separately (~80 MB shared across voices). +/// +/// Storage layout under the tray's data directory: +/// models/piper/<voice-id>/ +/// <voice-id>.onnx +/// tokens.txt +/// espeak-ng-data/... +/// +/// Each voice is ~50 MB compressed, ~80 MB extracted (with espeak data). +/// +/// **TODO (pre-GA):** SHA-256 verification of downloaded tarballs before +/// extraction (Audio_FollowUps.md §2). The current implementation trusts +/// HTTPS + the system trust chain only. +/// +public sealed class PiperVoiceManager +{ + private readonly string _voicesDirectory; + private readonly IOpenClawLogger _logger; + // Per-voice single-flight gate: prevents racing the same voice download + // from two callers (e.g. UI and a programmatic caller). Static so two + // PiperVoiceManager instances over the same data directory still + // coalesce against the same in-flight task. + private static readonly ConcurrentDictionary> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Curated catalog of Piper voices we offer in the UI. Each entry is + /// a sherpa-onnx pre-packaged tarball from the project's GitHub + /// releases. To add a voice: pick its key from + /// https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models, + /// download the tarball, compute its SHA-256, and pin it below. + /// Sizes shown in the UI are approximate compressed sizes. + /// + /// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against + /// the sherpa-onnx GitHub release on 2026-05-05. Downloads with a + /// different hash are rejected and the partial tarball is deleted. + /// Before any public release: re-verify each hash from an independent + /// source and document provenance in Audio_FollowUps.md §2. + /// + public static readonly PiperVoiceInfo[] AvailableVoices = + [ + new("en_US-amy-low", "English (US) — Amy (low quality, fast)", "en-US", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-amy-low.tar.bz2", + "c70f5284a09a7fd4ed203b39b2ff51cac1432b422b852eb647b481dade3cf639"), + new("en_US-libritts-high","English (US) — LibriTTS (high quality)", "en-US", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts-high.tar.bz2", + "d9d35056703fd38ed38e95c202a50f603fefdc8a92a7b6332c4f1a41616eac72"), + new("en_GB-alan-low", "English (GB) — Alan (low quality, fast)", "en-GB", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_GB-alan-low.tar.bz2", + "1308e730b7a12c3b64b669d65daa0138fcb83b1a086edee92fa9fa68cb0290dd"), + new("fr_FR-siwis-low", "Français (FR) — Siwis (low quality, fast)","fr-FR", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-low.tar.bz2", + "3d69170c160c8375c4123901a72a3845222b39456d39ab74f5bbd7310952b5af"), + new("de_DE-thorsten-low","Deutsch (DE) — Thorsten (low quality)", "de-DE", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-low.tar.bz2", + "41fab35910fdcec4696b031951d8fd6c262e594cf77b35e1068fadbeb5a091a6"), + new("zh_CN-huayan-medium","中文 (CN) — Huayan (medium quality)", "zh-CN", + "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-zh_CN-huayan-medium.tar.bz2", + "dbdfec42b91d9cee31cce9ff4b3e9c305eb6fbf60546d071f7e46273554cce6b"), + ]; + + public PiperVoiceManager(string dataDirectory, IOpenClawLogger logger) + { + _voicesDirectory = Path.Combine(dataDirectory, "models", "piper"); + _logger = logger; + Directory.CreateDirectory(_voicesDirectory); + } + + /// Root directory where this voice's files live (created lazily). + public string GetVoiceDirectory(string voiceId) + { + var info = FindVoice(voiceId); + return Path.Combine(_voicesDirectory, info.VoiceId); + } + + /// Path to the .onnx model file for a downloaded voice. + public string GetModelPath(string voiceId) + { + var dir = GetVoiceDirectory(voiceId); + // sherpa-onnx tarballs put files at the root of the voice dir; the + // model file is named after the voice id. + return Path.Combine(dir, $"{voiceId}.onnx"); + } + + /// Path to tokens.txt (phoneme map). + public string GetTokensPath(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "tokens.txt"); + + /// Path to the espeak-ng-data directory bundled with this voice. + public string GetEspeakDataDir(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "espeak-ng-data"); + + /// True when all three files are present on disk. + public bool IsVoiceDownloaded(string voiceId) + { + try + { + return File.Exists(GetModelPath(voiceId)) + && File.Exists(GetTokensPath(voiceId)) + && Directory.Exists(GetEspeakDataDir(voiceId)); + } + catch + { + // FindVoice throws on unknown voiceId — treat as not-downloaded. + return false; + } + } + + /// + /// Download and extract a Piper voice from the sherpa-onnx release. + /// Reports progress as bytes downloaded / total bytes (extraction + /// progress is not reported separately). + /// Per-voice single-flight: concurrent calls for the same voice await + /// the in-flight download instead of racing on the same temp tarball. + /// + public Task DownloadVoiceAsync( + string voiceId, + IProgress<(long downloaded, long total)>? progress = null, + CancellationToken cancellationToken = default) + { + var info = FindVoice(voiceId); + if (IsVoiceDownloaded(info.VoiceId)) + { + _logger.Info($"Piper voice '{info.VoiceId}' already downloaded"); + return Task.CompletedTask; + } + + // Preflight: bail out before downloading 50-150 MB if the OS isn't + // capable of extracting the .tar.bz2 we'd produce. tar.exe ships with + // Windows 10 1803+; older systems would fail at the extract step + // after a long, wasted download. + EnsureExtractorAvailable(); + + var key = info.VoiceId; + return SingleFlightDownload.RunAsync( + InFlightDownloads, + key, + token => DownloadVoiceCoreAsync(info, progress, token), + cancellationToken); + } + + private async Task DownloadVoiceCoreAsync( + PiperVoiceInfo info, + IProgress<(long downloaded, long total)>? progress, + CancellationToken cancellationToken) + { + // SECURITY: refuse to install any voice that doesn't have a pinned + // hash. See Audio_FollowUps.md §2. + if (string.IsNullOrWhiteSpace(info.Sha256)) + { + throw new InvalidOperationException( + $"Piper voice '{info.VoiceId}' has no pinned SHA-256; refusing to download. " + + "Add a verified hash to AvailableVoices before enabling this voice."); + } + + var voiceDir = Path.Combine(_voicesDirectory, info.VoiceId); + Directory.CreateDirectory(voiceDir); + var tarballPath = Path.Combine(voiceDir, $"{info.VoiceId}.tar.bz2.tmp"); + _logger.Info($"Downloading Piper voice '{info.VoiceId}' from {info.DownloadUrl}"); + + try + { + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(10); + using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? 0; + using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + using (var fileStream = new FileStream(tarballPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920)) + { + var buffer = new byte[81920]; + long downloaded = 0; + int bytesRead; + while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + downloaded += bytesRead; + progress?.Report((downloaded, totalBytes)); + } + } + + // SECURITY: verify SHA-256 of the downloaded tarball BEFORE we + // hand it to the extractor. tar reads file contents to disk; an + // attacker-controlled tarball could plant arbitrary files (path + // traversal aside, the .onnx model itself is loaded into the + // process). Fail closed on mismatch — partial dir cleanup runs + // in the catch block below. + await VerifyHashAsync(tarballPath, info.Sha256, info.VoiceId, cancellationToken); + + _logger.Info($"Extracting Piper voice '{info.VoiceId}'"); + ExtractTarBz2(tarballPath, voiceDir, cancellationToken); + + // Verify the extraction produced the files we expect; if not, + // tear the half-extracted dir down so a retry starts clean. + if (!IsVoiceDownloaded(info.VoiceId)) + { + throw new InvalidOperationException( + $"Extraction of Piper voice '{info.VoiceId}' did not produce the expected layout."); + } + + _logger.Info($"Piper voice '{info.VoiceId}' verified and ready at {voiceDir}"); + } + catch + { + // Best-effort cleanup — leaves the user able to retry without + // leftover partial files. + try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ } + try { if (Directory.Exists(voiceDir) && !IsVoiceDownloaded(info.VoiceId)) Directory.Delete(voiceDir, recursive: true); } catch { /* swallow */ } + throw; + } + finally + { + try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ } + } + } + + /// + /// Compute SHA-256 of and compare to + /// . Throws on mismatch (caller is + /// expected to delete the file). Does not echo the actual hash to + /// avoid handing attackers a confirmation oracle. + /// + private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); + var actual = await sha.ComputeHashAsync(stream, cancellationToken); + var actualHex = Convert.ToHexString(actual).ToLowerInvariant(); + if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase)) + { + throw new System.Security.SecurityException( + $"Piper voice '{assetName}' failed integrity check. The downloaded tarball does not match the pinned SHA-256."); + } + } + + /// Delete a downloaded voice directory. + public bool DeleteVoice(string voiceId) + { + var info = FindVoice(voiceId); + var dir = Path.Combine(_voicesDirectory, info.VoiceId); + if (!Directory.Exists(dir)) return false; + Directory.Delete(dir, recursive: true); + _logger.Info($"Deleted Piper voice '{info.VoiceId}'"); + return true; + } + + /// Total disk usage of a downloaded voice, or 0 if not downloaded. + public long GetVoiceSize(string voiceId) + { + var info = FindVoice(voiceId); + var dir = Path.Combine(_voicesDirectory, info.VoiceId); + if (!Directory.Exists(dir)) return 0; + long total = 0; + foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) + { + try { total += new FileInfo(f).Length; } catch { /* skip */ } + } + return total; + } + + /// + /// Probe the bundled OS tar.exe used by . + /// Throws a clear error before any network I/O happens so users on + /// downlevel Windows aren't left with a half-downloaded tarball. + /// + private static void EnsureExtractorAvailable() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "tar", + ArgumentList = { "--version" }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + using var proc = System.Diagnostics.Process.Start(psi); + if (proc == null) + { + throw new InvalidOperationException("tar.exe not found on PATH."); + } + proc.WaitForExit(2000); + if (!proc.HasExited) + { + try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ } + throw new InvalidOperationException("tar.exe didn't respond to --version."); + } + if (proc.ExitCode != 0) + { + throw new InvalidOperationException($"tar.exe --version returned exit code {proc.ExitCode}."); + } + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new InvalidOperationException( + "Piper voices need bundled tar (Windows 10 1803+). " + + "Your system doesn't have tar on PATH; please update Windows or install a tar utility.", ex); + } + } + + /// + /// Extract a .tar.bz2 archive in-place. We use SharpCompress (already a + /// transitive dependency via PiperSharp's ecosystem, but explicit here) + /// so we don't need to shell out to tar.exe. + /// + private static void ExtractTarBz2(string archivePath, string destinationDir, CancellationToken cancellationToken) + { + // SharpCompress isn't a direct dep of OpenClaw.Shared today; we + // intentionally use the BCL .tar reader on top of a bzip2 stream + // from a small inline implementation. Keeping the dep surface small + // matters in this assembly because everything here is also referenced + // from OpenClaw.Cli. + // + // .NET 7+ ships System.Formats.Tar; bzip2 is not in the BCL, so we + // bring it in via a thin wrapper. For now the simplest-correct path + // is to call out to the OS-bundled `tar` (Win10 1803+ ships it), + // which transparently handles bz2. + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "tar", + ArgumentList = { "-xjf", archivePath, "-C", destinationDir, "--strip-components=1" }, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + }; + using var proc = System.Diagnostics.Process.Start(psi) + ?? throw new InvalidOperationException("Could not start tar to extract Piper voice"); + + // Cancellation: kill the tar process if requested. + using var reg = cancellationToken.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ } }); + + proc.WaitForExit(); + if (proc.ExitCode != 0) + { + var err = proc.StandardError.ReadToEnd(); + throw new InvalidOperationException($"tar extraction failed (exit {proc.ExitCode}): {err}"); + } + } + + private static PiperVoiceInfo FindVoice(string voiceId) + { + foreach (var v in AvailableVoices) + { + if (string.Equals(v.VoiceId, voiceId, StringComparison.OrdinalIgnoreCase)) + return v; + } + var available = string.Join(", ", AvailableVoicesIds()); + throw new ArgumentException($"Unknown Piper voice: '{voiceId}'. Available: {available}"); + } + + private static IEnumerable AvailableVoicesIds() + { + foreach (var v in AvailableVoices) yield return v.VoiceId; + } +} + +/// Metadata about a Piper voice variant. +/// Short id, e.g. "en_US-amy-low". +/// Human-readable label for UI. +/// BCP-47 tag. +/// HTTPS URL of the .tar.bz2. +/// Pinned lowercase hex SHA-256 of the downloaded +/// tarball. MUST be set; downloads are refused when null. See the catalog +/// for the "verified on" date — these need re-verification before any +/// public release (see Audio_FollowUps.md §2). +public sealed record PiperVoiceInfo( + string VoiceId, + string DisplayName, + string LanguageTag, + string DownloadUrl, + string? Sha256); diff --git a/src/OpenClaw.Shared/Audio/SileroVadModelManifest.cs b/src/OpenClaw.Shared/Audio/SileroVadModelManifest.cs new file mode 100644 index 000000000..73637ba00 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/SileroVadModelManifest.cs @@ -0,0 +1,28 @@ +namespace OpenClaw.Shared.Audio; + +/// +/// Pinned descriptor for the Silero VAD ONNX model that the audio +/// pipeline auto-downloads on first use. +/// +/// SECURITY — same fail-closed verification discipline as +/// and : +/// the runtime checks the downloaded file's SHA-256 against +/// before installing it. The pinned hash here was +/// captured against the upstream raw URL on 2026-05-05; re-verify from +/// an independent source before any public release (Audio_FollowUps.md +/// §2 captures the broader signed-manifest plan). +/// +public static class SileroVadModelManifest +{ + public const string FileName = "silero_vad.onnx"; + + public const string DownloadUrl = + "https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx"; + + /// Lowercase hex SHA-256 of the canonical upstream file. + public const string Sha256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3"; + + /// Approximate compressed size in bytes (UI hint; actual size + /// is asserted via the SHA-256 check). + public const long ApproximateSizeBytes = 2_327_524; +} diff --git a/src/OpenClaw.Shared/Audio/SingleFlightDownload.cs b/src/OpenClaw.Shared/Audio/SingleFlightDownload.cs new file mode 100644 index 000000000..215d46d68 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/SingleFlightDownload.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Audio; + +internal static class SingleFlightDownload +{ + public static Task RunAsync( + ConcurrentDictionary> inFlight, + string key, + Func startDownload, + CancellationToken waitCancellationToken = default) + { + var candidate = new Lazy(() => + { + try + { + return startDownload(CancellationToken.None) + ?? Task.FromException(new InvalidOperationException("Download factory returned null.")); + } + catch (Exception ex) + { + return Task.FromException(ex); + } + }, LazyThreadSafetyMode.ExecutionAndPublication); + + var lazy = inFlight.GetOrAdd(key, candidate); + Task task; + try + { + task = lazy.Value; + } + catch + { + inFlight.TryRemove(new KeyValuePair>(key, lazy)); + throw; + } + + _ = task.ContinueWith( + _ => inFlight.TryRemove(new KeyValuePair>(key, lazy)), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return waitCancellationToken.CanBeCanceled + ? task.WaitAsync(waitCancellationToken) + : task; + } +} diff --git a/src/OpenClaw.Shared/Audio/SpeechToTextService.cs b/src/OpenClaw.Shared/Audio/SpeechToTextService.cs new file mode 100644 index 000000000..b0101c269 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/SpeechToTextService.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Whisper.net; +using Whisper.net.Ggml; + +namespace OpenClaw.Shared.Audio; + +/// +/// Wraps Whisper.net for speech-to-text transcription. +/// Lazily loads the model on first use and caches the factory. +/// Thread-safe: concurrent calls are serialized by a semaphore. +/// +public sealed class SpeechToTextService : IDisposable +{ + private readonly IOpenClawLogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private WhisperFactory? _factory; + private string? _loadedModelPath; + + public bool IsModelLoaded => _factory != null; + public string? LoadedModelPath => _loadedModelPath; + + public SpeechToTextService(IOpenClawLogger logger) + { + _logger = logger; + } + + /// Load (or reload) the Whisper model from disk. + public void LoadModel(string modelPath) + { + if (!System.IO.File.Exists(modelPath)) + throw new System.IO.FileNotFoundException($"Whisper model not found: {modelPath}"); + + _factory?.Dispose(); + _factory = WhisperFactory.FromPath(modelPath); + _loadedModelPath = modelPath; + _logger.Info($"Whisper model loaded: {modelPath}"); + } + + /// Unload the current model and free memory. + public void UnloadModel() + { + _factory?.Dispose(); + _factory = null; + _loadedModelPath = null; + _logger.Info("Whisper model unloaded"); + } + + /// + /// Transcribe raw 16 kHz mono PCM float samples. + /// Returns all detected segments. + /// + public async Task> TranscribeAsync( + float[] samples, + string language = "auto", + CancellationToken cancellationToken = default) + { + if (_factory == null) + throw new InvalidOperationException("No Whisper model is loaded. Call LoadModel first."); + + await _gate.WaitAsync(cancellationToken); + try + { + // Whisper.net's WithLanguage expects either "auto" or a 2-letter + // ISO 639-1 code. The capability validator accepts the broader + // BCP-47 shape ("en-US", "zh-Hans-CN") because that's what the + // public docs advertise; normalize down here so Whisper actually + // sees something it understands. + var whisperLang = NormalizeForWhisper(language); + var builder = _factory.CreateBuilder() + .WithLanguage(whisperLang) + .WithThreads(Math.Max(1, Environment.ProcessorCount / 2)); + + using var processor = builder.Build(); + + using var wavStream = PcmToWavStream(samples, 16000); + + var results = new List(); + await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken)) + { + var text = segment.Text?.Trim(); + if (!string.IsNullOrEmpty(text)) + { + results.Add(new TranscriptionResult + { + Text = text, + Start = segment.Start, + End = segment.End, + Language = whisperLang + }); + } + } + + return results; + } + finally + { + _gate.Release(); + } + } + + /// + /// Convert raw 16-bit PCM float samples to a WAV MemoryStream. + /// Whisper.net processes WAV streams natively. + /// + private static System.IO.MemoryStream PcmToWavStream(float[] samples, int sampleRate) + { + var ms = new System.IO.MemoryStream(); + using var writer = new System.IO.BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true); + + int bitsPerSample = 16; + short channels = 1; + int byteRate = sampleRate * channels * bitsPerSample / 8; + short blockAlign = (short)(channels * bitsPerSample / 8); + int dataSize = samples.Length * blockAlign; + + // RIFF header + writer.Write("RIFF"u8); + writer.Write(36 + dataSize); + writer.Write("WAVE"u8); + + // fmt subchunk + writer.Write("fmt "u8); + writer.Write(16); // subchunk size + writer.Write((short)1); // PCM format + writer.Write(channels); + writer.Write(sampleRate); + writer.Write(byteRate); + writer.Write(blockAlign); + writer.Write((short)bitsPerSample); + + // data subchunk + writer.Write("data"u8); + writer.Write(dataSize); + + // Convert float [-1.0, 1.0] to int16 + foreach (var sample in samples) + { + var clamped = Math.Clamp(sample, -1.0f, 1.0f); + var int16 = (short)(clamped * 32767); + writer.Write(int16); + } + + writer.Flush(); + ms.Position = 0; + return ms; + } + + /// + /// Reduce a BCP-47 tag (e.g. "en-US", "zh-Hans-CN") to the 2-letter + /// language subtag that Whisper.net's WithLanguage call expects. + /// "auto" passes through unchanged. Returns "auto" for nulls/whitespace + /// or values that don't begin with at least 2 ASCII letters. + /// + internal static string NormalizeForWhisper(string? language) + { + if (string.IsNullOrWhiteSpace(language)) return "auto"; + var trimmed = language.Trim(); + if (string.Equals(trimmed, "auto", StringComparison.OrdinalIgnoreCase)) return "auto"; + + // Take everything up to the first '-' (the primary subtag) and lowercase. + var dash = trimmed.IndexOf('-'); + var primary = (dash >= 0 ? trimmed[..dash] : trimmed).ToLowerInvariant(); + + // Whisper expects 2-letter ISO 639-1. If the caller handed us a + // 3-letter ISO 639-3 tag (no good cross-walk without a table) or + // garbage, fall back to auto-detection rather than silently + // sending an invalid value. + if (primary.Length != 2 || primary[0] is < 'a' or > 'z' || primary[1] is < 'a' or > 'z') + return "auto"; + + return primary; + } + + public void Dispose() + { + _factory?.Dispose(); + _gate.Dispose(); + } +} diff --git a/src/OpenClaw.Shared/Audio/VoiceActivityDetector.cs b/src/OpenClaw.Shared/Audio/VoiceActivityDetector.cs new file mode 100644 index 000000000..3112d3a47 --- /dev/null +++ b/src/OpenClaw.Shared/Audio/VoiceActivityDetector.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace OpenClaw.Shared.Audio; + +/// +/// Voice Activity Detection using Silero VAD ONNX model. +/// Processes 16 kHz mono audio in 512-sample chunks (~32 ms each) +/// and returns a speech probability per chunk. +/// +public sealed class VoiceActivityDetector : IDisposable +{ + private InferenceSession? _session; + private float[] _state; // internal RNN state: shape [2, 1, 128] + private readonly int _stateSize; + private readonly IOpenClawLogger _logger; + + /// Expected sample rate for input audio. + public const int SampleRate = 16000; + + /// Number of samples per VAD chunk (512 @ 16 kHz = 32 ms). + public const int ChunkSamples = 512; + + public bool IsLoaded => _session != null; + + public VoiceActivityDetector(IOpenClawLogger logger) + { + _logger = logger; + _stateSize = 2 * 1 * 128; + _state = new float[_stateSize]; + } + + /// Load the Silero VAD ONNX model from disk. + public void LoadModel(string modelPath) + { + if (!System.IO.File.Exists(modelPath)) + throw new System.IO.FileNotFoundException($"VAD model not found: {modelPath}"); + + var opts = new SessionOptions + { + InterOpNumThreads = 1, + IntraOpNumThreads = 1, + EnableCpuMemArena = true + }; + opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; + + _session?.Dispose(); + _session = new InferenceSession(modelPath, opts); + ResetState(); + _logger.Info($"Silero VAD model loaded: {modelPath}"); + } + + /// Reset the internal RNN state (call between utterances). + public void ResetState() + { + Array.Clear(_state, 0, _state.Length); + } + + /// + /// Process a single chunk of audio and return the speech probability (0.0–1.0). + /// Input must be exactly float samples at 16 kHz. + /// + public float ProcessChunk(float[] audioChunk) + { + if (_session == null) + throw new InvalidOperationException("VAD model not loaded. Call LoadModel first."); + + if (audioChunk.Length != ChunkSamples) + throw new ArgumentException($"Audio chunk must be exactly {ChunkSamples} samples, got {audioChunk.Length}"); + + // Build input tensors matching Silero VAD v5 expected shapes. + // See: github.com/snakers4/silero-vad/blob/master/examples/csharp/SileroVadOnnxModel.cs + var inputTensor = new DenseTensor(audioChunk, new[] { 1, ChunkSamples }); + var srTensor = new DenseTensor(new long[] { SampleRate }, new[] { 1 }); + var stateTensor = new DenseTensor(_state, new[] { 2, 1, 128 }); + + using var results = _session.Run(new List + { + NamedOnnxValue.CreateFromTensor("input", inputTensor), + NamedOnnxValue.CreateFromTensor("sr", srTensor), + NamedOnnxValue.CreateFromTensor("state", stateTensor) + }); + + float probability = 0f; + foreach (var result in results) + { + if (result.Name == "output") + { + var tensor = result.AsTensor(); + probability = tensor.Length > 0 ? tensor.GetValue(0) : 0f; + } + else if (result.Name == "stateN") + { + var newState = result.AsTensor(); + for (int i = 0; i < _stateSize && i < newState.Length; i++) + _state[i] = newState.GetValue(i); + } + } + + return probability; + } + + public void Dispose() + { + _session?.Dispose(); + } +} diff --git a/src/OpenClaw.Shared/Audio/WhisperModelManager.cs b/src/OpenClaw.Shared/Audio/WhisperModelManager.cs new file mode 100644 index 000000000..6b49341ec --- /dev/null +++ b/src/OpenClaw.Shared/Audio/WhisperModelManager.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Audio; + +/// +/// Manages Whisper GGML model downloads, storage, and lifecycle. +/// Models are stored in %APPDATA%\OpenClawTray\models\ (or the +/// configured data directory). +/// +public sealed class WhisperModelManager +{ + private readonly string _modelsDirectory; + private readonly IOpenClawLogger _logger; + // Per-model single-flight gate: a manual auto-download (VoiceService + // EnsureInitializedAsync) and a UI-triggered download for the same + // model would otherwise both write the same .tmp file. Static so an + // additional manager instance constructed elsewhere (e.g. the Settings + // page's status-only check) doesn't bypass the lock. + private static readonly ConcurrentDictionary> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Known Whisper model definitions. + /// + /// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against + /// HuggingFace on 2026-05-05. Downloads with a different hash are + /// rejected and the partial file is deleted. Before any public release: + /// re-verify each hash from an independent source and document the + /// provenance in Audio_FollowUps.md §2 (also consider replacing this + /// inline table with a signed manifest). + /// + public static readonly WhisperModelInfo[] AvailableModels = + [ + new("ggml-tiny.bin", "tiny", 77_691_713, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", + "be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"), + new("ggml-base.bin", "base", 147_951_465, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", + "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"), + new("ggml-small.bin", "small", 487_601_967, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", + "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b"), + ]; + + public WhisperModelManager(string dataDirectory, IOpenClawLogger logger) + { + _modelsDirectory = Path.Combine(dataDirectory, "models"); + _logger = logger; + Directory.CreateDirectory(_modelsDirectory); + } + + /// Full file path for a given model name. + public string GetModelPath(string modelName) + { + var info = FindModel(modelName); + return Path.Combine(_modelsDirectory, info.FileName); + } + + /// Check whether a model file already exists on disk. + public bool IsModelDownloaded(string modelName) + { + var path = GetModelPath(modelName); + return File.Exists(path); + } + + /// Get the size of a downloaded model, or 0 if not downloaded. + public long GetModelSize(string modelName) + { + var path = GetModelPath(modelName); + return File.Exists(path) ? new FileInfo(path).Length : 0; + } + + /// + /// Download a model from HuggingFace if not already present. + /// Reports progress as bytes downloaded / total bytes. + /// Per-model single-flight: concurrent calls for the same model await + /// the in-flight download instead of racing on the same .tmp file. + /// + public Task DownloadModelAsync( + string modelName, + IProgress<(long downloaded, long total)>? progress = null, + CancellationToken cancellationToken = default) + { + var info = FindModel(modelName); + var destPath = Path.Combine(_modelsDirectory, info.FileName); + + if (File.Exists(destPath)) + { + _logger.Info($"Model '{modelName}' already exists at {destPath}"); + return Task.CompletedTask; + } + + // Use the canonical key (FileName) so two callers that pass "base" + // and "ggml-base.bin" still coalesce. + var key = info.FileName; + return SingleFlightDownload.RunAsync( + InFlightDownloads, + key, + token => DownloadModelCoreAsync(info, destPath, progress, token), + cancellationToken); + } + + private async Task DownloadModelCoreAsync( + WhisperModelInfo info, + string destPath, + IProgress<(long downloaded, long total)>? progress, + CancellationToken cancellationToken) + { + // SECURITY: a missing pinned hash is treated as a hard failure so we + // never install an unverified asset. The catalog above pins all + // shipped models; if you add a new one without a hash, this is the + // place that refuses to download it. See Audio_FollowUps.md §2. + if (string.IsNullOrWhiteSpace(info.Sha256)) + { + throw new InvalidOperationException( + $"Whisper model '{info.Name}' has no pinned SHA-256; refusing to download. " + + "Add a verified hash to AvailableModels before enabling this model."); + } + + _logger.Info($"Downloading model '{info.Name}' from {info.DownloadUrl}"); + var tempPath = destPath + ".tmp"; + + try + { + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(30); + using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? info.ApproximateSizeBytes; + using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken)) + using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920)) + { + var buffer = new byte[81920]; + long downloadedBytes = 0; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + downloadedBytes += bytesRead; + progress?.Report((downloadedBytes, totalBytes)); + } + + await fileStream.FlushAsync(cancellationToken); + } + + // SECURITY: verify SHA-256 BEFORE the atomic rename, so a + // tampered file never lands at the canonical path. On mismatch + // we delete the temp file (no partial install) and surface a + // sanitized error — we deliberately do NOT echo the actual + // hash because that gives an attacker a confirmation oracle. + await VerifyHashAsync(tempPath, info.Sha256, info.Name, cancellationToken); + + File.Move(tempPath, destPath, overwrite: true); + _logger.Info($"Model '{info.Name}' downloaded and verified"); + } + catch + { + // Clean up partial download + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best effort */ } + throw; + } + } + + /// + /// Compute SHA-256 of and compare to + /// . Throws on mismatch (and the caller + /// is expected to delete the file). Does not echo the actual hash to + /// avoid handing attackers a confirmation oracle. + /// + private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); + var actual = await sha.ComputeHashAsync(stream, cancellationToken); + var actualHex = Convert.ToHexString(actual).ToLowerInvariant(); + if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase)) + { + throw new System.Security.SecurityException( + $"Whisper model '{assetName}' failed integrity check. The downloaded file does not match the pinned SHA-256."); + } + } + + /// Delete a downloaded model file. + public bool DeleteModel(string modelName) + { + var path = GetModelPath(modelName); + if (!File.Exists(path)) return false; + File.Delete(path); + _logger.Info($"Deleted model '{modelName}'"); + return true; + } + + private static WhisperModelInfo FindModel(string modelName) + { + foreach (var m in AvailableModels) + { + if (string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase)) + return m; + } + throw new ArgumentException($"Unknown model: '{modelName}'. Available: tiny, base, small"); + } +} + +/// Metadata about a Whisper model variant. +/// On-disk filename (e.g. "ggml-base.bin"). +/// Short identifier used by callers ("tiny" / "base" / "small"). +/// Approximate size hint for UI; the +/// actual size is asserted against after download. +/// HTTPS URL of the model file. +/// Pinned lowercase hex SHA-256 of the downloaded file. +/// MUST be set; downloads are refused when null. See the catalog for the +/// "verified on" date — these need re-verification before any public +/// release (see Audio_FollowUps.md §2). +public sealed record WhisperModelInfo( + string FileName, + string Name, + long ApproximateSizeBytes, + string DownloadUrl, + string? Sha256); diff --git a/src/OpenClaw.Shared/Capabilities/BrowserProxyCapability.cs b/src/OpenClaw.Shared/Capabilities/BrowserProxyCapability.cs index df3c45640..66632aa40 100644 --- a/src/OpenClaw.Shared/Capabilities/BrowserProxyCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/BrowserProxyCapability.cs @@ -80,19 +80,23 @@ public override async Task ExecuteAsync(NodeInvokeRequest re } catch (HttpRequestException ex) { - return Error($"Browser control host is not reachable on 127.0.0.1:{controlPort}: {ex.Message}. {BuildReachabilityGuidance(controlPort, _sshRemoteGatewayPort)}"); + Logger.Warn($"browser proxy: control host unreachable on 127.0.0.1:{controlPort}: {ex.Message}"); + return Error($"Browser control host is not reachable on 127.0.0.1:{controlPort}. {BuildReachabilityGuidance(controlPort, _sshRemoteGatewayPort)}"); } catch (JsonException ex) { - return Error($"Browser control host returned invalid JSON: {ex.Message}"); + Logger.Warn($"browser proxy: control host returned invalid JSON: {ex.Message}"); + return Error("Browser control host returned invalid JSON"); } catch (IOException ex) { - return Error($"Browser proxy file read failed: {ex.Message}"); + Logger.Warn($"browser proxy: file read failed: {ex.Message}"); + return Error("Browser proxy file read failed"); } catch (UnauthorizedAccessException ex) { - return Error($"Browser proxy file read denied: {ex.Message}"); + Logger.Warn($"browser proxy: file read denied: {ex.Message}"); + return Error("Browser proxy file read denied"); } } diff --git a/src/OpenClaw.Shared/Capabilities/CameraCapability.cs b/src/OpenClaw.Shared/Capabilities/CameraCapability.cs index aaddf1705..547a5bedf 100644 --- a/src/OpenClaw.Shared/Capabilities/CameraCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/CameraCapability.cs @@ -60,7 +60,7 @@ private async Task HandleListAsync(NodeInvokeRequest request catch (Exception ex) { Logger.Error("Camera list failed", ex); - return Error($"List failed: {ex.Message}"); + return Error("List failed"); } } @@ -106,7 +106,7 @@ private async Task HandleSnapAsync(NodeInvokeRequest request catch (Exception ex) { Logger.Error("Camera snap failed", ex); - return Error($"Snap failed: {ex.Message}"); + return Error("Snap failed"); } } @@ -147,7 +147,7 @@ private async Task HandleClipAsync(NodeInvokeRequest request catch (Exception ex) { Logger.Error("Camera clip failed", ex); - return Error($"Clip failed: {ex.Message}"); + return Error("Clip failed"); } } } diff --git a/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs b/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs index b093bd439..d98cac7fa 100644 --- a/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs @@ -216,8 +216,8 @@ private async Task HandleNavigateAsync(NodeInvokeRequest req } catch (Exception ex) { - Logger.Error($"canvas.navigate handler failed: {ex.Message}", ex); - return Error($"Navigate failed: {ex.Message}"); + Logger.Error("canvas.navigate handler failed", ex); + return Error("Navigate failed"); } } @@ -246,7 +246,12 @@ private async Task HandleEvalAsync(NodeInvokeRequest request } catch (Exception ex) { - return Error($"Eval failed: {ex.Message}"); + Logger.Error("canvas.eval handler failed", ex); + // Surface structured CANVAS_* errors for diagnostics; keep generic + // message for other exceptions to avoid leaking internal details. + var msg = ex.Message.StartsWith("CANVAS_", StringComparison.Ordinal) + ? ex.Message : "Eval failed"; + return Error(msg); } } @@ -277,7 +282,10 @@ private async Task HandleSnapshotAsync(NodeInvokeRequest req } catch (Exception ex) { - return Error($"Snapshot failed: {ex.Message}"); + Logger.Error("canvas.snapshot handler failed", ex); + var msg = ex.Message.StartsWith("CANVAS_", StringComparison.Ordinal) + ? ex.Message : "Snapshot failed"; + return Error(msg); } } @@ -296,7 +304,7 @@ private NodeInvokeResponse HandleA2UIPush(NodeInvokeRequest request) catch (Exception ex) { Logger.Error($"{request.Command}: failed to read jsonlPath", ex); - return Error($"Failed to read jsonlPath: {ex.Message}"); + return Error("Failed to read jsonlPath"); } } @@ -367,7 +375,7 @@ private string ReadValidatedJsonlPath(string jsonlPath, string command) } catch (Exception ex) { - throw new InvalidOperationException($"Invalid jsonlPath: {ex.Message}", ex); + throw new InvalidOperationException("Invalid jsonlPath", ex); } if (!IsPathWithinRoot(fullPath, tempRoot)) @@ -404,8 +412,10 @@ private string ReadValidatedJsonlPath(string jsonlPath, string command) } using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + // GetFinalPathFromHandle is a Windows-only guard (returns "" on non-Windows); skip the + // containment check when no resolved path is available — prior symlink resolution covers that case. var finalPath = GetFinalPathFromHandle(stream.SafeFileHandle); - if (!IsPathWithinRoot(finalPath, tempRoot)) + if (!string.IsNullOrEmpty(finalPath) && !IsPathWithinRoot(finalPath, tempRoot)) { Logger.Warn($"{command}: jsonlPath file handle resolves outside temp directory: {finalPath}"); throw new InvalidOperationException("jsonlPath must resolve within the system temp directory"); @@ -495,7 +505,8 @@ private async Task HandleA2UIDumpAsync() } catch (Exception ex) { - return Error($"CANVAS_DUMP_FAILED: {ex.Message}"); + Logger.Error("canvas.a2ui.dump handler failed", ex); + return Error("CANVAS_DUMP_FAILED"); } } @@ -521,7 +532,8 @@ private async Task HandleCapsAsync() } catch (Exception ex) { - return Error($"CANVAS_CAPS_FAILED: {ex.Message}"); + Logger.Error("canvas.a2ui.caps handler failed", ex); + return Error("CANVAS_CAPS_FAILED"); } } } diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs index e975f6cd2..2c05ae9ac 100644 --- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs @@ -149,7 +149,7 @@ private async Task HandleStatusAsync(NodeInvokeRequest reque catch (Exception ex) { Logger.Warn($"device.status: {section} collection failed: {ex.Message}"); - return new { error = ex.Message }; + return new { error = "collection failed" }; } } @@ -159,7 +159,7 @@ private async Task HandleStatusAsync(NodeInvokeRequest reque catch (Exception ex) { Logger.Warn($"device.status: {section} collection failed: {ex.Message}"); - return new { error = ex.Message }; + return new { error = "collection failed" }; } } diff --git a/src/OpenClaw.Shared/Capabilities/LocationCapability.cs b/src/OpenClaw.Shared/Capabilities/LocationCapability.cs index 93b8309c0..832e0cbfe 100644 --- a/src/OpenClaw.Shared/Capabilities/LocationCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/LocationCapability.cs @@ -64,7 +64,7 @@ private async Task HandleGetAsync(NodeInvokeRequest request) catch (Exception ex) { Logger.Error("location.get failed", ex); - return Error($"Location failed: {ex.Message}"); + return Error("Location failed"); } } } diff --git a/src/OpenClaw.Shared/Capabilities/ScreenCapability.cs b/src/OpenClaw.Shared/Capabilities/ScreenCapability.cs index 8d07accb8..b81bf19a4 100644 --- a/src/OpenClaw.Shared/Capabilities/ScreenCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/ScreenCapability.cs @@ -84,7 +84,7 @@ private async Task HandleCaptureAsync(NodeInvokeRequest requ catch (Exception ex) { Logger.Error("Screen capture failed", ex); - return Error($"Capture failed: {ex.Message}"); + return Error("Capture failed"); } } @@ -134,7 +134,7 @@ private async Task HandleRecordAsync(NodeInvokeRequest reque catch (Exception ex) { Logger.Error("Screen recording failed", ex); - return Error($"Recording failed: {ex.Message}"); + return Error("Recording failed"); } } diff --git a/src/OpenClaw.Shared/Capabilities/SttCapability.cs b/src/OpenClaw.Shared/Capabilities/SttCapability.cs new file mode 100644 index 000000000..4a944054e --- /dev/null +++ b/src/OpenClaw.Shared/Capabilities/SttCapability.cs @@ -0,0 +1,339 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Capabilities; + +/// +/// Speech-to-text node capability. Three commands: +/// +/// * — bounded fixed-duration capture + transcription. +/// Caller must specify maxDurationMs (capped at ). +/// Useful for quick "give me 5 seconds of audio" prompts. +/// +/// * — VAD-driven capture that returns when speech ends +/// or after timeoutMs (default , range +/// ..). +/// Useful for conversational "listen until I stop talking" prompts. +/// +/// * — reports engine readiness (no PII). +/// +/// The actual engine lives in the tray (Whisper.net + NAudio + Silero VAD). +/// Whisper is local-first and privacy-respecting; the legacy WinRT +/// SpeechRecognizer + desktop SAPI fallback was removed because both +/// stacks are old, can leak audio to the Microsoft cloud (online-speech), +/// and don't work in unpackaged builds. +/// +/// **Privacy invariants for the response surface:** +/// - Validation errors never echo the caller-supplied language string. +/// - Handler exceptions never propagate their Message into the response; +/// full detail stays in the local logger only. This is critical because +/// failed-invoke errors land in recent activity / support bundles. +/// - response carries no PII (no transcript fragments, +/// no language history, no device IDs, no model paths). +/// +public sealed class SttCapability : NodeCapabilityBase +{ + public const string TranscribeCommand = "stt.transcribe"; + public const string ListenCommand = "stt.listen"; + public const string StatusCommand = "stt.status"; + + public const int MaxTranscribeDurationMs = 30_000; + public const int MinListenTimeoutMs = 1_000; + public const int MaxListenTimeoutMs = 120_000; + public const int DefaultListenTimeoutMs = 30_000; + + public const string DefaultLanguage = "en-US"; + public const string AutoLanguage = "auto"; + + /// + /// Engine identifier returned in engineEffective on every successful + /// stt.* response. Currently always "whisper"; the field exists so + /// adding a future engine doesn't break the wire shape. + /// + public const string EngineWhisper = "whisper"; + + private static readonly string[] _commands = [TranscribeCommand, ListenCommand, StatusCommand]; + + // Conservative BCP-47 check: 2-3 letter language, optional script + // (4 letter), optional region (2 letter or 3 digit), each separated + // by a hyphen. Rejects whitespace and punctuation that would otherwise + // trip Windows.Globalization.Language ctor. The literal "auto" + // sentinel is accepted in addition (Whisper supports auto-detect). + private static readonly Regex BcpTagRegex = new( + "^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?$", + RegexOptions.Compiled); + + public override string Category => "stt"; + public override IReadOnlyList Commands => _commands; + + /// + /// Tray-side handler for : bounded fixed-duration + /// capture + transcription. + /// + public event Func>? TranscribeRequested; + + /// + /// Tray-side handler for : VAD-driven capture that + /// returns on end-of-speech or after timeoutMs. + /// + public event Func>? ListenRequested; + + /// + /// Tray-side handler for : returns per-engine readiness. + /// + public event Func>? StatusRequested; + + public SttCapability(IOpenClawLogger logger) : base(logger) { } + + /// + /// Trim and validate a single language tag. Returns the trimmed tag on + /// success, the literal sentinel on a case-insensitive + /// "auto" input, or null if the input is neither. + /// Public so UI surfaces can validate against the same rule the wire applies. + /// + public static string? NormalizeLanguageTag(string tag) + { + var trimmed = tag.Trim(); + if (string.Equals(trimmed, AutoLanguage, StringComparison.OrdinalIgnoreCase)) + return AutoLanguage; + return BcpTagRegex.IsMatch(trimmed) ? trimmed : null; + } + + /// + /// Resolve the language to use for a recognition call: per-call argument + /// wins, then configured setting, then . + /// Returns null if the resolved string fails validation. + /// + public static string? ResolveLanguage(string? requested, string? configured) + { + var candidate = !string.IsNullOrWhiteSpace(requested) + ? requested + : (!string.IsNullOrWhiteSpace(configured) ? configured : DefaultLanguage); + + return NormalizeLanguageTag(candidate!); + } + + public override Task ExecuteAsync(NodeInvokeRequest request) + => ExecuteAsync(request, CancellationToken.None); + + public override async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + return request.Command switch + { + TranscribeCommand => await HandleTranscribeAsync(request, cancellationToken).ConfigureAwait(false), + ListenCommand => await HandleListenAsync(request, cancellationToken).ConfigureAwait(false), + StatusCommand => await HandleStatusAsync(cancellationToken).ConfigureAwait(false), + _ => Error($"Unknown command: {request.Command}") + }; + } + + private async Task HandleTranscribeAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + // maxDurationMs is required and bounded server-side. We deliberately + // reject 0/negative rather than substituting a default — callers + // explicitly choose how much mic time they're spending. + var maxDurationMs = GetIntArg(request.Args, "maxDurationMs", 0); + if (maxDurationMs <= 0) + return Error("Missing required maxDurationMs"); + if (maxDurationMs > MaxTranscribeDurationMs) + return Error($"maxDurationMs exceeds {MaxTranscribeDurationMs} ms"); + + var requestedLanguage = GetStringArg(request.Args, "language"); + string? resolvedLanguage = null; + if (!string.IsNullOrWhiteSpace(requestedLanguage)) + { + resolvedLanguage = NormalizeLanguageTag(requestedLanguage); + if (resolvedLanguage == null) + return Error("Invalid language tag"); + } + + if (TranscribeRequested == null) + return Error("STT transcribe not available"); + + var args = new SttTranscribeArgs + { + MaxDurationMs = maxDurationMs, + Language = resolvedLanguage // null lets the tray fall back to its configured setting + }; + + Logger.Info($"stt.transcribe: maxDurationMs={args.MaxDurationMs}, language={args.Language ?? "(default)"}"); + + try + { + var result = await TranscribeRequested(args, cancellationToken).ConfigureAwait(false); + return Success(new + { + transcribed = result.Transcribed, + text = result.Text, + durationMs = result.DurationMs, + language = result.Language, + engineEffective = result.EngineEffective + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Error("Transcribe canceled"); + } + catch (Exception ex) + { + // Privacy: never echo raw exception text into the response. The + // exception flows through the failed-invoke path and may be + // persisted to recent activity / support bundles. Full detail + // stays in the local log only. + Logger.Error("STT transcribe failed", ex); + return Error("Transcribe failed"); + } + } + + private async Task HandleListenAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + // timeoutMs is optional with a sane default; bounded both ways so + // a hostile caller can't pin the mic open for an hour. + var timeoutMs = GetIntArg(request.Args, "timeoutMs", DefaultListenTimeoutMs); + if (timeoutMs < MinListenTimeoutMs) timeoutMs = MinListenTimeoutMs; + if (timeoutMs > MaxListenTimeoutMs) timeoutMs = MaxListenTimeoutMs; + + var requestedLanguage = GetStringArg(request.Args, "language"); + string resolvedLanguage = AutoLanguage; + if (!string.IsNullOrWhiteSpace(requestedLanguage)) + { + var normalized = NormalizeLanguageTag(requestedLanguage); + if (normalized == null) + return Error("Invalid language tag"); + resolvedLanguage = normalized; + } + + if (ListenRequested == null) + return Error("STT listen not available"); + + var args = new SttListenArgs + { + TimeoutMs = timeoutMs, + Language = resolvedLanguage + }; + + Logger.Info($"stt.listen: timeoutMs={timeoutMs}, language={resolvedLanguage}"); + + try + { + var result = await ListenRequested(args, cancellationToken).ConfigureAwait(false); + return Success(new + { + text = result.Text, + language = result.Language, + durationMs = result.DurationMs, + segments = result.Segments, + engineEffective = result.EngineEffective + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Error("Listen canceled"); + } + catch (Exception ex) + { + // Same privacy invariant as Transcribe. + Logger.Error("STT listen failed", ex); + return Error("Listen failed"); + } + } + + private async Task HandleStatusAsync(CancellationToken cancellationToken) + { + if (StatusRequested == null) + return Error("STT status not available"); + + try + { + var result = await StatusRequested(cancellationToken).ConfigureAwait(false); + return Success(new + { + engine = result.Engine, + readiness = result.Readiness, + modelDownloadProgress = result.ModelDownloadProgress, + isListenWithVadSupported = result.IsListenWithVadSupported, + isBoundedTranscribeSupported = result.IsBoundedTranscribeSupported + }); + } + catch (Exception ex) + { + // Status must not leak engine internals; carry only a fixed message. + Logger.Error("STT status failed", ex); + return Error("Status failed"); + } + } +} + +public sealed class SttTranscribeArgs +{ + public int MaxDurationMs { get; set; } + /// + /// BCP-47 tag (e.g., "en-US"), the literal "auto" sentinel, or null + /// to let the tray fall back to its configured SttLanguage setting. + /// + public string? Language { get; set; } +} + +public sealed class SttTranscribeResult +{ + public bool Transcribed { get; set; } + public string Text { get; set; } = ""; + public int DurationMs { get; set; } + public string Language { get; set; } = SttCapability.DefaultLanguage; + + /// + /// Engine that served this call. Always + /// today; the field exists so a future engine doesn't break the wire. + /// + public string EngineEffective { get; set; } = SttCapability.EngineWhisper; +} + +public sealed class SttListenArgs +{ + public int TimeoutMs { get; set; } + /// + /// BCP-47 tag (e.g., "en-US"), or the literal "auto" sentinel + /// (default; lets Whisper auto-detect). + /// + public string Language { get; set; } = SttCapability.AutoLanguage; +} + +public sealed class SttListenResult +{ + public string Text { get; set; } = ""; + public string Language { get; set; } = SttCapability.AutoLanguage; + public int DurationMs { get; set; } + public IReadOnlyList Segments { get; set; } = Array.Empty(); + + public string EngineEffective { get; set; } = SttCapability.EngineWhisper; +} + +public sealed class SttSegment +{ + public string Text { get; set; } = ""; + public int StartMs { get; set; } + public int EndMs { get; set; } +} + +public sealed class SttStatusResult +{ + public string Engine { get; set; } = SttCapability.EngineWhisper; + + /// One of "ready", "initializing", "model-downloading", "model-not-downloaded", "unavailable". + public string Readiness { get; set; } = "unavailable"; + + /// 0..1 download progress when == "model-downloading"; null otherwise. + public double? ModelDownloadProgress { get; set; } + + public bool IsListenWithVadSupported { get; set; } + public bool IsBoundedTranscribeSupported { get; set; } +} diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 91d0e1afe..21914912f 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -271,7 +271,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) { // Rail 1: no silent fallback — handler exceptions become typed denies. Logger.Error($"[system.run] corr={correlationId} path=v2 handler threw", ex); - v2Result = ExecApprovalV2Result.ValidationFailed($"Handler exception: {ex.Message}"); + v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception"); } Logger.Info($"[system.run] corr={correlationId} decision={v2Result.Code} reason={v2Result.Reason}"); @@ -413,7 +413,7 @@ private async Task HandleRunAsync(NodeInvokeRequest request) catch (Exception ex) { Logger.Error("system.run failed", ex); - return Error($"Execution failed: {ex.Message}"); + return Error("Execution failed"); } } @@ -614,7 +614,7 @@ private NodeInvokeResponse HandleExecApprovalsSet(NodeInvokeRequest request) catch (Exception ex) { Logger.Error("execApprovals.set failed", ex); - return Error($"Failed to update policy: {ex.Message}"); + return Error("Failed to update policy"); } } @@ -643,6 +643,26 @@ private NodeInvokeResponse HandleExecApprovalsSet(NodeInvokeRequest request) if (normalized is "powershell *" or "pwsh *" or "cmd *" or "cmd.exe *") return $"Broad allow rule is not permitted: {pattern}"; + // Reject Allow rules whose pattern looks like an absolute file path. + // A remote .set call should never be able to whitelist a specific binary + // by path — that would be a two-step EoP (compromise MCP token → whitelist + // attacker binary → invoke it). Legitimate rules name commands, not paths. + // Strip one layer of matching surrounding quotes first so quoted forms + // ("C:\evil.exe", 'C:\evil.exe') are caught alongside bare paths. + // Covers: drive-rooted paths (C:\, C:/), UNC/long-path (\\, //), and + // forward-slash UNC namespace forms (//server/share, //?/C:/evil.exe). + var unquoted = normalized; + if (normalized.Length >= 2 && + ((normalized[0] == '"' && normalized[^1] == '"') || + (normalized[0] == '\'' && normalized[^1] == '\''))) + unquoted = normalized[1..^1]; + + if (unquoted.Length >= 3 && + ((char.IsLetter(unquoted[0]) && unquoted[1] == ':' && (unquoted[2] == '\\' || unquoted[2] == '/')) || + unquoted.StartsWith(@"\\", StringComparison.Ordinal) || + unquoted.StartsWith("//", StringComparison.Ordinal))) + return $"Absolute path allow rule is not permitted: {pattern}"; + foreach (var dangerous in DangerousAllowPatternFragments) { if (normalized.Contains(dangerous, StringComparison.Ordinal)) diff --git a/src/OpenClaw.Shared/Capabilities/TtsCapability.cs b/src/OpenClaw.Shared/Capabilities/TtsCapability.cs index c64078285..23878b221 100644 --- a/src/OpenClaw.Shared/Capabilities/TtsCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/TtsCapability.cs @@ -10,6 +10,11 @@ public sealed class TtsCapability : NodeCapabilityBase public const string SpeakCommand = "tts.speak"; public const string WindowsProvider = "windows"; public const string ElevenLabsProvider = "elevenlabs"; + /// + /// Local neural TTS via Sherpa-ONNX wrapping Piper voices. No network + /// egress; voice models download once to %LOCALAPPDATA%. + /// + public const string PiperProvider = "piper"; public const int MaxTextLength = 5000; private static readonly string[] _commands = [SpeakCommand]; @@ -30,7 +35,7 @@ public static string ResolveProvider(string? requestedProvider, string? configur : requestedProvider; return string.IsNullOrWhiteSpace(provider) - ? WindowsProvider + ? PiperProvider : provider.Trim().ToLowerInvariant(); } @@ -81,8 +86,14 @@ public override async Task ExecuteAsync( } catch (Exception ex) { + // Privacy: never echo raw exception text into the response. The + // exception flows through the failed-invoke path and may be + // persisted to recent activity / support bundles. ElevenLabs + // error messages can contain key prefixes; OS speech errors + // can contain device names. Full detail stays in the local + // log only. (Same pattern as SttCapability.) Logger.Error("TTS speak failed", ex); - return Error($"Speak failed: {ex.Message}"); + return Error("Speak failed"); } } diff --git a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs new file mode 100644 index 000000000..5cc2e39b4 --- /dev/null +++ b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Result of . +/// +/// Either is set (caller sends it via config.patch) +/// OR is set (caller refuses to send and surfaces +/// the reason to the user — typically "you've got redacted secrets in other +/// channels that we can't safely round-trip; open the Config page instead"). +/// +public sealed class ChannelPatchBuildResult +{ + /// The patched full-config to send. Null when is set. + public JsonElement? Patch { get; init; } + + /// Human-readable reason the patch was refused. Null when is set. + public string? BlockedReason { get; init; } + + /// Dot-path of the field that caused , when applicable. + public string? BlockedPath { get; init; } +} + +/// +/// Pure helper that merges a per-channel set of credential updates into a +/// cached gateway config snapshot, producing a full-config JsonElement +/// suitable for the gateway's config.patch { raw, baseHash } wire. +/// +/// Necessary because the gateway (v2026.5+) only accepts whole-config writes — +/// the legacy config.set { path, value } dot-path API was removed and +/// per-field writes are rejected with "must have required property 'raw'". +/// +/// Safety rail: the gateway redacts secrets in config.get responses, +/// so blindly round-tripping the cached config can clobber unrelated secrets +/// with their redaction sentinels. scans for the +/// common sentinels ([REDACTED], <redacted>, ***) +/// in fields OUTSIDE the channel being written and aborts the patch if any +/// are found — caller should direct the user to the Config page in that +/// state. +/// +public static class ChannelConfigPatchBuilder +{ + /// + /// Redaction sentinel strings we've observed gateways use. Matched + /// case-insensitively, trimmed, and exactly — we don't want to false- + /// positive on a value that happens to *contain* "redacted" as part of + /// a legitimate string. + /// + private static readonly HashSet RedactionSentinels = new(StringComparer.OrdinalIgnoreCase) + { + "[REDACTED]", + "", + "***", + "*****", + "********", + }; + + /// + /// Build a patched full-config from the cached config and a list of + /// per-field updates for one channel. + /// + /// + /// The actual config root (already unwrapped from any { path, raw, + /// parsed } wrapper — pass parsed). May be Undefined / Null + /// if the gateway hasn't returned config yet, in which case we start + /// with an empty document. + /// + /// Channel id, e.g. telegram. + /// + /// Dot-path / value pairs, e.g. ("channels.telegram.botToken", + /// "12345:abc"). The builder also sets channels.{channelId}.enabled + /// = true automatically — callers shouldn't include it. + /// + /// + /// Dot-paths whose string value should be split on newlines into an + /// array of trimmed non-empty entries (e.g. Nostr relay URLs). + /// + public static ChannelPatchBuildResult BuildPatch( + JsonElement cachedConfig, + string channelId, + IReadOnlyList<(string DotPath, object Value)> updates, + ISet? multilineDotPaths = null) + { + if (string.IsNullOrWhiteSpace(channelId)) + throw new ArgumentException("channelId is required", nameof(channelId)); + if (updates == null) + throw new ArgumentNullException(nameof(updates)); + + multilineDotPaths ??= new HashSet(StringComparer.Ordinal); + + // Seed a mutable dict from the cached config. An Object root is the + // only valid input — otherwise treat as fresh. + Dictionary root = cachedConfig.ValueKind == JsonValueKind.Object + ? DeserializeObject(cachedConfig) + : new Dictionary(); + + // Safety rail: scan the cached config for redaction sentinels in any + // leaf string field that lives OUTSIDE the channel we're writing. + // If we'd be re-sending one of those, abort — the gateway might + // clobber the on-disk secret with the sentinel. + var targetPrefix = $"channels.{channelId}."; + var redactedPath = FindRedactionSentinel(cachedConfig, "", targetPrefix); + if (redactedPath != null) + { + return new ChannelPatchBuildResult + { + BlockedReason = + $"Your gateway returns redacted credentials for other channels (e.g. {redactedPath}). " + + "Saving from here would risk overwriting those secrets with their redaction placeholders. " + + "Use Open Config page for safe editing while we sort this out.", + BlockedPath = redactedPath, + }; + } + + // Apply each requested field update at its dot-path. + foreach (var (path, rawValue) in updates) + { + if (string.IsNullOrWhiteSpace(path)) continue; + var value = NormalizeValue(rawValue, multilineDotPaths.Contains(path)); + SetNestedValue(root, path, value); + } + + // Channel enable flag — the gateway needs `enabled: true` alongside + // credentials before it will start the channel (see e.g. gateway test + // src/commands/channels.adds-non-default-telegram-account.test.ts). + SetNestedValue(root, $"channels.{channelId}.enabled", true); + + // Reserialize → re-parse → clone so the returned JsonElement isn't + // tied to a JsonDocument that goes out of scope on caller side. + var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }); + using var doc = JsonDocument.Parse(json); + return new ChannelPatchBuildResult { Patch = doc.RootElement.Clone() }; + } + + /// + /// Recursively walk looking for a string leaf whose + /// value matches one of . Returns the + /// first matching dot-path found, OR null if none. Paths that start with + /// are skipped — we don't care about + /// sentinels in the channel we're about to overwrite. + /// + internal static string? FindRedactionSentinel(JsonElement el, string path, string excludePrefix) + { + if (el.ValueKind == JsonValueKind.Object) + { + foreach (var prop in el.EnumerateObject()) + { + var childPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}"; + if (!string.IsNullOrEmpty(excludePrefix) && childPath.StartsWith(excludePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + var hit = FindRedactionSentinel(prop.Value, childPath, excludePrefix); + if (hit != null) return hit; + } + } + else if (el.ValueKind == JsonValueKind.Array) + { + int i = 0; + foreach (var item in el.EnumerateArray()) + { + var childPath = $"{path}[{i++}]"; + var hit = FindRedactionSentinel(item, childPath, excludePrefix); + if (hit != null) return hit; + } + } + else if (el.ValueKind == JsonValueKind.String) + { + var v = el.GetString(); + if (v != null && RedactionSentinels.Contains(v.Trim())) + return path; + } + return null; + } + + /// + /// Convert a request value into the JSON-serializable form we'll write. + /// For multiline fields we split on \n and trim each line — relay URL + /// lists arrive as one-per-line text. + /// + private static object? NormalizeValue(object value, bool multiline) + { + if (multiline && value is string s) + { + var lines = s.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length > 0) + .ToArray(); + return lines; + } + return value; + } + + /// + /// Apply a dot-separated path write to a mutable dict tree. Creates + /// intermediate objects as needed; deserializes existing JsonElement + /// objects into mutable Dictionary instances so the tree stays + /// uniformly writable. Empty segments are rejected (would create + /// "" keys in the JSON file). + /// + internal static void SetNestedValue(Dictionary dict, string dotPath, object? value) + { + var segments = dotPath.Split('.'); + if (segments.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException($"Invalid dot path '{dotPath}': empty segment.", nameof(dotPath)); + + var current = dict; + for (int i = 0; i < segments.Length - 1; i++) + { + var key = segments[i]; + if (current.TryGetValue(key, out var existing)) + { + if (existing is Dictionary childDict) + { + current = childDict; + continue; + } + if (existing is JsonElement je && je.ValueKind == JsonValueKind.Object) + { + var converted = DeserializeObject(je); + current[key] = converted; + current = converted; + continue; + } + // Existing non-object value at intermediate path — overwrite + // with a new object so the deeper write can proceed. + } + var fresh = new Dictionary(); + current[key] = fresh; + current = fresh; + } + current[segments[^1]] = value; + } + + /// + /// Deep-deserialize a JsonElement Object into a Dictionary tree we can + /// mutate. Nested objects become Dictionaries; everything else (arrays, + /// scalars) stays as JsonElement and is preserved verbatim on re- + /// serialization — only the paths we explicitly write are changed. + /// + private static Dictionary DeserializeObject(JsonElement obj) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var prop in obj.EnumerateObject()) + { + dict[prop.Name] = prop.Value.ValueKind == JsonValueKind.Object + ? DeserializeObject(prop.Value) + : (object?)prop.Value.Clone(); + } + return dict; + } +} diff --git a/src/OpenClaw.Shared/ChannelRecord.cs b/src/OpenClaw.Shared/ChannelRecord.cs new file mode 100644 index 000000000..b4aefe890 --- /dev/null +++ b/src/OpenClaw.Shared/ChannelRecord.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Aggregate view of one channel — combines the gateway-provided status snapshot +/// with capability flags and metadata. The page renders s, +/// not raw snapshots. +/// +public sealed class ChannelRecord +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string DetailLabel { get; init; } = ""; + public string? SystemImage { get; init; } + + /// Raw status JSON from channels.status. Use helpers to extract. + public JsonElement RawStatus { get; init; } + + public IReadOnlyList Accounts { get; init; } = []; + public string? DefaultAccountId { get; init; } + + /// True if this channel has any active configuration/state. Mirrors Mac's channelEnabled. + public bool IsConfigured { get; init; } + + /// + /// True when the channel is actively running on the gateway right now + /// (status.running, or any account.Running/Connected). Distinct from + /// which is the broader "has credentials or + /// any active state" flag. The page uses this to decide whether to offer + /// the "Start channel" action. + /// + public bool IsRunning { get; init; } + + /// Capability flags (per-channel — driven by id). + public ChannelCapabilities Capabilities { get; init; } + + /// True if Windows cannot host this channel even when configured (e.g. iMessage). + public bool IsUnavailableOnWindows { get; init; } + + /// Sort order (gateway-provided); lower wins. + public int SortOrder { get; init; } + + /// When we last received a status update for this channel. + public DateTime LastUpdatedAt { get; init; } + + /// Last probe completion (epoch ms → DateTime), when reported. + public DateTime? LastProbeAt { get; init; } +} + +/// Per-channel capability flags. Inferred from id; the page uses these to gate action buttons. +[Flags] +public enum ChannelCapabilities +{ + None = 0, + /// Legacy: kept on every record but unused by the page header (a single page-level Refresh-all button covers this). + CanRefresh = 1 << 0, + CanLogout = 1 << 1, + CanShowQr = 1 << 2, + CanRelink = 1 << 3, + /// Configured-but-not-running channel: offer a channels.start action. + CanStart = 1 << 4, + /// + /// Configured-and-running non-QR channel: offer a channels.stop + /// action (pause without clearing credentials). QR-link channels + /// (WhatsApp/Signal) instead expose in the + /// header — for them "logout" already means "unlink this device", + /// which is the analogous lightweight pause action. + /// + CanStop = 1 << 5, +} + +/// +/// Merges + a built-in capability/availability catalog +/// into a stable list of s suitable for binding to a list view. +/// +public static class ChannelsAggregator +{ + /// Built-in fallback ordering when the gateway returns an empty channelOrder. + public static readonly IReadOnlyList BuiltInChannelOrder = + new[] { "whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr" }; + + /// + /// Pretty labels for the built-in channels. Used when the gateway + /// hasn't reported a label (typical for "preview" channels — those the + /// user hasn't configured yet and the gateway doesn't have a plugin + /// for). Without this we'd render raw lowercase ids ("discord", + /// "googlechat") which look broken. + /// + private static readonly IReadOnlyDictionary BuiltInChannelLabels = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["whatsapp"] = "WhatsApp", + ["telegram"] = "Telegram", + ["discord"] = "Discord", + ["googlechat"] = "Google Chat", + ["slack"] = "Slack", + ["signal"] = "Signal", + ["imessage"] = "iMessage", + ["nostr"] = "Nostr", + }; + + /// Channels that require a phone-app QR scan. + private static readonly HashSet QrLinkChannels = + new(StringComparer.OrdinalIgnoreCase) { "whatsapp", "signal" }; + + /// Channels that support a per-channel logout/unlink action. + /// For QR channels (WhatsApp/Signal) Logout = unlink the device (re-scan + /// to reconnect). For non-QR channels (Telegram) Logout = clear stored + /// credentials (re-enter them to reconnect). + private static readonly HashSet LogoutChannels = + new(StringComparer.OrdinalIgnoreCase) { "whatsapp", "telegram", "signal" }; + + /// Channels that cannot be hosted on Windows. + private static readonly HashSet WindowsUnsupportedChannels = + new(StringComparer.OrdinalIgnoreCase) { "imessage" }; + + /// + /// Aggregate a snapshot into s. + /// Returns records ordered Configured-first, then by gateway/fallback sort order. + /// + /// Latest channels.status response, or null when none has been fetched. + /// Current time, used to stamp each record's . + /// + /// When true, an empty snapshot falls back to + /// (useful as a preview when no gateway is connected). When false, an empty + /// snapshot produces an empty list so the page doesn't fake-list channels + /// the gateway didn't actually report. Callers should pass true only when + /// the user isn't connected yet; once connected, the page should show + /// exactly what the gateway exposes. + /// + public static IReadOnlyList Aggregate( + ChannelsStatusSnapshot? snapshot, + DateTime now, + bool useBuiltInFallback = true) + { + snapshot ??= new ChannelsStatusSnapshot(); + + // Build the channel id list as the *union* of every source the gateway + // might use to expose channels. Older gateways and plugin-only setups + // sometimes omit channelOrder while still populating channels/meta — + // iterating channelOrder alone would silently drop them. + var order = BuildOrderedIds(snapshot, useBuiltInFallback); + var records = new List(order.Count); + + for (int i = 0; i < order.Count; i++) + { + var id = order[i]; + snapshot.Channels.TryGetValue(id, out var raw); + var accounts = snapshot.ChannelAccounts.TryGetValue(id, out var accs) ? accs : []; + snapshot.ChannelDefaultAccountId.TryGetValue(id, out var defaultAccountId); + + var configured = IsChannelConfigured(raw, accounts); + var running = IsChannelRunning(raw, accounts); + + // Capability gating: + // CanRefresh — kept for backcompat, but the page no longer renders + // a per-channel Refresh button; one page-level + // Refresh-all covers it. + // CanShowQr — QR channels (WhatsApp/Signal): available even when + // unconfigured because the QR scan IS how you + // configure them. Show-QR is the bootstrap path. + // CanRelink — QR channels, only once already configured: relink + // rotates the device link; meaningless before there's + // a device to relink. + // CanLogout — only on channels that have a session to end, AND + // only when actually configured. Hardcoding logout to + // the channel id alone shows a Logout button on + // "not configured" rows, which confuses users. + // CanStart — channel is configured but not running. Offers a + // channels.start action. Distinct from save-and-start + // in the inline form: this is the recovery affordance + // for an already-configured channel that didn't come + // up on its own. + var caps = ChannelCapabilities.CanRefresh; + if (QrLinkChannels.Contains(id)) + { + caps |= ChannelCapabilities.CanShowQr; + if (configured) caps |= ChannelCapabilities.CanRelink; + } + if (configured && LogoutChannels.Contains(id)) + caps |= ChannelCapabilities.CanLogout; + if (configured && !running) + caps |= ChannelCapabilities.CanStart; + // CanStop: lightweight pause for non-QR running channels. + // For QR channels (WhatsApp/Signal) we don't set CanStop — + // "Logout" is the analogous lightweight action there (unlink + // the device; can scan a fresh QR to reconnect). + if (configured && running && !QrLinkChannels.Contains(id)) + caps |= ChannelCapabilities.CanStop; + + // Label / DetailLabel fall back to BuiltInChannelLabels when + // the gateway didn't supply a nice name (typical for preview + // channels — those we surface from BuiltInChannelOrder for + // discoverability but that the gateway hasn't reported). + var gatewayLabel = snapshot.ResolveLabel(id); + var label = string.Equals(gatewayLabel, id, StringComparison.Ordinal) + && BuiltInChannelLabels.TryGetValue(id, out var nice) + ? nice + : gatewayLabel; + var gatewayDetailLabel = snapshot.ResolveDetailLabel(id); + var detailLabel = string.Equals(gatewayDetailLabel, id, StringComparison.Ordinal) + && BuiltInChannelLabels.TryGetValue(id, out var niceDetail) + ? niceDetail + : gatewayDetailLabel; + + records.Add(new ChannelRecord + { + Id = id, + Label = label, + DetailLabel = detailLabel, + SystemImage = snapshot.ResolveSystemImage(id), + RawStatus = raw, + Accounts = accounts, + DefaultAccountId = defaultAccountId, + IsConfigured = configured, + IsRunning = running, + Capabilities = caps, + IsUnavailableOnWindows = WindowsUnsupportedChannels.Contains(id), + SortOrder = i, + LastUpdatedAt = now, + LastProbeAt = ExtractLastProbeAt(raw), + }); + } + + return records + .OrderByDescending(r => r.IsConfigured) + .ThenBy(r => r.SortOrder) + .ToList(); + } + + /// + /// Build the ordered channel id list by unioning every source in the snapshot: + /// channelOrder (canonical, if provided) → channel ids in channels + /// (covers older gateways missing channelOrder) → ids in channelMeta / + /// channelAccounts → optionally . + /// + /// + /// When true, the built-in catalog is **always** unioned in so the + /// AVAILABLE section gives the user discoverable options to add — not + /// just whatever the gateway has already configured. Channels reported + /// by the gateway dedupe correctly via the OrdinalIgnoreCase HashSet + /// and keep their gateway-provided position; pure built-in extras + /// append at the end in BuiltInChannelOrder order. + /// When false, only what the gateway reported is returned — honest + /// mode for callers that don't want to surface "preview" channels the + /// gateway can't host. + /// + internal static IReadOnlyList BuildOrderedIds( + ChannelsStatusSnapshot snapshot, + bool useBuiltInFallback = true) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var order = new List(); + + void Append(IEnumerable source) + { + foreach (var id in source) + { + if (string.IsNullOrEmpty(id)) continue; + if (seen.Add(id)) order.Add(id); + } + } + + Append(snapshot.ChannelOrder); + if (snapshot.ChannelMeta is { } meta) Append(meta.Select(m => m.Id)); + Append(snapshot.Channels.Keys); + Append(snapshot.ChannelAccounts.Keys); + + // Always union the built-in catalog (when requested) so the + // AVAILABLE section stays populated even when the gateway is + // only reporting the user's currently-configured channels. + // Discoverability matters more than the small risk of surfacing + // a channel whose plugin isn't installed — the Save flow already + // detects "unknown channel" responses and points the user at + // openclaw plugins install . + if (useBuiltInFallback) Append(BuiltInChannelOrder); + return order; + } + + /// Mac's channelEnabled rule: configured || running || connected || any-account-active. + public static bool IsChannelConfigured(JsonElement raw, IReadOnlyList? accounts) + { + if (raw.ValueKind == JsonValueKind.Object) + { + if (TryGetBool(raw, "configured")) return true; + if (TryGetBool(raw, "running")) return true; + if (TryGetBool(raw, "connected")) return true; + } + if (accounts != null) + { + foreach (var acc in accounts) + if (acc.Configured == true || acc.Running == true || acc.Connected == true) + return true; + } + return false; + } + + /// + /// Narrower than : true only when the + /// channel is actively running (status.running, or any account is + /// running/connected). A channel that's only configured: true but + /// not yet running returns false, so the page can offer "Start channel". + /// + public static bool IsChannelRunning(JsonElement raw, IReadOnlyList? accounts) + { + if (raw.ValueKind == JsonValueKind.Object) + { + if (TryGetBool(raw, "running")) return true; + if (TryGetBool(raw, "connected")) return true; + } + if (accounts != null) + { + foreach (var acc in accounts) + if (acc.Running == true || acc.Connected == true) + return true; + } + return false; + } + + private static bool TryGetBool(JsonElement parent, string property) => + parent.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; + + private static DateTime? ExtractLastProbeAt(JsonElement raw) + { + if (raw.ValueKind != JsonValueKind.Object) return null; + if (!raw.TryGetProperty("lastProbeAt", out var ms) || ms.ValueKind != JsonValueKind.Number) return null; + if (!ms.TryGetDouble(out var d)) return null; + return DateTimeOffset.FromUnixTimeMilliseconds((long)d).UtcDateTime; + } +} diff --git a/src/OpenClaw.Shared/ChannelsSnapshot.cs b/src/OpenClaw.Shared/ChannelsSnapshot.cs new file mode 100644 index 000000000..721aaa3c5 --- /dev/null +++ b/src/OpenClaw.Shared/ChannelsSnapshot.cs @@ -0,0 +1,340 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Strongly-typed snapshot returned by channels.status. +/// Mirrors the shape used by the macOS app (ChannelsStatusSnapshot) and the +/// web UI (ChannelsStatusSnapshot in ui/src/ui/types.ts). +/// +/// The gateway is the source of truth for channel metadata: , +/// , , +/// , and all come from the +/// gateway's plugin registry. The tray UI renders whatever is reported; it does not +/// maintain its own list. +/// +public sealed class ChannelsStatusSnapshot +{ + /// Server timestamp (epoch seconds, fractional). + public double Ts { get; init; } + + /// Canonical ordered list of channel ids the gateway is aware of. + public IReadOnlyList ChannelOrder { get; init; } = []; + + /// Sidebar label per id (e.g. "whatsapp" → "WhatsApp"). + public IReadOnlyDictionary ChannelLabels { get; init; } + = new Dictionary(); + + /// Detail-pane title per id (falls back to ). + public IReadOnlyDictionary? ChannelDetailLabels { get; init; } + + /// SF Symbol name per id. Windows maps these to Fluent glyphs. + public IReadOnlyDictionary? ChannelSystemImages { get; init; } + + /// Richer per-channel UI metadata. When present, takes precedence over the *Labels maps. + public IReadOnlyList? ChannelMeta { get; init; } + + /// + /// Per-channel raw status JSON. Keys are channel ids; values are arbitrary channel-specific + /// status documents (typed records like available for + /// built-ins; plugin channels use the generic shape). + /// + public IReadOnlyDictionary Channels { get; init; } + = new Dictionary(); + + /// Per-channel account list. Channels with multi-account support (e.g. WhatsApp Business). + public IReadOnlyDictionary> ChannelAccounts { get; init; } + = new Dictionary>(); + + /// Per-channel default account id (when multi-account). + public IReadOnlyDictionary ChannelDefaultAccountId { get; init; } + = new Dictionary(); + + /// Resolve the sidebar label for a channel id, falling back through the precedence list Mac uses. + public string ResolveLabel(string id) + { + if (ChannelMeta is { } meta) + { + var entry = meta.FirstOrDefault(e => e.Id == id); + if (entry != null && !string.IsNullOrEmpty(entry.Label)) return entry.Label; + } + if (ChannelLabels.TryGetValue(id, out var label) && !string.IsNullOrEmpty(label)) return label; + return id; + } + + /// Resolve the detail-pane title for a channel id. + public string ResolveDetailLabel(string id) + { + if (ChannelMeta is { } meta) + { + var entry = meta.FirstOrDefault(e => e.Id == id); + if (entry != null && !string.IsNullOrEmpty(entry.DetailLabel)) return entry.DetailLabel; + } + if (ChannelDetailLabels is { } details && details.TryGetValue(id, out var detail) && !string.IsNullOrEmpty(detail)) + return detail; + return ResolveLabel(id); + } + + /// Resolve the SF Symbol name for a channel id (callers map to Fluent glyphs). + public string? ResolveSystemImage(string id) + { + if (ChannelMeta is { } meta) + { + var entry = meta.FirstOrDefault(e => e.Id == id); + if (entry != null && !string.IsNullOrEmpty(entry.SystemImage)) return entry.SystemImage; + } + if (ChannelSystemImages is { } sys && sys.TryGetValue(id, out var symbol) && !string.IsNullOrEmpty(symbol)) + return symbol; + return null; + } +} + +/// Per-channel UI metadata entry from channelMeta. +public sealed class ChannelUiMetaEntry +{ + public string Id { get; init; } = ""; + public string Label { get; init; } = ""; + public string DetailLabel { get; init; } = ""; + public string? SystemImage { get; init; } +} + +/// One account belonging to a channel (for channels with multi-account support). +public sealed class ChannelAccountSnapshot +{ + public string Id { get; init; } = ""; + public bool? Configured { get; init; } + public bool? Running { get; init; } + public bool? Connected { get; init; } + /// Last inbound message timestamp (epoch ms). + public double? LastInboundAt { get; init; } + /// Last outbound message timestamp (epoch ms). + public double? LastOutboundAt { get; init; } +} + +/// Generic channel status fields surfaced by virtually every channel plugin. +public sealed class GenericChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public bool Connected { get; init; } + public bool Linked { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + /// Last probe completion timestamp (epoch ms). + public double? LastProbeAt { get; init; } + /// Transport mode, e.g. "polling" / "webhook" — channel plugins set this to declare how they receive events. + public string? Mode { get; init; } + /// Epoch ms when the channel last booted (entered the running state). + public double? LastStartAt { get; init; } + /// Epoch ms of the channel's last upstream event (message, presence push, …) — useful as a "freshness" indicator. + public double? LastEventAt { get; init; } + /// Epoch ms of the last transport-layer activity (HTTP poll round-trip, WS heartbeat). Often equals when traffic is flowing. + public double? LastTransportActivityAt { get; init; } + /// Number of times the plugin has reconnected since it last booted. Non-zero is a soft caution signal. + public int ReconnectAttempts { get; init; } + /// True when the plugin is in the middle of a graceful restart (channels.start has been queued but hasn't completed). + public bool RestartPending { get; init; } +} + +/// Probe sub-record common to most channel statuses. +public sealed class ChannelProbe +{ + public bool? Ok { get; init; } + public int? Status { get; init; } + public double? ElapsedMs { get; init; } + public string? Version { get; init; } + public string? Error { get; init; } +} + +// ─── Typed status records for built-in channels ───────────────────────────── +// These cover the fields Mac surfaces in its detail-pane "details line"; plugin +// channels (e.g. nostr) use the generic shape above. + +public sealed class WhatsAppChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public bool Connected { get; init; } + public bool Linked { get; init; } + public string? LastError { get; init; } + public WhatsAppSelf? Self { get; init; } + public double? AuthAgeMs { get; init; } + public double? LastConnectedAt { get; init; } + public double? LastMessageAt { get; init; } + public int ReconnectAttempts { get; init; } + public WhatsAppLastDisconnect? LastDisconnect { get; init; } +} + +public sealed class WhatsAppSelf +{ + public string? E164 { get; init; } + public string? Jid { get; init; } +} + +public sealed class WhatsAppLastDisconnect +{ + public double? At { get; init; } + public int? Status { get; init; } + public string? Error { get; init; } + public bool? LoggedOut { get; init; } +} + +public sealed class TelegramChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + public double? LastProbeAt { get; init; } + public string? BotUsername { get; init; } +} + +public sealed class DiscordChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + public double? LastProbeAt { get; init; } + public string? WebhookUrl { get; init; } +} + +public sealed class GoogleChatChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + public double? LastProbeAt { get; init; } + public string? WebhookUrl { get; init; } +} + +public sealed class SignalChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + public double? LastProbeAt { get; init; } + public string? BaseUrl { get; init; } +} + +public sealed class IMessageChannelStatus +{ + public bool Configured { get; init; } + public bool Running { get; init; } + public string? LastError { get; init; } + public ChannelProbe? Probe { get; init; } + public double? LastProbeAt { get; init; } + public string? CliPath { get; init; } + public string? DbPath { get; init; } +} + +// ─── Web login (QR linking) ────────────────────────────────────────────────── + +/// Result of web.login.start — a QR or status for a channel. +public sealed class WebLoginStartResult +{ + public string? Message { get; init; } + public string? QrDataUrl { get; init; } + public bool Connected { get; init; } + + /// Gateway-side error message when the call failed (ok=false), or transport exception. Null on success. + public string? Error { get; init; } + + /// Raw JSON of the gateway response (or stringified exception). Used by the diagnostic disclosure in the UI. + public string? RawResponse { get; init; } +} + +/// Result of web.login.wait — long-poll outcome. +public sealed class WebLoginWaitResult +{ + public string? Message { get; init; } + public string? QrDataUrl { get; init; } + public bool Connected { get; init; } + + /// Gateway-side error message when the call failed (ok=false), or transport exception. Null on success. + public string? Error { get; init; } + + /// Raw JSON of the gateway response (or stringified exception). Used by the diagnostic disclosure in the UI. + public string? RawResponse { get; init; } +} + +/// +/// Result of channels.start. Mirrors the gateway response shape +/// ({ channel, accountId, started }) and adds error/raw fields so the +/// page can surface the gateway's actual error message — including the +/// telltale "unknown channel: foo" which means the channel plugin isn't +/// loaded on the gateway host and the operator needs to run +/// openclaw plugins install @openclaw/<id> on that machine. +/// +public sealed class ChannelStartResult +{ + /// Channel id the gateway acted on (echoes the request param). + public string? Channel { get; init; } + + /// Account id the gateway started (default if none was requested). + public string? AccountId { get; init; } + + /// True when the gateway reports the channel transitioned to started. + public bool Started { get; init; } + + /// Overall wire-level success — false on transport failure or gateway ok:false. + public bool Ok { get; init; } + + /// Gateway-side error message when is false. Null on success. + public string? Error { get; init; } + + /// Raw JSON of the gateway response (or stringified exception). Used by the page diagnostic disclosure. + public string? RawResponse { get; init; } + + /// + /// True when the gateway responded with "unknown channel" — a strong signal + /// the channel plugin isn't loaded on the gateway host. Used to upgrade + /// the page's hint to "install the plugin on your gateway first". + /// + public bool LooksLikeMissingPlugin => + Error != null && + Error.Contains("unknown channel", System.StringComparison.OrdinalIgnoreCase); +} + +/// +/// Result of . +/// +/// Distinguishes "patch dispatched" (the older fire-and-forget bool) from +/// "patch was accepted by the gateway". Lets the inline channel save flow +/// surface the gateway's actual error message — including the wire-format +/// validation errors that fail silently with the legacy +/// config.set { path, value } path on newer gateways. +/// +public sealed class ConfigPatchResult +{ + /// True when the gateway accepted the patch (responded ok:true). + public bool Ok { get; init; } + + /// Gateway-side error message when is false. Null on success. + public string? Error { get; init; } + + /// Raw JSON of the gateway response (or stringified exception). For diagnostic disclosure. + public string? RawResponse { get; init; } + + /// + /// True when the gateway rejected the patch because our baseHash was + /// stale (someone else changed the config out from under us). Pages + /// should refresh the cached config and prompt the user to retry. + /// + /// The bare word "conflict" is too generic to match on its own (Hanselman + /// review MEDIUM-3) — a JSON schema error like "property 'conflict_mode' + /// is invalid" would otherwise trigger a spurious refresh-and-retry loop. + /// We require "conflict" to co-occur with "hash" or "baseHash" before + /// treating it as a stale-hash signal. + /// + public bool LooksLikeStaleBaseHash => + Error != null && + (Error.Contains("baseHash", System.StringComparison.OrdinalIgnoreCase) || + Error.Contains("stale", System.StringComparison.OrdinalIgnoreCase) || + (Error.Contains("conflict", System.StringComparison.OrdinalIgnoreCase) && + Error.Contains("hash", System.StringComparison.OrdinalIgnoreCase))); +} diff --git a/src/OpenClaw.Shared/ChannelsStatusParser.cs b/src/OpenClaw.Shared/ChannelsStatusParser.cs new file mode 100644 index 000000000..a1413aaf1 --- /dev/null +++ b/src/OpenClaw.Shared/ChannelsStatusParser.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Parses the JSON payload of a channels.status response into a +/// . Tolerant of missing optional fields — +/// older gateways or plugin-only responses still parse. +/// +public static class ChannelsStatusParser +{ + public static ChannelsStatusSnapshot Parse(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) + return new ChannelsStatusSnapshot(); + + var snap = new ChannelsStatusSnapshot + { + Ts = GetDouble(payload, "ts") ?? 0, + ChannelOrder = ParseStringArray(payload, "channelOrder"), + ChannelLabels = ParseStringMap(payload, "channelLabels"), + ChannelDetailLabels = ParseOptionalStringMap(payload, "channelDetailLabels"), + ChannelSystemImages = ParseOptionalStringMap(payload, "channelSystemImages"), + ChannelMeta = ParseChannelMeta(payload), + Channels = ParseChannels(payload), + ChannelAccounts = ParseChannelAccounts(payload), + ChannelDefaultAccountId = ParseStringMap(payload, "channelDefaultAccountId"), + }; + return snap; + } + + // ─── Typed extractors for individual channels ──────────────────────────── + + public static WhatsAppChannelStatus? ExtractWhatsApp(JsonElement channel) => + channel.ValueKind == JsonValueKind.Object + ? new WhatsAppChannelStatus + { + Configured = GetBool(channel, "configured") ?? false, + Running = GetBool(channel, "running") ?? false, + Connected = GetBool(channel, "connected") ?? false, + Linked = GetBool(channel, "linked") ?? false, + LastError = GetString(channel, "lastError"), + Self = channel.TryGetProperty("self", out var s) && s.ValueKind == JsonValueKind.Object + ? new WhatsAppSelf { E164 = GetString(s, "e164"), Jid = GetString(s, "jid") } + : null, + AuthAgeMs = GetDouble(channel, "authAgeMs"), + LastConnectedAt = GetDouble(channel, "lastConnectedAt"), + LastMessageAt = GetDouble(channel, "lastMessageAt"), + ReconnectAttempts = (int)(GetDouble(channel, "reconnectAttempts") ?? 0), + LastDisconnect = channel.TryGetProperty("lastDisconnect", out var d) && d.ValueKind == JsonValueKind.Object + ? new WhatsAppLastDisconnect + { + At = GetDouble(d, "at"), + Status = (int?)GetDouble(d, "status"), + Error = GetString(d, "error"), + LoggedOut = GetBool(d, "loggedOut"), + } + : null, + } + : null; + + public static GenericChannelStatus? ExtractGeneric(JsonElement channel) => + channel.ValueKind == JsonValueKind.Object + ? new GenericChannelStatus + { + Configured = GetBool(channel, "configured") ?? false, + Running = GetBool(channel, "running") ?? false, + Connected = GetBool(channel, "connected") ?? false, + Linked = GetBool(channel, "linked") ?? false, + LastError = GetString(channel, "lastError") ?? GetString(channel, "error"), + Probe = ExtractProbe(channel), + LastProbeAt = GetDouble(channel, "lastProbeAt"), + Mode = GetString(channel, "mode"), + LastStartAt = GetDouble(channel, "lastStartAt"), + LastEventAt = GetDouble(channel, "lastEventAt"), + LastTransportActivityAt = GetDouble(channel, "lastTransportActivityAt"), + ReconnectAttempts = (int)(GetDouble(channel, "reconnectAttempts") ?? 0), + RestartPending = GetBool(channel, "restartPending") ?? false, + } + : null; + + public static ChannelProbe? ExtractProbe(JsonElement channel) + { + if (!channel.TryGetProperty("probe", out var p) || p.ValueKind != JsonValueKind.Object) + return null; + return new ChannelProbe + { + Ok = GetBool(p, "ok"), + Status = (int?)GetDouble(p, "status"), + ElapsedMs = GetDouble(p, "elapsedMs"), + Version = GetString(p, "version"), + Error = GetString(p, "error"), + }; + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static IReadOnlyList ParseStringArray(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array) + return []; + var list = new List(); + foreach (var item in arr.EnumerateArray()) + if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s) + list.Add(s); + return list; + } + + private static IReadOnlyDictionary ParseStringMap(JsonElement parent, string name) + { + var map = new Dictionary(); + if (!parent.TryGetProperty(name, out var obj) || obj.ValueKind != JsonValueKind.Object) return map; + foreach (var prop in obj.EnumerateObject()) + if (prop.Value.ValueKind == JsonValueKind.String && prop.Value.GetString() is { } v) + map[prop.Name] = v; + return map; + } + + private static IReadOnlyDictionary? ParseOptionalStringMap(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var obj) || obj.ValueKind != JsonValueKind.Object) return null; + var map = new Dictionary(); + foreach (var prop in obj.EnumerateObject()) + if (prop.Value.ValueKind == JsonValueKind.String && prop.Value.GetString() is { } v) + map[prop.Name] = v; + return map; + } + + private static IReadOnlyList? ParseChannelMeta(JsonElement parent) + { + if (!parent.TryGetProperty("channelMeta", out var arr) || arr.ValueKind != JsonValueKind.Array) + return null; + var list = new List(); + foreach (var item in arr.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) continue; + list.Add(new ChannelUiMetaEntry + { + Id = GetString(item, "id") ?? "", + Label = GetString(item, "label") ?? "", + DetailLabel = GetString(item, "detailLabel") ?? GetString(item, "label") ?? "", + SystemImage = GetString(item, "systemImage"), + }); + } + return list; + } + + private static IReadOnlyDictionary ParseChannels(JsonElement parent) + { + var map = new Dictionary(); + if (!parent.TryGetProperty("channels", out var obj) || obj.ValueKind != JsonValueKind.Object) return map; + foreach (var prop in obj.EnumerateObject()) + map[prop.Name] = prop.Value.Clone(); + return map; + } + + private static IReadOnlyDictionary> ParseChannelAccounts(JsonElement parent) + { + var map = new Dictionary>(); + if (!parent.TryGetProperty("channelAccounts", out var obj) || obj.ValueKind != JsonValueKind.Object) return map; + foreach (var prop in obj.EnumerateObject()) + { + if (prop.Value.ValueKind != JsonValueKind.Array) continue; + var list = new List(); + foreach (var acc in prop.Value.EnumerateArray()) + { + if (acc.ValueKind != JsonValueKind.Object) continue; + list.Add(new ChannelAccountSnapshot + { + Id = GetString(acc, "id") ?? "", + Configured = GetBool(acc, "configured"), + Running = GetBool(acc, "running"), + Connected = GetBool(acc, "connected"), + LastInboundAt = GetDouble(acc, "lastInboundAt"), + LastOutboundAt = GetDouble(acc, "lastOutboundAt"), + }); + } + map[prop.Name] = list; + } + return map; + } + + private static string? GetString(JsonElement parent, string name) => + parent.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + private static bool? GetBool(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var v)) return null; + return v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null, + }; + } + + private static double? GetDouble(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var v)) return null; + if (v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out var d)) return d; + return null; + } +} diff --git a/src/OpenClaw.Shared/ChatAttachment.cs b/src/OpenClaw.Shared/ChatAttachment.cs new file mode 100644 index 000000000..b18026819 --- /dev/null +++ b/src/OpenClaw.Shared/ChatAttachment.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace OpenClaw.Shared; + +/// +/// A file attachment to include with a chat message. Matches the gateway's +/// chat.send attachment schema: { type, mimeType, fileName, content }. +/// +public sealed class ChatAttachment +{ + /// Maximum attachment size in bytes (10 MB). + public const long MaxSizeBytes = 10 * 1024 * 1024; + + [JsonPropertyName("type")] + public string Type { get; init; } = "file"; + + [JsonPropertyName("mimeType")] + public string MimeType { get; init; } = "application/octet-stream"; + + [JsonPropertyName("fileName")] + public string FileName { get; init; } = ""; + + [JsonPropertyName("content")] + public string Content { get; init; } = ""; + + /// Original file size in bytes (client-side only, not sent to gateway). + [JsonIgnore] + public long SizeBytes { get; init; } + + /// + /// Creates a by reading a file from disk asynchronously, + /// base64-encoding its contents, and inferring the MIME type from the extension. + /// Throws if the file exceeds . + /// + public static async Task FromFileAsync(string filePath) + { + var info = new FileInfo(filePath); + if (!info.Exists) + throw new FileNotFoundException("Attachment file not found", filePath); + + if (info.Length > MaxSizeBytes) + throw new InvalidOperationException( + $"File is too large ({FormatBytes(info.Length)}). Maximum attachment size is {FormatBytes(MaxSizeBytes)}."); + + var bytes = await File.ReadAllBytesAsync(filePath); + var mime = InferMimeType(info.Extension); + + return new ChatAttachment + { + Type = mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ? "image" : "file", + MimeType = mime, + FileName = info.Name, + Content = Convert.ToBase64String(bytes), + SizeBytes = info.Length + }; + } + + /// + /// Synchronous overload kept for backward compatibility. + /// Throws if the file exceeds . + /// + public static ChatAttachment FromFile(string filePath) + { + var info = new FileInfo(filePath); + if (!info.Exists) + throw new FileNotFoundException("Attachment file not found", filePath); + + if (info.Length > MaxSizeBytes) + throw new InvalidOperationException( + $"File is too large ({FormatBytes(info.Length)}). Maximum attachment size is {FormatBytes(MaxSizeBytes)}."); + + var bytes = File.ReadAllBytes(filePath); + var mime = InferMimeType(info.Extension); + + return new ChatAttachment + { + Type = mime.StartsWith("image/", StringComparison.OrdinalIgnoreCase) ? "image" : "file", + MimeType = mime, + FileName = info.Name, + Content = Convert.ToBase64String(bytes), + SizeBytes = info.Length + }; + } + + private static string InferMimeType(string extension) + { + return extension.ToLowerInvariant() switch + { + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".bmp" => "image/bmp", + ".ico" => "image/x-icon", + ".pdf" => "application/pdf", + ".txt" => "text/plain", + ".md" => "text/markdown", + ".json" => "application/json", + ".xml" => "application/xml", + ".csv" => "text/csv", + ".html" or ".htm" => "text/html", + ".css" => "text/css", + ".js" => "text/javascript", + ".ts" => "text/typescript", + ".cs" => "text/x-csharp", + ".py" => "text/x-python", + ".yaml" or ".yml" => "text/yaml", + ".zip" => "application/zip", + ".log" => "text/plain", + _ => "application/octet-stream" + }; + } + + /// Human-readable size string for UI display. + public string FormatSize() + { + return FormatBytes(SizeBytes); + } + + private static string FormatBytes(long bytes) + { + return bytes switch + { + < 1024 => $"{bytes} B", + < 1024 * 1024 => $"{bytes / 1024.0:F1} KB", + _ => $"{bytes / (1024.0 * 1024.0):F1} MB" + }; + } +} diff --git a/src/OpenClaw.Shared/DeepLinkParser.cs b/src/OpenClaw.Shared/DeepLinkParser.cs index 21bcdf7cf..4ef68266e 100644 --- a/src/OpenClaw.Shared/DeepLinkParser.cs +++ b/src/OpenClaw.Shared/DeepLinkParser.cs @@ -20,10 +20,13 @@ public static class DeepLinkParser if (!uri.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase)) return null; - var remainder = uri[Scheme.Length..].TrimEnd('/'); + var remainder = uri[Scheme.Length..]; var queryIndex = remainder.IndexOf('?'); var query = queryIndex >= 0 ? remainder[(queryIndex + 1)..] : ""; - var path = queryIndex >= 0 ? remainder[..queryIndex] : remainder; + // Trim trailing slash AFTER splitting off the query so the + // Windows-canonicalized form `openclaw://send/?args=...` (slash + // BEFORE the `?`) yields path "send", not "send/". + var path = (queryIndex >= 0 ? remainder[..queryIndex] : remainder).TrimEnd('/'); var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index 3fa66eff1..b21bd82fc 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -19,15 +21,25 @@ public class DeviceIdentity private PublicKey? _publicKey; private string? _deviceId; private string? _deviceToken; + private string[]? _deviceTokenScopes; + private string? _nodeDeviceToken; + private string[]? _nodeDeviceTokenScopes; private static readonly SignatureAlgorithm Ed25519Algorithm = SignatureAlgorithm.Ed25519; public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized"); public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized"); public string? DeviceToken => _deviceToken; + public IReadOnlyList? DeviceTokenScopes => _deviceTokenScopes; + public string? NodeDeviceToken => _nodeDeviceToken; + public IReadOnlyList? NodeDeviceTokenScopes => _nodeDeviceTokenScopes; - public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) + public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + TryReadStoredDeviceTokenForRole(dataPath, "operator", logger); + + public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) { + var tokenRole = ParseDeviceTokenRole(role); var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); if (!File.Exists(keyPath)) { @@ -37,7 +49,11 @@ public class DeviceIdentity try { using var doc = JsonDocument.Parse(File.ReadAllText(keyPath)); - if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) && + var tokenPropertyName = tokenRole == DeviceTokenRole.Node + ? nameof(DeviceKeyData.NodeDeviceToken) + : nameof(DeviceKeyData.DeviceToken); + + if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) && deviceToken.ValueKind == JsonValueKind.String) { var value = deviceToken.GetString(); @@ -62,6 +78,84 @@ public class DeviceIdentity public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => !string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger)); + + public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) => + !string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger)); + + /// + /// Sets the operator DeviceToken field to null in + /// device-key-ed25519.json without deleting the file. + /// Preserves all other fields (Ed25519 keypair, algorithm, timestamps, + /// NodeDeviceToken). + /// + /// + /// true if the token was cleared; false if the file was + /// absent or the DeviceToken field was already null/empty + /// (idempotent skip). + /// + public static bool TryClearDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + TryClearDeviceTokenForRole(dataPath, "operator", logger); + + /// + /// Sets the role-specific device token field to null in + /// device-key-ed25519.json without deleting the file. Preserves the + /// Ed25519 keypair and unrelated role tokens. + /// + /// + /// true if the token was cleared; false if the file was + /// absent or the role token was already null/empty. + /// + public static bool TryClearDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) + { + var tokenRole = ParseDeviceTokenRole(role); + var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); + if (!File.Exists(keyPath)) + return false; + + try + { + var json = File.ReadAllText(keyPath); + var data = JsonSerializer.Deserialize(json); + if (data == null) + return false; + + var token = tokenRole == DeviceTokenRole.Node + ? data.NodeDeviceToken + : data.DeviceToken; + if (string.IsNullOrEmpty(token)) + return false; // already null — idempotent + + if (tokenRole == DeviceTokenRole.Node) + { + data.NodeDeviceToken = null; + data.NodeDeviceTokenScopes = null; + } + else + { + data.DeviceToken = null; + data.DeviceTokenScopes = null; + } + + AtomicWriteKeyFile(keyPath, data); + logger?.Info($"{(tokenRole == DeviceTokenRole.Node ? "NodeDeviceToken" : "DeviceToken")} cleared from device-key-ed25519.json (file preserved)."); + return true; + } + catch (IOException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + } public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null) { @@ -103,6 +197,9 @@ private void LoadExisting() _publicKey = _privateKey.PublicKey; _deviceId = data.DeviceId; _deviceToken = data.DeviceToken; + _deviceTokenScopes = NormalizeScopes(data.DeviceTokenScopes); + _nodeDeviceToken = data.NodeDeviceToken; + _nodeDeviceTokenScopes = NormalizeScopes(data.NodeDeviceTokenScopes); _logger.Info($"Loaded Ed25519 device identity: {_deviceId?[..16]}..."); } @@ -150,16 +247,17 @@ private void GenerateNew() if (!string.IsNullOrEmpty(dir)) McpAuthToken.TryRestrictDataDirectoryAcl(dir); - File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath); + // Save to disk via atomic temp+rename so a process-kill or power-loss + // mid-write cannot leave a torn/zero-byte key file that the next + // LoadOrCreate would treat as invalid and silently rotate the identity. + AtomicWriteKeyFile(_keyPath, data); _logger.Info($"Generated new Ed25519 device identity: {_deviceId}"); } /// - /// Sign a payload for device authentication - /// Payload format: v2|{deviceId}|{client.id}|{client.mode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce} - /// IMPORTANT: {token} is the auth.token from the connect request, NOT the device token! + /// Sign a payload for device authentication. /// + [Obsolete("Use SignConnectPayloadV3 instead. This method hardcodes v2 format with node-specific values.")] public string SignPayload(string nonce, long signedAtMs, string clientId, string authToken) { if (_privateKey == null || _deviceId == null) @@ -289,10 +387,9 @@ public string BuildConnectPayloadV2( } /// - /// Build the payload string (for debugging) - /// Format: v2|{deviceId}|{clientId}|{clientMode}|{role}||{signedAtMs}|{token}|{nonce} - /// IMPORTANT: {token} is the auth.token from connect request! + /// Build the legacy v2 payload string for node connections. /// + [Obsolete("Use BuildConnectPayloadV3 instead. This method hardcodes v2 format with node-specific values.")] public string BuildDebugPayload(string nonce, long signedAtMs, string clientId, string authToken) { if (_deviceId == null) @@ -310,11 +407,41 @@ public string BuildDebugPayload(string nonce, long signedAtMs, string clientId, /// Store the device token received after pairing approval /// public void StoreDeviceToken(string token) + { + StoreDeviceTokenCore(token, null); + } + + public void StoreDeviceTokenWithScopes(string token, IEnumerable? scopes) + { + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + public void StoreDeviceTokenForRole(string role, string token, IEnumerable? scopes = null) + { + var tokenRole = ParseDeviceTokenRole(role); + if (tokenRole == DeviceTokenRole.Node) + { + StoreNodeDeviceTokenCore(token, NormalizeScopes(scopes)); + return; + } + + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + private static DeviceTokenRole ParseDeviceTokenRole(string role) => role switch + { + "operator" => DeviceTokenRole.Operator, + "node" => DeviceTokenRole.Node, + _ => throw new ArgumentOutOfRangeException(nameof(role), "Device token role must be 'operator' or 'node'.") + }; + + private void StoreDeviceTokenCore(string token, string[]? scopes) { if (string.IsNullOrWhiteSpace(token)) throw new ArgumentException("Device token cannot be empty.", nameof(token)); _deviceToken = token; + _deviceTokenScopes = scopes; // Update the key file with the token try @@ -326,8 +453,8 @@ public void StoreDeviceToken(string token) if (data != null) { data.DeviceToken = token; - File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath); + data.DeviceTokenScopes = scopes; + AtomicWriteKeyFile(_keyPath, data); _logger.Info("Device token stored"); } } @@ -337,6 +464,78 @@ public void StoreDeviceToken(string token) _logger.Error($"Failed to store device token: {ex.Message}"); } } + + private void StoreNodeDeviceTokenCore(string token, string[]? scopes) + { + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Device token cannot be empty.", nameof(token)); + + _nodeDeviceToken = token; + _nodeDeviceTokenScopes = scopes; + + try + { + if (File.Exists(_keyPath)) + { + var json = File.ReadAllText(_keyPath); + var data = JsonSerializer.Deserialize(json); + if (data != null) + { + data.NodeDeviceToken = token; + data.NodeDeviceTokenScopes = scopes; + AtomicWriteKeyFile(_keyPath, data); + _logger.Info("Node device token stored"); + } + } + } + catch (Exception ex) + { + _logger.Error($"Failed to store node device token: {ex.Message}"); + } + } + + /// + /// Atomic write of device-key JSON: serialize to a sibling temp file + /// (.<name>.<guid>.tmp), lock its ACL, then + /// with overwrite=true. The + /// rename is atomic on NTFS — a process-kill or power-loss mid-write + /// either leaves the existing key file intact or replaces it wholesale, + /// never a torn/zero-byte file that the next LoadOrCreate would silently + /// rotate the identity over. + /// Same shape as . + /// + private static void AtomicWriteKeyFile(string path, DeviceKeyData data) + { + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + var dir = Path.GetDirectoryName(path); + var tempDir = string.IsNullOrEmpty(dir) ? Environment.CurrentDirectory : dir; + var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(tempPath, json); + McpAuthToken.TryRestrictSensitiveFileAcl(tempPath); + File.Move(tempPath, path, overwrite: true); + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best-effort cleanup */ } + throw; + } + McpAuthToken.TryRestrictSensitiveFileAcl(path); + } + + private static string[]? NormalizeScopes(IEnumerable? scopes) + { + if (scopes == null) + return null; + + var normalized = scopes + .Where(scope => !string.IsNullOrWhiteSpace(scope)) + .Select(scope => scope.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return normalized.Length == 0 ? null : normalized; + } private static string Base64UrlEncode(byte[] data) { @@ -346,12 +545,21 @@ private static string Base64UrlEncode(byte[] data) .TrimEnd('='); } + private enum DeviceTokenRole + { + Operator, + Node + } + private class DeviceKeyData { public string? PrivateKeyBase64 { get; set; } public string? PublicKeyBase64 { get; set; } public string? DeviceId { get; set; } public string? DeviceToken { get; set; } + public string[]? DeviceTokenScopes { get; set; } + public string? NodeDeviceToken { get; set; } + public string[]? NodeDeviceTokenScopes { get; set; } public string? Algorithm { get; set; } public long CreatedAt { get; set; } } diff --git a/src/OpenClaw.Shared/DeviceIdentityFileReader.cs b/src/OpenClaw.Shared/DeviceIdentityFileReader.cs new file mode 100644 index 000000000..4856d37c8 --- /dev/null +++ b/src/OpenClaw.Shared/DeviceIdentityFileReader.cs @@ -0,0 +1,16 @@ +namespace OpenClaw.Shared; + +/// +/// Default implementation of that delegates +/// to the static methods. +/// +public sealed class DeviceIdentityFileReader : IDeviceIdentityReader +{ + public static readonly DeviceIdentityFileReader Instance = new(); + + public string? TryReadStoredDeviceToken(string dataPath) => + DeviceIdentity.TryReadStoredDeviceToken(dataPath); + + public string? TryReadStoredNodeDeviceToken(string dataPath) => + DeviceIdentity.TryReadStoredDeviceTokenForRole(dataPath, "node"); +} diff --git a/src/OpenClaw.Shared/DeviceTokenReceivedEventArgs.cs b/src/OpenClaw.Shared/DeviceTokenReceivedEventArgs.cs new file mode 100644 index 000000000..c2b48e3bc --- /dev/null +++ b/src/OpenClaw.Shared/DeviceTokenReceivedEventArgs.cs @@ -0,0 +1,25 @@ +namespace OpenClaw.Shared; + +/// +/// Event args raised when a client receives a device token from the gateway +/// during the hello-ok handshake. Additive event — clients continue to write +/// tokens internally for backward compatibility. +/// +public sealed class DeviceTokenReceivedEventArgs : EventArgs +{ + /// The device token string. + public string Token { get; } + + /// Scopes granted with this token, if available. + public string[]? Scopes { get; } + + /// "operator" or "node". + public string Role { get; } + + public DeviceTokenReceivedEventArgs(string token, string[]? scopes, string role) + { + Token = token; + Scopes = scopes; + Role = role; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovalPolicy.cs b/src/OpenClaw.Shared/ExecApprovalPolicy.cs index 6f0827362..7dbeda209 100644 --- a/src/OpenClaw.Shared/ExecApprovalPolicy.cs +++ b/src/OpenClaw.Shared/ExecApprovalPolicy.cs @@ -249,14 +249,8 @@ public void Save() var dir = Path.GetDirectoryName(_policyFilePath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - - var data = new ExecPolicyData - { - DefaultAction = _defaultAction, - Rules = _rules - }; - - var json = JsonSerializer.Serialize(data, _jsonOptions); + + var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions); File.WriteAllText(_policyFilePath, json); } catch (Exception ex) diff --git a/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs new file mode 100644 index 000000000..647b920d7 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/CanonicalCommandIdentity.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +// Architectural barrier produced by PR3. +// Equivalent to ExecHostValidatedRequest in the macOS reference, extended with resolution outputs. +// No module from PR4 onward may accept ValidatedRunRequest as direct input (research doc 05 line 439). +// Rail 15: a single canonical representation reused across evaluation, logging, prompting, execution. +public sealed class CanonicalCommandIdentity +{ + // ── Normalization outputs ───────────────────────────────────────────────── + + // Argv exactly as produced by PR2 (no trimming; coding contract process-argv-semantics). + public IReadOnlyList Command { get; } + + // Canonical display form generated from argv. Never rawCommand from the agent. + // Used by logging and prompting. Research doc 05 decision 2. + public string DisplayCommand { get; } + + // Safe rawCommand for executable resolution. Null in Windows v1 (rawCommand not in + // system.run protocol; research doc 05 OQ-V4 / decision 10). + public string? EvaluationRawCommand { get; } + + // ── Resolution outputs ──────────────────────────────────────────────────── + + // Singular resolution for the state machine (PR5). + // Null if the primary executable cannot be determined. + public ExecCommandResolution? Resolution { get; } + + // Per-segment resolutions for the allowlist matcher (PR4/PR5). + // Empty list means fail-closed — no allowlist satisfaction possible. + public IReadOnlyList AllowlistResolutions { get; } + + // Suggested allowlist patterns for prompt/UI (PR6). Not a security decision. + public IReadOnlyList AllowAlwaysPatterns { get; } + + // ── Request context (carried from ValidatedRunRequest) ──────────────────── + + public string? Cwd { get; } + public int TimeoutMs { get; } + public IReadOnlyDictionary? Env { get; } + public string? AgentId { get; } + public string? SessionKey { get; } + + internal CanonicalCommandIdentity( + IReadOnlyList command, + string displayCommand, + string? evaluationRawCommand, + ExecCommandResolution? resolution, + IReadOnlyList allowlistResolutions, + IReadOnlyList allowAlwaysPatterns, + string? cwd, + int timeoutMs, + IReadOnlyDictionary? env, + string? agentId, + string? sessionKey) + { + Command = command; + DisplayCommand = displayCommand; + EvaluationRawCommand = evaluationRawCommand; + Resolution = resolution; + AllowlistResolutions = allowlistResolutions; + AllowAlwaysPatterns = allowAlwaysPatterns; + Cwd = cwd; + TimeoutMs = timeoutMs; + Env = env; + AgentId = agentId; + SessionKey = sessionKey; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecAllowlistMatcher.cs b/src/OpenClaw.Shared/ExecApprovals/ExecAllowlistMatcher.cs new file mode 100644 index 000000000..804e39afe --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecAllowlistMatcher.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared.ExecApprovals; + +// Path-based allowlist matcher. +// Research doc 03 decisions: +// - target = resolvedPath ?? rawExecutable +// - * = single segment ([^/]*); ** = any segments (.*); ? = single char no separator ([^/]) +// - case-insensitive via RegexOptions (no ToLowerInvariant); \ → / normalization before matching +// - basename-only patterns are invalid and fail-closed (no match produced) +// - matchAll is strict all-or-nothing: any miss returns empty list +internal static class ExecAllowlistMatcher +{ + // Compiled regexes keyed by normalized pattern string. + // Allowlist patterns are config-defined and bounded; unbounded cache growth is not a concern. + private static readonly ConcurrentDictionary s_regexCache = new(); + + // Returns the first entry whose pattern matches the resolution's target path, or null. + // Target is normalized once before iterating — not per entry. + internal static ExecAllowlistEntry? Match( + IReadOnlyList entries, + ExecCommandResolution resolution) + { + var target = NormalizeSeparators(resolution.ResolvedPath ?? resolution.RawExecutable); + foreach (var entry in entries) + { + var pattern = entry.Pattern; + if (string.IsNullOrWhiteSpace(pattern)) continue; + var normalizedPattern = NormalizeSeparators(pattern); + if (!IsValidNormalizedPattern(normalizedPattern)) continue; + if (s_regexCache.GetOrAdd(normalizedPattern, BuildPatternRegex).IsMatch(target)) + return entry; + } + return null; + } + + // Returns one matching entry per resolution in input order. + // Any resolution with no match causes the entire result to be empty (all-or-nothing). + internal static IReadOnlyList MatchAll( + IReadOnlyList entries, + IReadOnlyList resolutions) + { + if (resolutions.Count == 0) return []; + + var result = new ExecAllowlistEntry[resolutions.Count]; + for (var i = 0; i < resolutions.Count; i++) + { + var match = Match(entries, resolutions[i]); + if (match is null) return []; + result[i] = match; + } + return result; + } + + // A pattern is valid iff it contains a path separator after normalization. + // Basename-only patterns (e.g. "rg", "echo") are invalid. + internal static bool IsValidPattern(string? pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) return false; + return IsValidNormalizedPattern(NormalizeSeparators(pattern)); + } + + // Inner check on an already-normalized pattern — single source of truth for the rule. + private static bool IsValidNormalizedPattern(string normalizedPattern) + => normalizedPattern.Contains('/') && !HasMalformedDoubleStars(normalizedPattern); + + // ** is valid only at segment boundaries: preceded by start-of-string or '/', followed by '/' or end. + // e.g. "C:/tools**" and "**suffix" are malformed and must fail-closed. + private static bool HasMalformedDoubleStars(string normalizedPattern) + { + for (var i = 0; i < normalizedPattern.Length - 1; i++) + { + if (normalizedPattern[i] != '*' || normalizedPattern[i + 1] != '*') continue; + var precededByBoundary = i == 0 || normalizedPattern[i - 1] == '/'; + var followedByBoundary = i + 2 >= normalizedPattern.Length || normalizedPattern[i + 2] == '/'; + if (!precededByBoundary || !followedByBoundary) return true; + i++; // skip second * + } + return false; + } + + // Normalizes path separators only. + // Case insensitivity is delegated to the regex engine (IgnoreCase | CultureInvariant) + // so no ToLowerInvariant() allocation is needed here. + // Safe to apply to paths that are already forward-slash normalized (idempotent). + private static string NormalizeSeparators(string? value) + => (value ?? string.Empty).Replace('\\', '/'); + + // Converts a separator-normalized glob pattern to an anchored compiled regex. + // Called at most once per unique pattern — result is stored in s_regexCache by the caller. + // NonBacktracking prevents catastrophic behavior on adversarial or degenerate patterns. + private static Regex BuildPatternRegex(string pattern) + { + var sb = new StringBuilder("^"); + var i = 0; + while (i < pattern.Length) + { + if (i + 1 < pattern.Length && pattern[i] == '*' && pattern[i + 1] == '*') + { + i += 2; + if (i < pattern.Length && pattern[i] == '/' && i + 1 < pattern.Length) + { + // **/rest — rest must start at a segment boundary, not as a suffix of another name. + // (.*\/)? matches zero or more path segments including their trailing separator. + sb.Append(@"(.*\/)?"); + i++; + } + else + { + // trailing ** — match anything (no following segment to anchor) + sb.Append(".*"); + } + } + else if (pattern[i] == '*') + { + sb.Append("[^/]*"); + i++; + } + else if (pattern[i] == '?') + { + // Research doc 03 security decision: ? must not cross separators on Windows. + sb.Append("[^/]"); + i++; + } + else + { + // Collect consecutive literal characters (including /) and escape as one span + // to avoid one string allocation per character. + var literalStart = i; + while (i < pattern.Length && pattern[i] != '*' && pattern[i] != '?') + i++; + sb.Append(Regex.Escape(pattern[literalStart..i])); + } + } + sb.Append('$'); + return new Regex( + sb.ToString(), + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking); + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluation.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluation.cs new file mode 100644 index 000000000..f477314a9 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluation.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +// Aggregated evaluation context passed to the stateless evaluator. +// Shape mirrors macOS ExecApprovalEvaluation struct (research doc 06). +// Derived fields are computed once in the constructor and must not be recomputed by callers. +// Research doc 06 stable conclusion 3: construction belongs to the coordinator (PR7), not the evaluator. +public sealed class ExecApprovalEvaluation +{ + public IReadOnlyList Command { get; } + public string DisplayCommand { get; } + public string? AgentId { get; } + public ExecSecurity Security { get; } + public ExecAsk Ask { get; } + public IReadOnlyDictionary? Env { get; } + + // Singular resolution — AllowlistResolutions[0], or null if the list is empty. + // Research doc 06: "resolution = allowlistResolutions.first" (not an independent call). + public ExecCommandResolution? Resolution { get; } + + public IReadOnlyList AllowlistResolutions { get; } + public IReadOnlyList AllowAlwaysPatterns { get; } + public IReadOnlyList AllowlistMatches { get; } + + // true iff security==allowlist && resolutions.Count>0 && matches.Count==resolutions.Count. + // Research doc 06 derivation rule — must not be re-derived outside the constructor. + public bool AllowlistSatisfied { get; } + + // First match when AllowlistSatisfied; null otherwise. + // Research doc 06 R5: AllowlistMatch must be null when AllowlistSatisfied is false. + public ExecAllowlistEntry? AllowlistMatch { get; } + + // Always false in v1. Kept as part of the conceptual model; activation deferred. + public bool SkillAllow { get; } + + public ExecApprovalEvaluation( + IReadOnlyList command, + string displayCommand, + string? agentId, + ExecSecurity security, + ExecAsk ask, + IReadOnlyDictionary? env, + IReadOnlyList allowlistResolutions, + IReadOnlyList allowAlwaysPatterns, + IReadOnlyList allowlistMatches, + bool skillAllow = false) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(displayCommand); + ArgumentNullException.ThrowIfNull(allowlistResolutions); + ArgumentNullException.ThrowIfNull(allowAlwaysPatterns); + ArgumentNullException.ThrowIfNull(allowlistMatches); + if (allowlistMatches.Count > allowlistResolutions.Count) + throw new ArgumentException( + "allowlistMatches cannot have more entries than allowlistResolutions.", + nameof(allowlistMatches)); + + Command = command; + DisplayCommand = displayCommand; + AgentId = agentId; + Security = security; + Ask = ask; + Env = env; + AllowlistResolutions = allowlistResolutions; + AllowAlwaysPatterns = allowAlwaysPatterns; + AllowlistMatches = allowlistMatches; + SkillAllow = skillAllow; + + Resolution = allowlistResolutions.Count > 0 ? allowlistResolutions[0] : (ExecCommandResolution?)null; + + AllowlistSatisfied = security == ExecSecurity.Allowlist + && allowlistResolutions.Count > 0 + && allowlistMatches.Count == allowlistResolutions.Count; + + AllowlistMatch = AllowlistSatisfied ? allowlistMatches[0] : null; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluator.cs new file mode 100644 index 000000000..535412e61 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalEvaluator.cs @@ -0,0 +1,66 @@ +namespace OpenClaw.Shared.ExecApprovals; + +// Stateless evaluation function. +// Mirrors macOS ExecHostRequestEvaluator.evaluate(context:approvalDecision:). +// Research doc 06: five-step precedence table — order is fixed and must not be changed. +// The evaluator does not read the store, call the matcher, show UI, or execute commands. +internal static class ExecApprovalEvaluator +{ + // Pure decision function: same inputs always produce the same output, no side effects. + // + // Two-pass mechanics (coordinator's responsibility): + // pass 1: Evaluate(context, null) → typically RequiresPrompt + // pass 2: Evaluate(context, userDecision) → Allow or Deny + // The context is constructed once and reused; only approvalDecision varies between passes. + internal static ExecHostPolicyDecision Evaluate( + ExecApprovalEvaluation context, + ExecApprovalDecision? approvalDecision) + { + // Step 1: security=deny is an absolute override. No user approval can bypass it. + if (context.Security == ExecSecurity.Deny) + return ExecHostPolicyDecision.Deny(ExecApprovalV2Result.SecurityDeny("security=deny")); + + // Step 2: explicit user denial. + if (approvalDecision == ExecApprovalDecision.Deny) + return ExecHostPolicyDecision.Deny(ExecApprovalV2Result.UserDenied("user-denied")); + + // Step 3: prompt required — give the user a chance before checking for an allowlist miss. + if (RequiresAsk(context.Ask, context.Security, context.AllowlistMatch, context.SkillAllow) + && approvalDecision is null) + return ExecHostPolicyDecision.RequiresPrompt; + + // Step 4: allowlist miss — security=allowlist, ask=off, no match, no user approval. + var approvedByAsk = approvalDecision is not null; + if (context.Security == ExecSecurity.Allowlist + && !context.AllowlistSatisfied + && !context.SkillAllow + && !approvedByAsk) + return ExecHostPolicyDecision.Deny(ExecApprovalV2Result.AllowlistMiss("allowlist-miss")); + + // Step 4.5: ask=deny — non-interactive fallback (AskFallback=Deny is the store default). + // Fires only when no pre-authorization is in place (AllowlistSatisfied=false). + // When AllowlistSatisfied=true the allowlist already answered the question without + // any prompt, so AskFallback is irrelevant and execution proceeds to Allow. + if (context.Ask == ExecAsk.Deny && approvalDecision is null && !context.AllowlistSatisfied) + return ExecHostPolicyDecision.Deny(ExecApprovalV2Result.AskDeny("ask=deny")); + + // Step 5: allow. + return ExecHostPolicyDecision.Allow(approvedByAsk); + } + + // Pure helper for the prompt condition. + // Research doc 06: four-input boolean function, no I/O, no matching, no store access. + internal static bool RequiresAsk( + ExecAsk ask, + ExecSecurity security, + ExecAllowlistEntry? allowlistMatch, + bool skillAllow) + { + if (ask == ExecAsk.Always) return true; + if (ask == ExecAsk.OnMiss + && security == ExecSecurity.Allowlist + && allowlistMatch is null + && !skillAllow) return true; + return false; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPromptOutcome.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPromptOutcome.cs new file mode 100644 index 000000000..92e00a80d --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalPromptOutcome.cs @@ -0,0 +1,13 @@ +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Decision returned by . +/// Deny is the zero/default value so uninitialized instances fail-closed. +/// +public enum ExecApprovalPromptOutcome +{ + Deny = 0, + Allow, + AllowOnce, + AllowAlways, +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs new file mode 100644 index 000000000..863315792 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NormalizationStep.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +// Either a CanonicalCommandIdentity (IsResolved=true) or a typed denial (IsResolved=false). +// Produced by ExecApprovalV2Normalizer; consumed by the coordinator pipeline (PR7). +public sealed class ExecApprovalV2NormalizationOutcome +{ + public bool IsResolved { get; } + public CanonicalCommandIdentity? Identity { get; } + public ExecApprovalV2Result? Error { get; } + + private ExecApprovalV2NormalizationOutcome(CanonicalCommandIdentity identity) + { + IsResolved = true; + Identity = identity; + } + + private ExecApprovalV2NormalizationOutcome(ExecApprovalV2Result error) + { + IsResolved = false; + Error = error; + } + + public static ExecApprovalV2NormalizationOutcome Ok(CanonicalCommandIdentity identity) + => new(identity); + + public static ExecApprovalV2NormalizationOutcome Fail(ExecApprovalV2Result error) + => new(error); +} + +// Rail 18 steps 2-4: normalize command form → resolve executable → build canonical identity. +// Stateless — safe to call concurrently. +public static class ExecApprovalV2Normalizer +{ + public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest request) + { + var argv = request.Argv; + var cwd = request.Cwd; + var env = request.Env as IReadOnlyDictionary; + + // displayCommand is always derived from argv, never from rawCommand (research doc 05 decision 2). + var displayCommand = ShellQuoting.FormatExecCommand(argv); + + // rawCommand is null in Windows v1 (system.run does not carry it; research doc 05 OQ-V4). + // EvaluationRawCommand stays null — correct and documented conservative output. + string? evaluationRawCommand = null; + + // Singular resolution for state machine. + var resolution = ExecCommandResolver.Resolve(argv, cwd, env); + + // Multi-segment resolution for allowlist. + // Empty list is fail-closed: no allowlist satisfaction possible (research doc 04 R2). + // An empty list is NOT itself a denial at this step — the evaluator decides. + var allowlistResolutions = ExecCommandResolver.ResolveForAllowlist( + argv, evaluationRawCommand, cwd, env); + + // UX patterns for prompting. + var allowAlwaysPatterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(argv, cwd, env); + + // Rail 6: if argv is non-empty but resolution is entirely impossible, deny. + // "Ambiguous or inconsistent" → typed deny, not silent allow. + if (resolution is null && allowlistResolutions.Count == 0) + return Fail("executable-resolution-failed"); + + var identity = new CanonicalCommandIdentity( + argv, + displayCommand, + evaluationRawCommand, + resolution, + allowlistResolutions, + allowAlwaysPatterns, + cwd, + request.TimeoutMs, + env, + request.AgentId, + request.SessionKey); + + return ExecApprovalV2NormalizationOutcome.Ok(identity); + } + + private static ExecApprovalV2NormalizationOutcome Fail(string reason) + => ExecApprovalV2NormalizationOutcome.Fail( + ExecApprovalV2Result.ResolutionFailed(reason)); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullPromptHandler.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullPromptHandler.cs new file mode 100644 index 000000000..9957bf15a --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2NullPromptHandler.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.ExecApprovals; + +/// Default prompt handler stub: always denies. Never throws. +public sealed class ExecApprovalV2NullPromptHandler : IExecApprovalV2PromptHandler +{ + public static readonly ExecApprovalV2NullPromptHandler Instance = new(); + + public Task PromptAsync(ExecApprovalV2PromptRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(ExecApprovalPromptOutcome.Deny); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs new file mode 100644 index 000000000..683a6a32c --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2PromptRequest.cs @@ -0,0 +1,27 @@ +namespace OpenClaw.Shared.ExecApprovals; + +/// +/// Prompt request passed to . +/// +public sealed class ExecApprovalV2PromptRequest +{ + /// + /// Command text as received from the agent — NOT sanitized. Presenters must strip + /// control characters and BiDi overrides before rendering to prevent command spoofing. + /// + public required string DisplayCommand { get; init; } + public string? Cwd { get; init; } + public string? Host { get; init; } + public required ExecSecurity Security { get; init; } + public required ExecAsk Ask { get; init; } + public required string AgentId { get; init; } + public string? ResolvedPath { get; init; } + /// + /// Opaque key scoping AllowOnce/AllowAlways decisions to a conversation session. + /// Minted by the gateway per session; null means no session context is available. + /// Not safe to display — internal identifier only. + /// + public string? SessionKey { get; init; } + /// Short identifier propagated through logging for this approval request. + public required string CorrelationId { get; init; } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs index b175d5156..9e74a32f2 100644 --- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs @@ -7,6 +7,7 @@ public enum ExecApprovalV2Code { Unavailable, SecurityDeny, + AskDeny, AllowlistMiss, UserDenied, ValidationFailed, @@ -34,6 +35,9 @@ public static ExecApprovalV2Result Unavailable(string reason = "Handler not avai public static ExecApprovalV2Result SecurityDeny(string reason) => new(ExecApprovalV2Code.SecurityDeny, reason); + public static ExecApprovalV2Result AskDeny(string reason) + => new(ExecApprovalV2Code.AskDeny, reason); + public static ExecApprovalV2Result AllowlistMiss(string reason) => new(ExecApprovalV2Code.AllowlistMiss, reason); diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs new file mode 100644 index 000000000..b1fb05227 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsContracts.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.ExecApprovals; + +// ── Config enums ────────────────────────────────────────────────────────────── + +public enum ExecSecurity +{ + Deny, + Allowlist, + Full, +} + +public enum ExecAsk +{ + Off, + OnMiss, + Always, + Deny, +} + +public enum ExecApprovalDecision +{ + Allow, + Deny, + AllowOnce, + AllowAlways, +} + +// ── Allowlist contracts ─────────────────────────────────────────────────────── + +public sealed class ExecAllowlistEntry +{ + public Guid? Id { get; set; } + public string? Pattern { get; set; } + public double? LastUsedAt { get; set; } + public string? LastUsedCommand { get; set; } + public string? LastResolvedPath { get; set; } +} + +// ── Persisted config contracts ──────────────────────────────────────────────── + +public sealed class ExecApprovalsSocketConfig +{ + public string? Path { get; set; } + public string? Token { get; set; } +} + +public sealed class ExecApprovalsDefaults +{ + public ExecSecurity? Security { get; set; } + public ExecAsk? Ask { get; set; } + public ExecAsk? AskFallback { get; set; } + public bool? AutoAllowSkills { get; set; } +} + +public sealed class ExecApprovalsAgent +{ + public ExecSecurity? Security { get; set; } + public ExecAsk? Ask { get; set; } + public ExecAsk? AskFallback { get; set; } + public bool? AutoAllowSkills { get; set; } + public List? Allowlist { get; set; } +} + +public sealed class ExecApprovalsFile +{ + public int? Version { get; set; } + public ExecApprovalsSocketConfig? Socket { get; set; } + public ExecApprovalsDefaults? Defaults { get; set; } + public Dictionary? Agents { get; set; } +} + +// ── Resolved/runtime contracts (not serialized) ─────────────────────────────── + +public sealed class ExecApprovalsResolvedDefaults +{ + public ExecSecurity Security { get; init; } + public ExecAsk Ask { get; init; } + public ExecAsk AskFallback { get; init; } + public bool AutoAllowSkills { get; init; } +} + +public sealed class ExecApprovalsResolved +{ + public string AgentId { get; init; } = string.Empty; + public ExecApprovalsResolvedDefaults Defaults { get; init; } = null!; + public IReadOnlyList Allowlist { get; init; } = []; + public string? SocketToken { get; init; } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs new file mode 100644 index 000000000..376e5e20e --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsStore.cs @@ -0,0 +1,318 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.ExecApprovals; + +// New store for exec-approvals.json. Separate from legacy ExecApprovalPolicy (exec-policy.json). +// PR4 scope: read path only. Write path (recordAllowlistUse) is added in PR9. +public sealed class ExecApprovalsStore +{ + // KebabCaseLower covers all macOS enum values: deny, allowlist, full, off, on-miss, always, + // allow-once, allow-always. CamelCase would fail for "on-miss" and "allow-once". + internal static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower) }, + }; + + private readonly string _filePath; + private readonly IOpenClawLogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + private enum LoadFileStatus + { + Missing, + Loaded, + Invalid, + } + + private readonly record struct LoadFileResult(LoadFileStatus Status, ExecApprovalsFile? File); + + public ExecApprovalsStore(string dataPath, IOpenClawLogger logger) + { + _filePath = Path.Combine(dataPath, "exec-approvals.json"); + _logger = logger; + } + + // ── Public API ──────────────────────────────────────────────────────────── + + // No side effects; does not create the file. Used by the evaluator (PR5). + public ExecApprovalsResolved ResolveReadOnly(string? agentId) + { + var result = LoadFile(); + return result.Status != LoadFileStatus.Loaded || result.File is null + ? DefaultResolved(NormalizeAgentId(agentId)) + : ResolveFromFile(result.File, agentId); + } + + // Side-effecting resolve: creates the file if missing, initializes agents dict. + // For startup / settings UI. Not used by the evaluator. + public async Task ResolveAsync(string? agentId) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + var file = await EnsureFileAsync().ConfigureAwait(false); + return ResolveFromFile(file, agentId); + } + finally + { + _lock.Release(); + } + } + + // ── File I/O ────────────────────────────────────────────────────────────── + + private LoadFileResult LoadFile() + { + if (!File.Exists(_filePath)) return new LoadFileResult(LoadFileStatus.Missing, null); + try + { + var json = File.ReadAllText(_filePath); + var file = JsonSerializer.Deserialize(json, JsonOptions); + if (file is null) + { + _logger.Warn("[EXEC-APPROVALS] exec-approvals.json deserialized to null; applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null); + } + if (file.Version != 1) + { + var version = file.Version?.ToString() ?? "missing"; + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json has unsupported version {version}; applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null); + } + return new LoadFileResult(LoadFileStatus.Loaded, Normalize(file)); + } + catch (JsonException ex) + { + _logger.Warn($"[EXEC-APPROVALS] exec-approvals.json is malformed ({ex.Message}); applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null); + } + catch (Exception ex) + { + _logger.Warn($"[EXEC-APPROVALS] Failed to load exec-approvals.json ({ex.Message}); applying default-deny"); + return new LoadFileResult(LoadFileStatus.Invalid, null); + } + } + + private async Task EnsureFileAsync() + { + var result = LoadFile(); + if (result.Status == LoadFileStatus.Loaded && result.File is not null) + { + var file = result.File; + if (file.Agents is null) + { + file = new ExecApprovalsFile + { + Version = file.Version, + Socket = file.Socket, + Defaults = CopyDefaults(file.Defaults), + Agents = [], + }; + await SaveFileAsync(file).ConfigureAwait(false); + } + return file; + } + + if (result.Status == LoadFileStatus.Invalid) + { + _logger.Warn($"[EXEC-APPROVALS] Preserving unreadable exec-approvals.json at {_filePath}; using empty in-memory store"); + return new ExecApprovalsFile { Version = 1, Agents = [] }; + } + + // socket intentionally omitted in Windows v1 (research doc 02 decision 3). + var newFile = new ExecApprovalsFile { Version = 1, Agents = [] }; + await SaveFileAsync(newFile).ConfigureAwait(false); + _logger.Info($"[EXEC-APPROVALS] Created {_filePath}"); + return newFile; + } + + private async Task SaveFileAsync(ExecApprovalsFile file) + { + var dir = Path.GetDirectoryName(_filePath)!; + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); + + var tmp = Path.Combine(dir, $".exec-approvals-{Guid.NewGuid():N}.tmp"); + try + { + var json = JsonSerializer.Serialize(file, JsonOptions); + await File.WriteAllTextAsync(tmp, json).ConfigureAwait(false); + // Atomic replace on NTFS via MoveFileExW (MOVEFILE_REPLACE_EXISTING). + File.Move(tmp, _filePath, overwrite: true); + } + catch (Exception ex) + { + _logger.Error($"[EXEC-APPROVALS] Failed to save {_filePath} ({ex.Message})"); + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + throw; + } + } + + // ── Normalization ───────────────────────────────────────────────────────── + + private static ExecApprovalsFile Normalize(ExecApprovalsFile file) + { + // Trim socket fields; nullify if both are empty after trim. + var socket = file.Socket is null ? null : NormalizeSocket(file.Socket); + + // Migrate agents["default"] → agents["main"]; "main" wins on conflicting fields. + // Null agents stays null here — EnsureFileAsync is responsible for initialization. + var defaults = CopyDefaults(file.Defaults); + + if (file.Agents is null) + return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = null }; + + var agents = new Dictionary(file.Agents); + + if (agents.TryGetValue("default", out var defaultAgent)) + { + agents.Remove("default"); + agents["main"] = agents.TryGetValue("main", out var mainAgent) + ? MergeAgent(fallback: defaultAgent, winner: mainAgent) + : defaultAgent; + } + + // Normalize allowlist entries (dropInvalid: false — keep non-empty invalids). + foreach (var key in agents.Keys.ToList()) + { + var agent = agents[key]; + if (agent.Allowlist is not null) + agents[key] = WithNormalizedAllowlist(agent, dropInvalid: false); + } + + return new ExecApprovalsFile { Version = 1, Socket = socket, Defaults = defaults, Agents = agents }; + } + + private static ExecApprovalsDefaults? CopyDefaults(ExecApprovalsDefaults? d) => + d is null ? null : new ExecApprovalsDefaults + { + Security = d.Security, + Ask = d.Ask, + AskFallback = d.AskFallback, + AutoAllowSkills = d.AutoAllowSkills, + }; + + private static ExecApprovalsSocketConfig? NormalizeSocket(ExecApprovalsSocketConfig s) + { + var path = string.IsNullOrWhiteSpace(s.Path) ? null : s.Path.Trim(); + var token = string.IsNullOrWhiteSpace(s.Token) ? null : s.Token.Trim(); + return (path is null && token is null) ? null : new ExecApprovalsSocketConfig { Path = path, Token = token }; + } + + // winner's non-null fields take precedence; allowlists are concatenated (fallback first). + private static ExecApprovalsAgent MergeAgent(ExecApprovalsAgent fallback, ExecApprovalsAgent winner) + { + var allowlist = new List(); + if (fallback.Allowlist is not null) allowlist.AddRange(fallback.Allowlist); + if (winner.Allowlist is not null) allowlist.AddRange(winner.Allowlist); + + return new ExecApprovalsAgent + { + Security = winner.Security ?? fallback.Security, + Ask = winner.Ask ?? fallback.Ask, + AskFallback = winner.AskFallback ?? fallback.AskFallback, + AutoAllowSkills = winner.AutoAllowSkills ?? fallback.AutoAllowSkills, + Allowlist = allowlist.Count > 0 ? allowlist : null, + }; + } + + private static ExecApprovalsAgent WithNormalizedAllowlist(ExecApprovalsAgent agent, bool dropInvalid) => + new() + { + Security = agent.Security, + Ask = agent.Ask, + AskFallback = agent.AskFallback, + AutoAllowSkills = agent.AutoAllowSkills, + Allowlist = NormalizeAllowlistEntries(agent.Allowlist!, dropInvalid) + is { Count: > 0 } list ? list : null, + }; + + // Mirrors macOS normalizeAllowlistEntries. + // dropInvalid=false: discard only null/empty patterns; keep non-empty ones regardless of validity. + // dropInvalid=true: same in v1 — pattern validity beyond non-empty is enforced by the allowlist + // matcher in PR5, not here. The flag is preserved for API symmetry with macOS. + internal static List NormalizeAllowlistEntries( + IEnumerable entries, bool dropInvalid) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (var entry in entries) + { + var pattern = entry.Pattern?.Trim(); + if (string.IsNullOrEmpty(pattern)) continue; + if (!seen.Add(pattern)) continue; + result.Add(pattern == entry.Pattern ? entry : new ExecAllowlistEntry + { + Id = entry.Id, + Pattern = pattern, + LastUsedAt = entry.LastUsedAt, + LastUsedCommand = entry.LastUsedCommand, + LastResolvedPath = entry.LastResolvedPath, + }); + } + return result; + } + + // ── Cascade resolution ──────────────────────────────────────────────────── + + private static ExecApprovalsResolved ResolveFromFile(ExecApprovalsFile file, string? agentId) + { + var id = NormalizeAgentId(agentId); + var agents = file.Agents ?? new Dictionary(); + agents.TryGetValue(id, out var agentEntry); + agents.TryGetValue("*", out var wildcardEntry); + var defaults = file.Defaults; + + // Cascade: agentEntry → wildcard → defaults → systemDefault + var security = agentEntry?.Security ?? wildcardEntry?.Security ?? defaults?.Security ?? ExecSecurity.Deny; + var ask = agentEntry?.Ask ?? wildcardEntry?.Ask ?? defaults?.Ask ?? ExecAsk.OnMiss; + var askFallback = agentEntry?.AskFallback ?? wildcardEntry?.AskFallback ?? defaults?.AskFallback ?? ExecAsk.Deny; + var autoAllowSkills = agentEntry?.AutoAllowSkills ?? wildcardEntry?.AutoAllowSkills ?? defaults?.AutoAllowSkills ?? false; + + // Allowlist: wildcard first, then agent; then normalize dropInvalid=true. + var combined = new List(); + if (wildcardEntry?.Allowlist is not null) combined.AddRange(wildcardEntry.Allowlist); + if (agentEntry?.Allowlist is not null) combined.AddRange(agentEntry.Allowlist); + + return new ExecApprovalsResolved + { + AgentId = id, + Defaults = new ExecApprovalsResolvedDefaults + { + Security = security, + Ask = ask, + AskFallback = askFallback, + AutoAllowSkills = autoAllowSkills, + }, + Allowlist = NormalizeAllowlistEntries(combined, dropInvalid: true), + SocketToken = file.Socket?.Token, + }; + } + + private static ExecApprovalsResolved DefaultResolved(string agentId) => + new() + { + AgentId = agentId, + Defaults = new ExecApprovalsResolvedDefaults + { + Security = ExecSecurity.Deny, + Ask = ExecAsk.OnMiss, + AskFallback = ExecAsk.Deny, + AutoAllowSkills = false, + }, + Allowlist = [], + }; + + // null/empty agentId → "main". Mirrors macOS. Evaluator does not need to know this. + private static string NormalizeAgentId(string? agentId) => + string.IsNullOrWhiteSpace(agentId) ? "main" : agentId; +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs new file mode 100644 index 000000000..59f53ab9a --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandResolution.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace OpenClaw.Shared.ExecApprovals; + +// Resolved identity of a single executable token. +// Shape mirrors macOS ExecCommandResolution struct. +public readonly record struct ExecCommandResolution( + string RawExecutable, + string? ResolvedPath, + string ExecutableName, + string? Cwd); + +// The three resolution functions required by the pipeline. +// resolve() → singular, for state machine +// ResolveForAllowlist() → multi-segment, fail-closed, for allowlist matching +// ResolveAllowAlwaysPatterns() → UX suggestions for prompt +internal static class ExecCommandResolver +{ + // Windows executable extensions, tried in order for basename search. + private static readonly string[] s_extensions = [".exe", ".cmd", ".bat", ".com"]; + + // ── Public API ─────────────────────────────────────────────────────────── + + // Singular resolution of the primary executable for the state machine. + // Returns null if the command is empty or resolution is impossible. + // Unwraps transparent env prefixes (no modifiers). + internal static ExecCommandResolution? Resolve( + IReadOnlyList command, + string? cwd, + IReadOnlyDictionary? env) + { + var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command); + if (effective.Count == 0) return null; + var raw = effective[0].Trim(); + return raw.Length == 0 ? null : ResolveExecutable(raw, cwd, env); + } + + // Multi-segment resolution for allowlist matching. + // Detects shell wrappers; splits payload chain; resolves one executable per segment. + // Returns empty list (fail-closed) on any ambiguity, command substitution, or env manipulation. + internal static IReadOnlyList ResolveForAllowlist( + IReadOnlyList command, + string? evaluationRawCommand, + string? cwd, + IReadOnlyDictionary? env) + { + // Fail-closed: any env invocation with modifiers (flags or VAR=val assignments). + // The allowlist cannot verify which executable will actually run under a modified env — + // the resolver uses the original env while execution uses the modified one. + // Subsumes the previous shell-wrapper-only check (Hanselman review finding #2). + if (command.Count > 0 + && ExecCommandToken.IsEnv(command[0].Trim()) + && ExecEnvInvocationUnwrapper.HasModifiers(command)) + return []; + + var wrapper = ExecShellWrapperNormalizer.Extract(command); + if (wrapper.IsWrapper) + { + if (wrapper.InlineCommand is null) return []; + var segments = SplitShellCommandChain(wrapper.InlineCommand); + if (segments is null) return []; + + var resolutions = new List(segments.Count); + foreach (var segment in segments) + { + var token = ParseFirstToken(segment); + if (token is null) return []; + // -EncodedCommand and aliases in segment position: fail-closed (research doc 04 S1). + if (SegmentUsesEncodedCommand(segment, token)) return []; + var res = ResolveExecutable(token, cwd, env); + if (res is null) return []; + resolutions.Add(res.Value); + } + return resolutions; + } + + // Direct exec: fail-closed if powershell/pwsh invoked directly with -EncodedCommand. + // Covers top-level `["powershell", "-enc", ...]` and transparent `["env", "pwsh", "-enc", ...]`. + if (DirectExecUsesEncodedCommand(command)) return []; + + var single = ResolveSingle(command, evaluationRawCommand, cwd, env); + return single is null ? [] : [single.Value]; + } + + // UX suggestions of allowlist patterns for prompting. + // Unlike ResolveForAllowlist, this unwraps env with modifiers to surface the real executable. + internal static IReadOnlyList ResolveAllowAlwaysPatterns( + IReadOnlyList command, + string? cwd, + IReadOnlyDictionary? env) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var patterns = new List(); + CollectPatterns(command, cwd, env, seen, patterns, 0); + return patterns; + } + + // ── Resolution helpers ─────────────────────────────────────────────────── + + private static ExecCommandResolution? ResolveSingle( + IReadOnlyList command, + string? rawCommand, + string? cwd, + IReadOnlyDictionary? env) + { + // Prefer first token of evaluationRawCommand when present. + if (!string.IsNullOrWhiteSpace(rawCommand)) + { + var token = ParseFirstToken(rawCommand); + if (token is not null) return ResolveExecutable(token, cwd, env); + } + return Resolve(command, cwd, env); + } + + private static ExecCommandResolution? ResolveExecutable( + string rawExecutable, + string? cwd, + IReadOnlyDictionary? env) + { + try + { + var expanded = ExpandTilde(rawExecutable); + var hasSep = expanded.Contains('/') || expanded.Contains('\\'); + + string? resolvedPath; + if (hasSep) + { + // Reject paths with ':' in non-volume-separator positions (ADS, non-standard forms). + if (HasNonStandardColon(expanded)) return null; + + resolvedPath = Path.IsPathFullyQualified(expanded) + ? Path.GetFullPath(expanded) + : Path.GetFullPath(expanded, string.IsNullOrWhiteSpace(cwd) + ? Directory.GetCurrentDirectory() + : cwd.Trim()); + } + else + { + resolvedPath = FindInPath(expanded, GetSearchPaths(env), GetPathExtensions(env)); + } + + var name = resolvedPath is not null ? Path.GetFileName(resolvedPath) : expanded; + return new ExecCommandResolution(expanded, resolvedPath, name, cwd); + } + catch { return null; } // fail-closed; intentionally broad — add diagnostic tracing here if needed + } + + // ── Shell command chain splitting ──────────────────────────────────────── + + // Splits a shell command string on ;, &&, ||, |, &, \n. + // Returns null (fail-closed) on command/process substitution: $(...), `...`, <(...), >(...). + // Returns null on unclosed quotes or unresolved escapes. + private static IReadOnlyList? SplitShellCommandChain(string command) + { + var trimmed = command.Trim(); + if (trimmed.Length == 0) return null; + + var segments = new List(); + var current = new StringBuilder(); + bool inSingle = false, inDouble = false, escaped = false; + var chars = trimmed.ToCharArray(); + + for (var i = 0; i < chars.Length; i++) + { + var ch = chars[i]; + char? next = i + 1 < chars.Length ? chars[i + 1] : null; + + if (escaped) { current.Append(ch); escaped = false; continue; } + if (ch == '\\' && !inSingle) { current.Append(ch); escaped = true; continue; } + if (ch == '\'' && !inDouble) { inSingle = !inSingle; current.Append(ch); continue; } + if (ch == '"' && !inSingle) { inDouble = !inDouble; current.Append(ch); continue; } + + // Fail-closed on command/process substitution. + if (!inSingle && IsCommandSubstitution(ch, next, inDouble)) return null; + + if (!inSingle && !inDouble) + { + var step = DelimiterStep(ch, i > 0 ? chars[i - 1] : (char?)null, next); + if (step.HasValue) + { + var seg = current.ToString().Trim(); + if (seg.Length == 0) return null; + segments.Add(seg); + current.Clear(); + i += step.Value - 1; + continue; + } + } + + current.Append(ch); + } + + if (escaped || inSingle || inDouble) return null; + + var last = current.ToString().Trim(); + if (last.Length == 0) return null; + segments.Add(last); + return segments; + } + + private static bool IsCommandSubstitution(char ch, char? next, bool inDouble) + { + if (inDouble) return ch == '`' || (ch == '$' && next == '('); + return ch == '`' || + (ch == '$' && next == '(') || + (ch == '<' && next == '(') || + (ch == '>' && next == '('); + } + + private static int? DelimiterStep(char ch, char? prev, char? next) + { + if (ch == ';' || ch == '\n') return 1; + if (ch == '&') + { + if (next == '&') return 2; + return (prev == '>' || next == '>') ? null : (int?)1; + } + if (ch == '|') + { + if (next == '|' || next == '&') return 2; + return 1; + } + return null; + } + + // Extracts the first shell-tokenized word from a command string. + private static string? ParseFirstToken(string command) + { + var trimmed = command.Trim(); + if (trimmed.Length == 0) return null; + var first = trimmed[0]; + if (first == '"' || first == '\'') + { + var rest = trimmed.AsSpan(1); + var end = rest.IndexOf(first); + if (end < 0) return null; // unclosed quote — fail-closed; do not guess the token + var inner = rest[..end].ToString(); + if (inner.Length == 0) return null; + // Preserve any suffix after the closing quote up to the next whitespace. + // Handles `"git".exe` → "git.exe" and `"C:\Program Files\Git\bin\git".exe` → *.exe. + var afterClose = rest[(end + 1)..]; + var suffixEnd = afterClose.IndexOfAny(' ', '\t'); + var suffix = suffixEnd >= 0 ? afterClose[..suffixEnd].ToString() : afterClose.ToString(); + return suffix.Length > 0 ? inner + suffix : inner; + } + var space = trimmed.AsSpan().IndexOfAny(' ', '\t'); + return space >= 0 ? trimmed[..space] : trimmed; + } + + // ── allowAlwaysPatterns collection ─────────────────────────────────────── + + private static void CollectPatterns( + IReadOnlyList command, + string? cwd, + IReadOnlyDictionary? env, + HashSet seen, + List patterns, + int depth) + { + if (depth >= 3 || command.Count == 0) return; + + var wrapper = ExecShellWrapperNormalizer.Extract(command); + if (wrapper.IsWrapper && wrapper.InlineCommand is not null) + { + var segments = SplitShellCommandChain(wrapper.InlineCommand); + if (segments is null) return; + foreach (var seg in segments) + { + // allowAlwaysPatterns does NOT fail-closed on -EncodedCommand: it's UX only. + var token = ParseFirstToken(seg); + if (token is null) continue; + var res = ResolveExecutable(token, cwd, env); + if (res is null) continue; + var pattern = res.Value.ResolvedPath ?? res.Value.RawExecutable; + if (seen.Add(pattern)) patterns.Add(pattern); + } + return; + } + + // For direct exec, unwrap env including with-modifier cases for pattern discovery. + var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command); + if (effective.Count == 0) return; + var rawToken = effective[0].Trim(); + if (rawToken.Length == 0) return; + var resolution = ResolveExecutable(rawToken, cwd, env); + if (resolution is null) return; + var pat = resolution.Value.ResolvedPath ?? resolution.Value.RawExecutable; + if (seen.Add(pat)) patterns.Add(pat); + } + + // ── -EncodedCommand detection ───────────────────────────────────────────── + + // Research doc 04 S1: if a chain segment invokes PowerShell with -EncodedCommand (or any + // alias / unambiguous prefix abbreviation), the payload is opaque base64 — fail-closed. + // Only triggers when the first token IS a PowerShell binary AND the segment contains the flag. + // `powershell -c 'Get-Date'` (no -enc) must NOT be fail-closed. + private static bool SegmentUsesEncodedCommand(string segment, string firstToken) + { + var b = ExecCommandToken.NormalizedBasename(firstToken); + if (b is not ("powershell" or "pwsh")) return false; + + var rest = segment.AsSpan(); + while (rest.Length > 0) + { + var i = 0; + while (i < rest.Length && char.IsWhiteSpace(rest[i])) i++; + rest = rest[i..]; + if (rest.Length == 0) break; + + // Extract next token — quoted strings count as one unit so `"-enc"` is detected. + int end; + if (rest[0] is '"' or '\'') + { + var q = rest[0]; + end = 1; + while (end < rest.Length && rest[end] != q) end++; + if (end < rest.Length) end++; // include closing quote + } + else + { + end = 0; + while (end < rest.Length && !char.IsWhiteSpace(rest[end])) end++; + } + + var token = rest[..end].ToString(); + rest = rest[end..]; + + if (IsEncodedCommandFlag(token)) return true; + if (token == "--") break; + } + return false; + } + + // Returns true when a raw flag token (possibly quoted, possibly with colon/equals value suffix) + // represents -EncodedCommand or any of its unambiguous prefix abbreviations. + // Covers: "-EncodedCommand", "-enc", "-ec", "-e", `"-enc"`, `-enc:payload`, `-encod`, etc. + private static bool IsEncodedCommandFlag(string rawToken) + { + var t = rawToken; + if (t.Length >= 2 && t[0] is '"' or '\'' && t[^1] == t[0]) + t = t[1..^1]; // strip matching outer quotes + if (t.Length == 0 || t[0] != '-') return false; + // Strip trailing :value or =value (e.g. -EncodedCommand:base64). + var sep = t.AsSpan(1).IndexOfAny('=', ':'); + var flag = (sep >= 0 ? t[..(sep + 1)] : t).ToLowerInvariant(); + // -e is accepted by Windows PowerShell as a short alias for -EncodedCommand. + if (flag is "-e" or "-ec" or "-enc" or "-encodedcommand") return true; + // Any unambiguous prefix abbreviation of -encodedcommand beginning at -en. + const string full = "-encodedcommand"; + return flag.Length >= 3 && full.StartsWith(flag, StringComparison.Ordinal); + } + + // True when direct exec (no shell wrapper) is a PowerShell invocation with -EncodedCommand. + // Unwraps transparent env prefixes so `["env", "pwsh", "-enc", ...]` is also caught. + private static bool DirectExecUsesEncodedCommand(IReadOnlyList command) + { + var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command); + if (effective.Count < 2) return false; + var b = ExecCommandToken.NormalizedBasename(effective[0].Trim()); + if (b is not ("powershell" or "pwsh")) return false; + for (var i = 1; i < effective.Count; i++) + { + var t = effective[i].Trim(); + if (t == "--") break; + if (IsEncodedCommandFlag(t)) return true; + } + return false; + } + + // ── PATH search ─────────────────────────────────────────────────────────── + + private static string? GetEnvValueIgnoreCase(IReadOnlyDictionary? env, string key) + { + if (env is null) return null; + foreach (var kvp in env) + { + if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase)) + return kvp.Value; + } + return null; + } + + private static string? FindInPath( + string name, + IReadOnlyList searchPaths, + IReadOnlyList extensions) + { + foreach (var dir in searchPaths) + { + if (string.IsNullOrEmpty(dir)) continue; + var candidate = Path.Combine(dir, name); + // PATHEXT extensions first — matches Windows CreateProcess resolution order. + // A no-extension shadow in PATH must not shadow a PATHEXT binary of the same stem. + // Note: PATHEXT is probed even when `name` already carries an extension (git.exe → + // tries git.exe.exe, git.exe.cmd, …). This matches CreateProcess behavior — the extra + // File.Exists calls are harmless and avoiding them would require extension detection here. + foreach (var ext in extensions) + { + var withExt = candidate + ext; + if (File.Exists(withExt)) return TryNormalizePath(withExt); + } + // Bare name as final fallback (covers names that already have an explicit extension). + if (File.Exists(candidate)) return TryNormalizePath(candidate); + } + return null; + } + + private static IReadOnlyList GetSearchPaths(IReadOnlyDictionary? env) + { + var rawPath = GetEnvValueIgnoreCase(env, "PATH"); + if (!string.IsNullOrEmpty(rawPath)) + { + var parts = rawPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) return parts; + } + // Fallback to process PATH. + var processPath = Environment.GetEnvironmentVariable("PATH"); + if (!string.IsNullOrEmpty(processPath)) + { + var parts = processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) return parts; + } + return WellKnownPaths(); + } + + private static IReadOnlyList GetPathExtensions(IReadOnlyDictionary? env) + { + var rawPathExt = GetEnvValueIgnoreCase(env, "PATHEXT"); + if (!string.IsNullOrEmpty(rawPathExt)) + { + var parts = rawPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) return parts; + } + var processPathExt = Environment.GetEnvironmentVariable("PATHEXT"); + if (!string.IsNullOrEmpty(processPathExt)) + { + var parts = processPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length > 0) return parts; + } + return s_extensions; + } + + private static IReadOnlyList WellKnownPaths() + { + var sys32 = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32"); + var sys = Environment.GetFolderPath(Environment.SpecialFolder.System); + var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + return + [ + sys32, + sys, + Path.Combine(sys32, "OpenSSH"), + Path.Combine(pf, "Git", "usr", "bin"), + Path.Combine(pf, "Git", "bin"), + ]; + } + + // ── Path helpers ────────────────────────────────────────────────────────── + + private static string ExpandTilde(string path) + { + if (!path.StartsWith('~')) return path; + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return path.Length == 1 ? home : home + path[1..]; + } + + // Paths with ':' outside the volume-separator position are rejected (ADS, non-standard forms). + // Research doc 04 section 3 / S3. + private static bool HasNonStandardColon(string path) + { + // Extended-length prefix — strip it and evaluate the remainder (\\?\C:\ is valid). + var effective = path.StartsWith(@"\\?\", StringComparison.Ordinal) ? path[4..] : path; + + // UNC paths (\\server\share) and extended UNC (\\?\UNC\...) have no drive colon — fine. + if (effective.StartsWith(@"\\", StringComparison.Ordinal)) return false; + + var colonIdx = effective.IndexOf(':'); + if (colonIdx < 0) return false; // no colon — fine + // Drive-letter form: single ASCII letter at index 0 followed by ':' — fine if no second colon. + // '1', '!' etc. at index 0 are not valid drive letters and must be rejected. + if (colonIdx == 1 && char.IsAsciiLetter(effective[0])) + return effective.IndexOf(':', 2) >= 0; + return true; + } + + // Attempt 8.3 → long path normalization for paths that exist on disk. + // Only applied to resolved paths from PATH search (existence already confirmed). + // Research doc 04 section canonicalization / 8.3 short names. + private static string TryNormalizePath(string path) + { + // GetFullPath resolves . and .. but does not expand 8.3 short names. + // Full GetLongPathName P/Invoke is left as OQ-R1 in the research docs. + try { return Path.GetFullPath(path); } + catch { return path; } // hostile path must not throw out of resolution + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs new file mode 100644 index 000000000..28a963a4b --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecCommandToken.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; + +namespace OpenClaw.Shared.ExecApprovals; + +// Utility helpers for command token classification. +internal static class ExecCommandToken +{ + // Returns the lowercased last path component (basename) of a token, without extension. + internal static string BasenameLower(string token) + { + var trimmed = token.Trim(); + if (trimmed.Length == 0) return string.Empty; + var name = Path.GetFileName(trimmed.Replace('\\', '/')); + if (name.Length == 0) name = trimmed; + return name.ToLowerInvariant(); + } + + // Returns the basename without .exe suffix (lowercased). + internal static string NormalizedBasename(string token) + { + var b = BasenameLower(token); + return b.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? b[..^4] : b; + } + + internal static bool IsEnv(string token) => + NormalizedBasename(token) == "env"; +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs b/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs new file mode 100644 index 000000000..3410e882d --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecEnvInvocationUnwrapper.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace OpenClaw.Shared.ExecApprovals; + +// Strips `env [OPTIONS] [VAR=VAL...] COMMAND [ARGS...]` so the true executable can be resolved. +// Fail-closed: returns null when any unknown flag is encountered or the command cannot be safely +// unwrapped. Mirrors ExecEnvInvocationUnwrapper in the windows-app reference. +internal static class ExecEnvInvocationUnwrapper +{ + internal const int MaxWrapperDepth = 4; + + private static readonly Regex s_envAssignment = + new(@"^[A-Za-z_][A-Za-z0-9_]*=", RegexOptions.Compiled); + + // Strips one level of `env` wrapper. + // Returns the remaining argv starting at the real COMMAND token, or null on any ambiguity. + internal static IReadOnlyList? Unwrap(IReadOnlyList command) + { + var idx = 1; + var expectsOptionValue = false; + + while (idx < command.Count) + { + var token = command[idx].Trim(); + if (token.Length == 0) { idx++; continue; } + + if (expectsOptionValue) { expectsOptionValue = false; idx++; continue; } + + if (token == "--" || token == "-") { idx++; break; } + + if (s_envAssignment.IsMatch(token)) { idx++; continue; } + + if (token.StartsWith('-') && token != "-") + { + var lower = token.ToLowerInvariant(); + var flag = lower.Split('=', 2)[0]; + + if (ExecEnvOptions.FlagOnly.Contains(flag)) { idx++; continue; } + + if (ExecEnvOptions.WithValue.Contains(flag)) + { + if (!lower.Contains('=')) expectsOptionValue = true; + idx++; + continue; + } + + if (ExecEnvOptions.InlineValuePrefixes.Any(p => lower.StartsWith(p, StringComparison.Ordinal))) + { + idx++; + continue; + } + + return null; // Unknown flag — fail-closed. + } + + break; // Executable token found. + } + + if (idx >= command.Count) return null; + return command.Skip(idx).ToList(); + } + + // Returns true when the env invocation has flags or VAR=val assignments before the command. + // `--` ends option processing without modifying the environment → not a modifier. + // `-` alone replaces the environment entirely → modifier. + internal static bool HasModifiers(IReadOnlyList command) + { + for (var i = 1; i < command.Count; i++) + { + var token = command[i].Trim(); + if (token.Length == 0) continue; + if (token == "--") return false; + if (token == "-") return true; + if (token.StartsWith('-')) return true; + if (s_envAssignment.IsMatch(token)) return true; + return false; // first non-modifier token is the command + } + return false; + } + + // Iteratively strips env wrappers for executable resolution only. + internal static IReadOnlyList UnwrapForResolution(IReadOnlyList command) + { + var current = command; + for (var depth = 0; depth < MaxWrapperDepth; depth++) + { + if (current.Count == 0) break; + var token = current[0].Trim(); + if (token.Length == 0) break; + if (!ExecCommandToken.IsEnv(token)) break; + var unwrapped = Unwrap(current); + if (unwrapped is null || unwrapped.Count == 0) break; + current = unwrapped; + } + return current; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs b/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs new file mode 100644 index 000000000..7b9e6a738 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecEnvOptions.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +// Option grammar of the POSIX `env` command. +// Mirrors the constants in the windows-app reference (ExecEnvOptions.cs). +internal static class ExecEnvOptions +{ + // Options that consume the next argument as their value (or use inline = form). + internal static readonly HashSet WithValue = new(System.StringComparer.Ordinal) + { + "-u", "--unset", + "-c", "--chdir", + "-s", "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + }; + + // Options that are standalone flags (take no value at all). + internal static readonly HashSet FlagOnly = new(System.StringComparer.Ordinal) + { + "-i", "--ignore-environment", + "-0", "--null", + }; + + // Prefixes for the inline-value form (e.g. `-uFOO` or `--unset=FOO`). + internal static readonly IReadOnlyList InlineValuePrefixes = + [ + "-u", "-c", "-s", + "--unset=", + "--chdir=", + "--split-string=", + "--default-signal=", + "--ignore-signal=", + "--block-signal=", + ]; +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecHostPolicyDecision.cs b/src/OpenClaw.Shared/ExecApprovals/ExecHostPolicyDecision.cs new file mode 100644 index 000000000..efe6e4adf --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecHostPolicyDecision.cs @@ -0,0 +1,30 @@ +namespace OpenClaw.Shared.ExecApprovals; + +// Outcome of the stateless evaluator. Exactly three cases: deny, requiresPrompt, allow. +// Sealed class hierarchy is the idiomatic C# discriminated-union representation. +// Research doc 06 OQ-SM3: closed for implementation in this PR. +public abstract class ExecHostPolicyDecision +{ + private ExecHostPolicyDecision() { } + + public sealed class DenyOutcome : ExecHostPolicyDecision + { + public ExecApprovalV2Result Error { get; } + internal DenyOutcome(ExecApprovalV2Result error) => Error = error; + } + + public sealed class RequiresPromptOutcome : ExecHostPolicyDecision + { + internal RequiresPromptOutcome() { } + } + + public sealed class AllowOutcome : ExecHostPolicyDecision + { + public bool ApprovedByAsk { get; } + internal AllowOutcome(bool approvedByAsk) => ApprovedByAsk = approvedByAsk; + } + + public static ExecHostPolicyDecision Deny(ExecApprovalV2Result error) => new DenyOutcome(error); + public static readonly ExecHostPolicyDecision RequiresPrompt = new RequiresPromptOutcome(); + public static ExecHostPolicyDecision Allow(bool approvedByAsk) => new AllowOutcome(approvedByAsk); +} diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs new file mode 100644 index 000000000..71e36b472 --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/ExecShellWrapperNormalizer.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; + +namespace OpenClaw.Shared.ExecApprovals; + +// Single-level shell wrapper detection for the V2 exec approval pipeline. +// Differs from the legacy ExecShellWrapperParser.Expand (BFS multi-level, string-based). +// This normalizer operates on argv (IReadOnlyList) and performs one level of +// wrapper detection, with recursive env-prefix unwrapping up to MaxWrapperDepth. +// Rail 18 step 2: normalize command form. +internal static class ExecShellWrapperNormalizer +{ + private enum WrapperKind { Posix, Cmd, PowerShell } + + private sealed record WrapperSpec(WrapperKind Kind, HashSet Names); + + private static readonly HashSet s_posixInlineFlags = + new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" }; + + private static readonly HashSet s_powerShellInlineFlags = + new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" }; + + private static readonly WrapperSpec[] s_specs = + [ + new(WrapperKind.Posix, new HashSet(StringComparer.OrdinalIgnoreCase) + { "ash", "sh", "bash", "zsh", "dash", "ksh", "fish" }), + new(WrapperKind.Cmd, new HashSet(StringComparer.OrdinalIgnoreCase) + { "cmd", "cmd.exe" }), + new(WrapperKind.PowerShell, new HashSet(StringComparer.OrdinalIgnoreCase) + { "powershell", "powershell.exe", "pwsh", "pwsh.exe" }), + ]; + + internal sealed record ParsedWrapper(bool IsWrapper, string? InlineCommand); + + internal static readonly ParsedWrapper NotWrapper = new(false, null); + + // Detects a single-level shell wrapper in argv. + // rawCommand is always null in Windows v1 (not in system.run protocol; research doc 05 OQ-V4). + // Detection is on argv only; rawCommand is accepted for API compatibility with future use. + internal static ParsedWrapper Extract(IReadOnlyList command, string? rawCommand = null) + => ExtractInner(command, rawCommand, 0); + + private static ParsedWrapper ExtractInner( + IReadOnlyList command, string? rawCommand, int depth) + { + if (depth >= ExecEnvInvocationUnwrapper.MaxWrapperDepth) return NotWrapper; + if (command.Count == 0) return NotWrapper; + + var token0 = command[0].Trim(); + if (token0.Length == 0) return NotWrapper; + + // Recursively unwrap transparent env prefixes. + if (ExecCommandToken.IsEnv(token0)) + { + var unwrapped = ExecEnvInvocationUnwrapper.Unwrap(command); + if (unwrapped is null) return NotWrapper; + return ExtractInner(unwrapped, rawCommand, depth + 1); + } + + var basename = ExecCommandToken.NormalizedBasename(token0); + var spec = Array.Find(s_specs, s => s.Names.Contains(basename)); + if (spec is null) return NotWrapper; + + var payload = ExtractPayload(command, spec); + if (payload is null) return NotWrapper; + + return new ParsedWrapper(true, payload); + } + + private static string? ExtractPayload(IReadOnlyList command, WrapperSpec spec) => + spec.Kind switch + { + WrapperKind.Posix => ExtractPosixPayload(command), + WrapperKind.Cmd => ExtractCmdPayload(command), + WrapperKind.PowerShell => ExtractPowerShellPayload(command), + _ => null, + }; + + private static string? ExtractPosixPayload(IReadOnlyList command) + { + if (command.Count < 2) return null; + var flag = command[1].Trim(); + if (!s_posixInlineFlags.Contains(flag)) return null; + if (command.Count < 3) return null; + var payload = command[2].Trim(); + return payload.Length == 0 ? null : payload; + } + + private static string? ExtractCmdPayload(IReadOnlyList command) + { + for (var i = 1; i < command.Count; i++) + { + if (string.Equals(command[i].Trim(), "/c", StringComparison.OrdinalIgnoreCase)) + { + var tail = string.Join(" ", command.Skip(i + 1)).Trim(); + return tail.Length == 0 ? null : tail; + } + } + return null; + } + + private static string? ExtractPowerShellPayload(IReadOnlyList command) + { + for (var i = 1; i < command.Count; i++) + { + var t = command[i].Trim().ToLowerInvariant(); + if (t.Length == 0) continue; + if (t == "--") break; + if (s_powerShellInlineFlags.Contains(t)) + { + if (i + 1 >= command.Count) return null; + var payload = command[i + 1].Trim(); + return payload.Length == 0 ? null : payload; + } + } + return null; + } +} diff --git a/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2PromptHandler.cs b/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2PromptHandler.cs new file mode 100644 index 000000000..2cc646e1c --- /dev/null +++ b/src/OpenClaw.Shared/ExecApprovals/IExecApprovalV2PromptHandler.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.ExecApprovals; + +public interface IExecApprovalV2PromptHandler +{ + // Implementations must never throw. On any unhandled error, fail-closed to Deny. + Task PromptAsync(ExecApprovalV2PromptRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/OpenClaw.Shared/ExecEnvSanitizer.cs b/src/OpenClaw.Shared/ExecEnvSanitizer.cs index e657c1b30..3a0ce6c4e 100644 --- a/src/OpenClaw.Shared/ExecEnvSanitizer.cs +++ b/src/OpenClaw.Shared/ExecEnvSanitizer.cs @@ -90,9 +90,20 @@ internal static bool IsBlocked(string? name) if (name.IndexOfAny(['=', '\0', '\r', '\n']) >= 0) return true; - foreach (var c in name) + // Vectorized scan: any char in [0x00, 0x20] covers all ASCII control characters + // (0x01–0x1F) plus space (0x20) in a single SIMD pass — the common fast path for + // the ASCII-only names that make up virtually all environment variable keys. + var span = name.AsSpan(); + if (span.IndexOfAnyInRange('\x00', '\x20') >= 0) + return true; + // DEL (0x7F) — control char outside the range above. + if (span.IndexOf('\x7F') >= 0) + return true; + // Non-ASCII Unicode control / whitespace (rare; UTF-8 env var names are uncommon). + for (var i = 0; i < name.Length; i++) { - if (char.IsControl(c) || char.IsWhiteSpace(c)) + var c = name[i]; + if (c > '\x7F' && (char.IsControl(c) || char.IsWhiteSpace(c))) return true; } diff --git a/src/OpenClaw.Shared/ExecShellWrapperParser.cs b/src/OpenClaw.Shared/ExecShellWrapperParser.cs index b5640e3ff..8649db3e4 100644 --- a/src/OpenClaw.Shared/ExecShellWrapperParser.cs +++ b/src/OpenClaw.Shared/ExecShellWrapperParser.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using OpenClaw.Shared.ExecApprovals; namespace OpenClaw.Shared; @@ -78,12 +79,11 @@ private static (string? Payload, string? Shell, string? Error) TryExtractWrapped if (tokens.Length < 2) return default; - var executable = Path.GetFileName(tokens[0]); + var executable = ExecCommandToken.NormalizedBasename(tokens[0]); if (string.IsNullOrWhiteSpace(executable)) return default; - if (executable.Equals("cmd", StringComparison.OrdinalIgnoreCase) || - executable.Equals("cmd.exe", StringComparison.OrdinalIgnoreCase)) + if (executable == "cmd") { for (var i = 1; i < tokens.Length; i++) { @@ -98,22 +98,13 @@ private static (string? Payload, string? Shell, string? Error) TryExtractWrapped } } - if (executable.Equals("powershell", StringComparison.OrdinalIgnoreCase) || - executable.Equals("powershell.exe", StringComparison.OrdinalIgnoreCase)) - { + if (executable == "powershell") return ParsePowerShellPayload(tokens, "powershell"); - } - if (executable.Equals("pwsh", StringComparison.OrdinalIgnoreCase) || - executable.Equals("pwsh.exe", StringComparison.OrdinalIgnoreCase)) - { + if (executable == "pwsh") return ParsePowerShellPayload(tokens, "pwsh"); - } - if (executable.Equals("bash", StringComparison.OrdinalIgnoreCase) || - executable.Equals("bash.exe", StringComparison.OrdinalIgnoreCase) || - executable.Equals("sh", StringComparison.OrdinalIgnoreCase) || - executable.Equals("sh.exe", StringComparison.OrdinalIgnoreCase)) + if (executable is "bash" or "sh" or "zsh" or "dash" or "ash" or "ksh" or "fish") { for (var i = 1; i < tokens.Length; i++) { @@ -135,8 +126,26 @@ private static (string? Payload, string? Shell, string? Error) ParsePowerShellPa for (var i = 1; i < tokens.Length; i++) { var option = tokens[i]; - if (option.Equals("-Command", StringComparison.OrdinalIgnoreCase) || - option.Equals("-c", StringComparison.OrdinalIgnoreCase)) + + // Check for inline separator form first: -flag:value or -flag=value + var sepIdx = IndexOfFlagSeparator(option); + if (sepIdx > 0) + { + var flagPart = option[..sepIdx]; + var valuePart = option[(sepIdx + 1)..]; + + if (IsCommandFlag(flagPart)) + { + return string.IsNullOrWhiteSpace(valuePart) + ? ("", shell, "Shell wrapper payload was empty") + : (valuePart, shell, null); + } + + if (IsEncodedCommandFlag(flagPart)) + return DecodeEncodedPayload(valuePart, shell); + } + + if (IsCommandFlag(option)) { var payload = string.Join(" ", tokens, i + 1, tokens.Length - i - 1).Trim(); return string.IsNullOrWhiteSpace(payload) @@ -144,32 +153,68 @@ private static (string? Payload, string? Shell, string? Error) ParsePowerShellPa : (payload, shell, null); } - if (option.Equals("-EncodedCommand", StringComparison.OrdinalIgnoreCase) || - option.Equals("-enc", StringComparison.OrdinalIgnoreCase) || - option.Equals("-ec", StringComparison.OrdinalIgnoreCase)) + if (IsEncodedCommandFlag(option)) { var encoded = i + 1 < tokens.Length ? tokens[i + 1] : null; - if (string.IsNullOrWhiteSpace(encoded)) - return ("", shell, "Shell wrapper payload was empty"); - - try - { - var bytes = Convert.FromBase64String(encoded); - var payload = Encoding.Unicode.GetString(bytes).Trim(); - return string.IsNullOrWhiteSpace(payload) - ? ("", shell, "EncodedCommand decoded to an empty payload") - : (payload, shell, null); - } - catch (FormatException) - { - return ("", shell, "EncodedCommand could not be decoded"); - } + return DecodeEncodedPayload(encoded, shell); } } return default; } + // Returns the index of the first ':' or '=' in a flag token (after the leading '-'). + private static int IndexOfFlagSeparator(string token) + { + for (var i = 1; i < token.Length; i++) + { + if (token[i] == ':' || token[i] == '=') + return i; + } + return -1; + } + + // Matches -Command and -c (documented PowerShell -Command aliases). + private static bool IsCommandFlag(string flag) => + flag.Equals("-Command", StringComparison.OrdinalIgnoreCase) || + flag.Equals("-c", StringComparison.OrdinalIgnoreCase); + + // Matches -e/-ec aliases and all unique prefix abbreviations of -EncodedCommand. + // Windows PowerShell accepts -e as EncodedCommand despite the apparent ambiguity with + // -ExecutionPolicy, so the parser must fail closed and decode it. + private static bool IsEncodedCommandFlag(string flag) + { + if (flag.Equals("-e", StringComparison.OrdinalIgnoreCase)) + return true; + + if (flag.Equals("-ec", StringComparison.OrdinalIgnoreCase)) + return true; + + const string fullFlag = "-encodedcommand"; + return flag.Length >= 3 && // minimum: -en + flag.Length <= fullFlag.Length && + fullFlag.StartsWith(flag, StringComparison.OrdinalIgnoreCase); + } + + private static (string? Payload, string? Shell, string? Error) DecodeEncodedPayload(string? encoded, string shell) + { + if (string.IsNullOrWhiteSpace(encoded)) + return ("", shell, "Shell wrapper payload was empty"); + + try + { + var bytes = Convert.FromBase64String(encoded); + var payload = Encoding.Unicode.GetString(bytes).Trim(); + return string.IsNullOrWhiteSpace(payload) + ? ("", shell, "EncodedCommand decoded to an empty payload") + : (payload, shell, null); + } + catch (FormatException) + { + return ("", shell, "EncodedCommand could not be decoded"); + } + } + private static List SplitTopLevelCommands(string command) { var parts = new List(); diff --git a/src/OpenClaw.Shared/GatewayLkg.cs b/src/OpenClaw.Shared/GatewayLkg.cs new file mode 100644 index 000000000..9b91898f4 --- /dev/null +++ b/src/OpenClaw.Shared/GatewayLkg.cs @@ -0,0 +1,39 @@ +namespace OpenClaw.Shared; + +/// +/// Last-Known-Good openclaw gateway npm package version that the tray ships with by default. +/// +/// +/// +/// These constants are the single source of truth at compile time for the gateway version +/// the tray installs during local setup. They are baked into consuming assemblies (because +/// they are const) so a tampered gateway-lkg.json after install cannot +/// silently change behavior. +/// +/// +/// Source of truth for tooling (CI auto-bump workflow, dev scripts) is the +/// gateway-lkg.json file at the repo root. A unit test +/// (GatewayLkgTests) enforces that the JSON file and these constants agree, so +/// drift fails the build. +/// +/// +/// Runtime override: set the OPENCLAW_GATEWAY_VERSION environment variable before +/// launching the tray to install a different gateway version (e.g. "latest" or a +/// specific version like "2026.5.18"). Useful for CI matrix runs and hands-on +/// validation; the LKG version remains the default for unattended user installs. +/// +/// +public static class GatewayLkg +{ + /// npm package version string for the LKG gateway. + public const string Version = "2026.5.18"; + + /// ISO-8601 UTC timestamp at which this version was last verified by CI. + public const string VerifiedAt = "2026-05-20T04:50:00Z"; + + /// Tray git ref (commit SHA or tag) that verified this version. + public const string VerifiedTrayRef = "spike-run-26138294682"; + + /// Environment variable name to override the LKG version at runtime. + public const string VersionOverrideEnvironmentVariable = "OPENCLAW_GATEWAY_VERSION"; +} diff --git a/src/OpenClaw.Shared/IClock.cs b/src/OpenClaw.Shared/IClock.cs new file mode 100644 index 000000000..59520bf9c --- /dev/null +++ b/src/OpenClaw.Shared/IClock.cs @@ -0,0 +1,18 @@ +namespace OpenClaw.Shared; + +/// +/// Time abstraction for testability. Replaces DateTime.UtcNow. +/// +public interface IClock +{ + DateTime UtcNow { get; } +} + +/// +/// Production clock using . +/// +public sealed class SystemClock : IClock +{ + public static readonly SystemClock Instance = new(); + public DateTime UtcNow => DateTime.UtcNow; +} diff --git a/src/OpenClaw.Shared/IDeviceIdentityReader.cs b/src/OpenClaw.Shared/IDeviceIdentityReader.cs new file mode 100644 index 000000000..3dfe1b6fc --- /dev/null +++ b/src/OpenClaw.Shared/IDeviceIdentityReader.cs @@ -0,0 +1,20 @@ +namespace OpenClaw.Shared; + +/// +/// Reads stored device tokens from a DeviceIdentity file without requiring +/// a full DeviceIdentity instance. Enables testable credential resolution. +/// +public interface IDeviceIdentityReader +{ + /// + /// Try to read the stored operator device token from the identity file at the given path. + /// Returns null if no token is stored or the file doesn't exist. + /// + string? TryReadStoredDeviceToken(string dataPath); + + /// + /// Try to read the stored node device token from the identity file at the given path. + /// Returns null if no token is stored or the file doesn't exist. + /// + string? TryReadStoredNodeDeviceToken(string dataPath); +} diff --git a/src/OpenClaw.Shared/IDeviceIdentityStore.cs b/src/OpenClaw.Shared/IDeviceIdentityStore.cs new file mode 100644 index 000000000..a0be35807 --- /dev/null +++ b/src/OpenClaw.Shared/IDeviceIdentityStore.cs @@ -0,0 +1,31 @@ +namespace OpenClaw.Shared; + +/// +/// Interface for storing device tokens to the identity file. +/// Decouples token persistence from the gateway/node clients. +/// +public interface IDeviceIdentityStore +{ + /// Store a device token for the given role at the specified identity path. + void StoreToken(string identityPath, string token, string[]? scopes, string role); +} + +/// +/// Production implementation that delegates to . +/// +public sealed class DeviceIdentityFileStore : IDeviceIdentityStore +{ + private readonly IOpenClawLogger _logger; + + public DeviceIdentityFileStore(IOpenClawLogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public void StoreToken(string identityPath, string token, string[]? scopes, string role) + { + var identity = new DeviceIdentity(identityPath, _logger); + identity.Initialize(); + identity.StoreDeviceTokenForRole(role, token, scopes); + } +} diff --git a/src/OpenClaw.Shared/IFileSystem.cs b/src/OpenClaw.Shared/IFileSystem.cs new file mode 100644 index 000000000..286b86589 --- /dev/null +++ b/src/OpenClaw.Shared/IFileSystem.cs @@ -0,0 +1,31 @@ +namespace OpenClaw.Shared; + +/// +/// Filesystem abstraction for testability. Production uses . +/// +public interface IFileSystem +{ + bool FileExists(string path); + string ReadAllText(string path); + void WriteAllText(string path, string content); + void CreateDirectory(string path); + bool DirectoryExists(string path); + void CopyFile(string source, string destination, bool overwrite); +} + +/// +/// Production filesystem implementation delegating to +/// and . +/// +public sealed class RealFileSystem : IFileSystem +{ + public static readonly RealFileSystem Instance = new(); + + public bool FileExists(string path) => File.Exists(path); + public string ReadAllText(string path) => File.ReadAllText(path); + public void WriteAllText(string path, string content) => File.WriteAllText(path, content); + public void CreateDirectory(string path) => Directory.CreateDirectory(path); + public bool DirectoryExists(string path) => Directory.Exists(path); + public void CopyFile(string source, string destination, bool overwrite) => + File.Copy(source, destination, overwrite); +} diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs new file mode 100644 index 000000000..2d59f0389 --- /dev/null +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -0,0 +1,114 @@ +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// Read-only facade for the operator gateway client. +/// Exposes data events and request methods needed by UI consumers +/// without exposing connection lifecycle methods (connect/disconnect/dispose). +/// +public interface IOperatorGatewayClient +{ + // ─── Data Events ─── + event EventHandler? NotificationReceived; + event EventHandler? ActivityChanged; + event EventHandler? ChannelHealthUpdated; + event EventHandler? SessionsUpdated; + event EventHandler? UsageUpdated; + event EventHandler? UsageStatusUpdated; + event EventHandler? UsageCostUpdated; + event EventHandler? NodesUpdated; + event EventHandler? SessionPreviewUpdated; + event EventHandler? SessionCommandCompleted; + event EventHandler? GatewaySelfUpdated; + event EventHandler? CronListUpdated; + event EventHandler? CronStatusUpdated; + event EventHandler? CronRunsUpdated; + event EventHandler? SkillsStatusUpdated; + event EventHandler? ConfigUpdated; + event EventHandler? ConfigSchemaUpdated; + event EventHandler? AgentEventReceived; + event EventHandler? NodePairListUpdated; + event EventHandler? DevicePairListUpdated; + event EventHandler? ModelsListUpdated; + event EventHandler? PresenceUpdated; + event EventHandler? AgentsListUpdated; + event EventHandler? AgentFilesListUpdated; + event EventHandler? AgentFileContentUpdated; + event EventHandler? ChatEventReceived; + + // ─── Query ─── + string? OperatorDeviceId { get; } + IReadOnlyList GrantedOperatorScopes { get; } + bool IsConnectedToGateway { get; } + /// Canonical main session key resolved from hello-ok; null until handshake. + string? MainSessionKey { get; } + /// True once the hello-ok handshake has been processed. + bool HasHandshakeSnapshot { get; } + + // ─── Connection events (from WebSocketClientBase) ─── + event EventHandler? StatusChanged; + event EventHandler? AuthenticationFailed; + event EventHandler? DeviceTokenReceived; + event EventHandler? HandshakeSucceeded; + + // ─── Configuration ─── + void SetUserRules(IReadOnlyList? rules); + void SetPreferStructuredCategories(bool value); + + // ─── Request Methods ─── + Task SendChatMessageAsync(string message, string? sessionKey = null); + Task SendChatMessageForRunAsync(string message, string? sessionKey = null); + Task CheckHealthAsync(); + Task RequestSessionsAsync(string? agentId = null); + Task RequestUsageAsync(); + Task RequestNodesAsync(); + Task RequestUsageStatusAsync(); + Task RequestUsageCostAsync(int days = 30); + Task RequestSessionPreviewAsync(string[] keys, int limit = 12, int maxChars = 240); + Task PatchSessionAsync(string key, string? model = null, string? thinkingLevel = null, string? verboseLevel = null); + Task ResetSessionAsync(string key); + Task DeleteSessionAsync(string key, bool deleteTranscript = true); + Task CompactSessionAsync(string key, int maxLines = 400); + Task RequestCronListAsync(); + Task RequestCronStatusAsync(); + Task RunCronJobAsync(string jobId, bool force = true); + Task RemoveCronJobAsync(string jobId); + Task AddCronJobAsync(object jobDefinition); + Task UpdateCronJobAsync(string id, object patch); + Task RequestCronRunsAsync(string? id = null, int limit = 20, int offset = 0); + Task RequestSkillsStatusAsync(string? agentId = null); + Task InstallSkillAsync(string skillId); + Task SetSkillEnabledAsync(string skillKey, bool enabled); + Task RequestConfigAsync(); + Task RequestConfigSchemaAsync(); + Task SetConfigAsync(string path, object value); + Task PatchConfigAsync(JsonElement fullConfig, string? baseHash); + /// Response-aware variant of : awaits the gateway's reply and returns the real error on failure. + Task PatchConfigDetailedAsync(JsonElement fullConfig, string? baseHash, int timeoutMs = 15000); + Task RequestAgentsListAsync(); + Task RequestAgentFilesListAsync(string agentId = "main"); + Task RequestAgentFileGetAsync(string agentId, string name); + Task RequestModelsListAsync(); + Task RequestNodePairListAsync(); + Task NodePairApproveAsync(string requestId); + Task NodePairRejectAsync(string requestId); + Task NodePairRemoveAsync(string nodeId); + Task NodeRenameAsync(string nodeId, string displayName); + Task RequestDevicePairListAsync(); + Task DevicePairApproveAsync(string requestId); + Task DevicePairRejectAsync(string requestId); + Task StartChannelAsync(string channelName); + /// Start a channel and return the full gateway response so the page can detect "unknown channel" (plugin not loaded). + Task StartChannelDetailedAsync(string channelName, int timeoutMs = 12000); + Task StopChannelAsync(string channelName); + /// Fetch the rich channels.status snapshot from the gateway. Mac/web canonical wire method. + Task GetChannelsStatusAsync(bool probe = false, int timeoutMs = 12000); + /// Log out / unlink a channel (whatsapp, telegram). Sends channels.logout { channel }. + Task LogoutChannelAsync(string channelName, int timeoutMs = 12000); + /// Begin a QR linking flow (whatsapp, signal). Sends web.login.start { force, timeoutMs }. + Task WebLoginStartAsync(bool force = false, int timeoutMs = 30000); + /// Long-poll for QR linking completion. Sends web.login.wait { currentQrDataUrl, timeoutMs }. + Task WebLoginWaitAsync(string? currentQrDataUrl = null, int timeoutMs = 30000); + Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000); +} diff --git a/src/OpenClaw.Shared/InstanceMerger.cs b/src/OpenClaw.Shared/InstanceMerger.cs new file mode 100644 index 000000000..61efc5aca --- /dev/null +++ b/src/OpenClaw.Shared/InstanceMerger.cs @@ -0,0 +1,424 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenClaw.Shared; + +/// +/// Pure merge of presence beacons (broad, all platforms) with gateway node-list entries +/// (rich, Windows-paired only) into one ordered list for the Instances UI. +/// +/// +/// Match strategy is intentionally conservative — destructive actions (Rename/Forget) +/// must never be attached to the wrong row. The fallback to host/displayName matching +/// only applies when both sides are unambiguous on those values. +/// +public static class InstanceMerger +{ + public static IReadOnlyList Merge( + IReadOnlyList? nodes, + IReadOnlyList? presence, + InstanceMergeOptions? options = null) + { + options ??= new InstanceMergeOptions(); + var nowUtc = options.NowUtc?.Invoke() ?? DateTime.UtcNow; + + var nodesList = DedupeNodes(nodes); + var presenceList = presence is null + ? new List() + : presence.Where(p => p is not null).ToList(); + + var unmatchedNodes = new HashSet(nodesList); + var nodeByIdKey = BuildNodeIdIndex(nodesList); + var nodeByDisplayName = BuildNodeDisplayNameIndex(nodesList); + + var hostCounts = CountNormalized(presenceList.Select(p => p.Host)); + var displayCounts = CountNormalized(nodesList.Select(n => n.DisplayName)); + + var rows = new List(presenceList.Count + unmatchedNodes.Count); + + // Two-pass match: do all strong (DeviceId/InstanceId → NodeId/ClientId) + // matches FIRST, then weak (host → displayName) fallbacks on what's + // left. Without this split, an earlier presence that matches a node by + // host would consume that node from the indexes — and a later presence + // entry with a strong DeviceId match for the same node would miss it + // and render presence-only, losing the Rename/Forget surface. + var matchByPresenceIndex = new GatewayNodeInfo?[presenceList.Count]; + var strongMatchByPresenceIndex = new bool[presenceList.Count]; + + for (int i = 0; i < presenceList.Count; i++) + { + var matched = TryStrongMatch(presenceList[i], nodeByIdKey); + if (matched is not null) + { + unmatchedNodes.Remove(matched); + RemoveNodeFromIndexes(matched, nodeByIdKey, nodeByDisplayName); + matchByPresenceIndex[i] = matched; + strongMatchByPresenceIndex[i] = true; + } + } + + for (int i = 0; i < presenceList.Count; i++) + { + if (matchByPresenceIndex[i] is not null) continue; + var matched = TryWeakMatch(presenceList[i], nodeByDisplayName, hostCounts, displayCounts); + if (matched is not null) + { + unmatchedNodes.Remove(matched); + RemoveNodeFromIndexes(matched, nodeByIdKey, nodeByDisplayName); + matchByPresenceIndex[i] = matched; + } + } + + for (int i = 0; i < presenceList.Count; i++) + { + rows.Add(BuildFromPresence( + presenceList[i], + matchByPresenceIndex[i], + strongMatchByPresenceIndex[i], + nowUtc, + options)); + } + + foreach (var orphan in unmatchedNodes) + { + options.OnUnmatchedNode?.Invoke( + $"node.list entry without matching presence: " + + $"nodeId={orphan.NodeId} clientId={orphan.ClientId} " + + $"displayName={orphan.DisplayName} platform={orphan.Platform}"); + rows.Add(BuildFromOrphanNode(orphan, nowUtc, options)); + } + + return SortStable(rows, nowUtc); + } + + /// + /// Drops null entries and collapses duplicate node-list rows that point at + /// the same identity (same normalised NodeId or ClientId) so the gateway + /// echoing a node twice does not become two offline cards. Tracks NodeId + /// and ClientId in separate sets so a NodeId value cannot accidentally + /// suppress a different node that happens to share it as ClientId. + /// + private static List DedupeNodes(IReadOnlyList? nodes) + { + if (nodes is null) return new List(); + var result = new List(nodes.Count); + var seenNodeIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenClientIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var n in nodes) + { + if (n is null) continue; + var nodeKey = Normalize(n.NodeId); + var clientKey = Normalize(n.ClientId); + if (nodeKey.Length > 0 && seenNodeIds.Contains(nodeKey)) continue; + if (clientKey.Length > 0 && seenClientIds.Contains(clientKey)) continue; + if (nodeKey.Length > 0) seenNodeIds.Add(nodeKey); + if (clientKey.Length > 0) seenClientIds.Add(clientKey); + result.Add(n); + } + return result; + } + + private static void RemoveNodeFromIndexes( + GatewayNodeInfo node, + Dictionary nodeByIdKey, + Dictionary nodeByDisplayName) + { + RemoveIfMatches(nodeByIdKey, node.NodeId, node); + RemoveIfMatches(nodeByIdKey, node.ClientId, node); + RemoveIfMatches(nodeByDisplayName, node.DisplayName, node); + } + + private static void RemoveIfMatches( + Dictionary map, + string? key, + GatewayNodeInfo node) + { + var k = Normalize(key); + if (k.Length == 0) return; + // Only remove the entry if it still points at the same node — a later + // node with a colliding key (which AddIfMissing would have skipped) + // never reached the index in the first place. + if (map.TryGetValue(k, out var existing) && ReferenceEquals(existing, node)) + { + map.Remove(k); + } + } + + private static Dictionary BuildNodeIdIndex(IReadOnlyList nodes) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var n in nodes) + { + AddIfMissing(map, n.NodeId, n); + AddIfMissing(map, n.ClientId, n); + } + return map; + } + + private static Dictionary BuildNodeDisplayNameIndex(IReadOnlyList nodes) + { + // Only the first node per (case-insensitive) display name is kept; uniqueness + // is gated separately at match time via displayCounts. + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var n in nodes) + { + AddIfMissing(map, n.DisplayName, n); + } + return map; + } + + private static void AddIfMissing(Dictionary map, string? key, GatewayNodeInfo node) + { + var k = Normalize(key); + if (k.Length == 0) return; + if (!map.ContainsKey(k)) map[k] = node; + } + + /// + /// Strong match: exact DeviceId / InstanceId against NodeId / ClientId + /// (both folded into ). Only this kind of + /// match should attach Rename/Forget to a presence row. + /// + private static GatewayNodeInfo? TryStrongMatch( + PresenceEntry p, + Dictionary nodeByIdKey) + { + if (TryGet(nodeByIdKey, p.DeviceId, out var n1)) return n1; + if (TryGet(nodeByIdKey, p.InstanceId, out var n2)) return n2; + return null; + } + + /// + /// Weak fallback: host ↔ display-name fuzzy match, gated by uniqueness on + /// BOTH sides. Runs only after all strong matches have consumed their + /// nodes so a host-collision can never steal a node from its true owner. + /// + private static GatewayNodeInfo? TryWeakMatch( + PresenceEntry p, + Dictionary nodeByDisplayName, + Dictionary hostCounts, + Dictionary displayCounts) + { + var hostKey = Normalize(p.Host); + if (hostKey.Length > 0 && + hostCounts.TryGetValue(hostKey, out var hc) && hc == 1 && + displayCounts.TryGetValue(hostKey, out var dc) && dc == 1 && + nodeByDisplayName.TryGetValue(hostKey, out var nh)) + { + return nh; + } + return null; + } + + private static bool TryGet(Dictionary map, string? key, out GatewayNodeInfo node) + { + var k = Normalize(key); + if (k.Length > 0 && map.TryGetValue(k, out var found)) + { + node = found; + return true; + } + node = null!; + return false; + } + + private static MergedInstance BuildFromPresence( + PresenceEntry p, + GatewayNodeInfo? node, + bool isStrongNodeMatch, + DateTime nowUtc, + InstanceMergeOptions options) + { + var status = ClassifyPresence(p, nowUtc, options); + var isGateway = string.Equals(p.Mode?.Trim(), "gateway", StringComparison.OrdinalIgnoreCase); + if (isGateway) status = PresenceStatus.Gateway; + + var key = StableKey(node?.NodeId, p.DeviceId, p.InstanceId, p.Host, p.Ip); + return new MergedInstance + { + Key = key, + Presence = p, + Node = node, + CanManageNode = node is not null && isStrongNodeMatch, + Status = status, + IsGateway = isGateway, + IsThisInstance = !isGateway && IsLocalIdentity(node, p, options), + DisplayName = node?.DisplayName is { Length: > 0 } dn ? dn : p.DisplayName, + Ip = p.Ip ?? node?.RemoteIp, + Version = p.Version ?? node?.Version, + Platform = p.Platform ?? node?.Platform, + DeviceFamily = p.DeviceFamily ?? node?.DeviceFamily, + ModelIdentifier = p.ModelIdentifier ?? node?.ModelIdentifier, + Mode = p.Mode ?? node?.Mode, + LastInputSeconds = p.LastInputSeconds, + Reason = p.Reason ?? node?.LastSeenReason, + Timestamp = p.Ts > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(p.Ts).UtcDateTime : node?.LastSeen, + CapabilityCount = node?.CapabilityCount ?? 0, + CommandCount = node?.CommandCount ?? 0, + Roles = p.Roles is { Length: > 0 } r ? r : Array.Empty(), + IdentityCaption = p.InstanceId ?? p.DeviceId ?? node?.NodeId, + NodeStatusRaw = NormalizeStatus(node?.Status), + DebugText = p.Text, + }; + } + + private static MergedInstance BuildFromOrphanNode( + GatewayNodeInfo node, + DateTime nowUtc, + InstanceMergeOptions options) + { + var key = StableKey(node.NodeId, node.ClientId, displayName: node.DisplayName); + return new MergedInstance + { + Key = key, + Presence = null, + Node = node, + CanManageNode = true, + Status = PresenceStatus.Offline, + IsGateway = false, + IsThisInstance = IsLocalIdentity(node, presence: null, options), + DisplayName = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName, + Ip = node.RemoteIp, + Version = node.Version, + Platform = node.Platform, + DeviceFamily = node.DeviceFamily, + ModelIdentifier = node.ModelIdentifier, + Mode = node.Mode, + LastInputSeconds = null, + Reason = node.LastSeenReason, + Timestamp = node.LastSeen, + CapabilityCount = node.CapabilityCount, + CommandCount = node.CommandCount, + Roles = Array.Empty(), + IdentityCaption = node.NodeId, + NodeStatusRaw = NormalizeStatus(node.Status), + DebugText = null, + }; + } + + /// + /// Normalize the raw node status — return null when the value carries no extra + /// signal beyond what PresenceStatus already conveys (e.g. blank, "unknown", + /// "online" for an active row). + /// + private static string? NormalizeStatus(string? status) + { + var n = Normalize(status); + if (n.Length == 0) return null; + if (string.Equals(n, "unknown", StringComparison.OrdinalIgnoreCase)) return null; + if (string.Equals(n, "online", StringComparison.OrdinalIgnoreCase)) return null; + return n; + } + + private static PresenceStatus ClassifyPresence( + PresenceEntry p, + DateTime nowUtc, + InstanceMergeOptions options) + { + if (p.Ts <= 0) return PresenceStatus.Stale; + var beaconUtc = DateTimeOffset.FromUnixTimeMilliseconds(p.Ts).UtcDateTime; + var age = nowUtc - beaconUtc; + if (age < TimeSpan.Zero) age = TimeSpan.Zero; + if (age <= options.ActiveThreshold) return PresenceStatus.Active; + if (age <= options.IdleThreshold) return PresenceStatus.Idle; + return PresenceStatus.Stale; + } + + private static bool IsLocalIdentity(GatewayNodeInfo? node, PresenceEntry? presence, InstanceMergeOptions options) + { + var localId = Normalize(options.LocalNodeId); + var localHost = Normalize(options.LocalHost); + + if (localId.Length > 0) + { + if (node is not null && + (string.Equals(localId, Normalize(node.NodeId), StringComparison.OrdinalIgnoreCase) || + string.Equals(localId, Normalize(node.ClientId), StringComparison.OrdinalIgnoreCase))) + return true; + if (presence is not null && + (string.Equals(localId, Normalize(presence.DeviceId), StringComparison.OrdinalIgnoreCase) || + string.Equals(localId, Normalize(presence.InstanceId), StringComparison.OrdinalIgnoreCase))) + return true; + } + + if (localHost.Length > 0 && presence is not null && + string.Equals(localHost, Normalize(presence.Host), StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static List SortStable(List rows, DateTime nowUtc) + { + return rows + .Select((r, i) => (r, i)) + .OrderBy(t => SortBucket(t.r)) + .ThenBy(t => t.r.DisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(t => t.i) + .Select(t => t.r) + .ToList(); + } + + private static int SortBucket(MergedInstance r) => r switch + { + { IsGateway: true } => 0, + { IsThisInstance: true } => 1, + { Status: PresenceStatus.Active } => 2, + { Status: PresenceStatus.Idle } => 3, + { Status: PresenceStatus.Stale } => 4, + { Status: PresenceStatus.Offline } => 5, + _ => 6, + }; + + private static Dictionary CountNormalized(IEnumerable values) + { + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var v in values) + { + var k = Normalize(v); + if (k.Length == 0) continue; + counts[k] = counts.TryGetValue(k, out var c) ? c + 1 : 1; + } + return counts; + } + + private static string Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + + /// + /// Builds a deterministic identity string for a merged row. Used as the + /// row's for diffing / animation hooks + /// callers may add later. The key composes available identifiers in + /// priority order so two rows that share only a host (e.g. behind NAT) or + /// only an IP do not collide. + /// + internal static string StableKey( + string? nodeId = null, + string? deviceId = null, + string? instanceId = null, + string? host = null, + string? ip = null, + string? displayName = null) + { + // Strongest identifiers first — any one of these is sufficient. + var strong = Normalize(nodeId); + if (strong.Length > 0) return "n:" + strong; + strong = Normalize(deviceId); + if (strong.Length > 0) return "d:" + strong; + strong = Normalize(instanceId); + if (strong.Length > 0) return "i:" + strong; + + // Weak identifiers — combine all that are present so two rows that + // share, say, a host name but live on different IPs remain distinct. + var parts = new List(3); + var h = Normalize(host); + var dn = Normalize(displayName); + var i = Normalize(ip); + if (h.Length > 0) parts.Add("h:" + h); + if (dn.Length > 0) parts.Add("dn:" + dn); + if (i.Length > 0) parts.Add("ip:" + i); + if (parts.Count > 0) return string.Join("|", parts); + + return "instance"; + } +} diff --git a/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs b/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs new file mode 100644 index 000000000..bfe1270f2 --- /dev/null +++ b/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs @@ -0,0 +1,25 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// Shared literal-host classifier for gateway URLs that point at the local machine. +/// +public static class LocalGatewayUrlClassifier +{ + public static bool IsLocalGatewayUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + + try + { + var uri = new Uri(url); + var host = uri.Host.ToLowerInvariant(); + return host is "localhost" or "127.0.0.1" or "::1" or "[::1]"; + } + catch + { + return false; + } + } +} diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index e6ce348ae..2bae20005 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -236,9 +236,18 @@ private object HandleToolsList() ["camera.clip"] = "Record a short clip from a camera. Args: deviceId (string, optional), durationMs (int, required, max 60000), format ('mp4'|'webm', default 'mp4'), maxWidth (int, default 1280). Returns { format, durationMs, base64 }.", + // stt.* — microphone capture → text. Default-off; privacy-sensitive. + // Single engine: Whisper.net runs locally on the device. + ["stt.transcribe"] = + "Capture microphone audio for a bounded duration and return the transcribed text. Args: maxDurationMs (int, required, > 0, max 30000), language (string, optional BCP-47 tag like 'en-US' or 'auto' — falls back to the configured SttLanguage setting). Returns { transcribed, text, durationMs, language, engineEffective ('whisper') }. Whisper model is downloaded on first use; until then this returns an error pointing to Voice Settings. Requires NodeSttEnabled.", + ["stt.listen"] = + "Capture microphone audio with voice-activity detection and return when the user stops speaking, or after timeoutMs. Args: timeoutMs (int, optional, default 30000, range 1000..120000), language (string, optional BCP-47 tag or 'auto', default 'auto'). Returns { text, language, durationMs, segments[{ text, startMs, endMs }], engineEffective ('whisper') }. Result is the full silence-bounded utterance (all Whisper segments concatenated), not a partial first segment. Requires NodeSttEnabled.", + ["stt.status"] = + "Report STT engine readiness. No args. Returns { engine ('whisper'), readiness ('ready'|'initializing'|'model-downloading'|'model-not-downloaded'|'unavailable'), modelDownloadProgress (0..1 or null), isListenWithVadSupported (bool), isBoundedTranscribeSupported (bool) }. Carries no PII (no transcript history, no language history, no device IDs, no model paths).", + // tts.* ["tts.speak"] = - "Speak text aloud on the Windows node. Args: text (string, required), provider ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.", + "Speak text aloud on the Windows node. Args: text (string, required), provider ('piper'|'windows'|'elevenlabs', optional — falls back to the configured TtsProvider setting, default 'piper' for fresh installs), voiceId (string, optional — overrides the per-provider configured voice), model (string, optional, ElevenLabs only), interrupt (bool, default false — interrupts any in-progress playback). Returns { spoken, provider, contentType, durationMs }.", // app.* ["app.navigate"] = diff --git a/src/OpenClaw.Shared/MenuSizingHelper.cs b/src/OpenClaw.Shared/MenuSizingHelper.cs index 72db271c7..9eb67a8ec 100644 --- a/src/OpenClaw.Shared/MenuSizingHelper.cs +++ b/src/OpenClaw.Shared/MenuSizingHelper.cs @@ -5,6 +5,8 @@ namespace OpenClaw.Shared; /// public static class MenuSizingHelper { + private const double ScaleTolerance = 0.001; + public static int ConvertPixelsToViewUnits(int pixels, uint dpi) { if (pixels <= 0) return 0; @@ -13,6 +15,19 @@ public static int ConvertPixelsToViewUnits(int pixels, uint dpi) return Math.Max(1, (int)Math.Floor(pixels * 96.0 / dpi)); } + public static bool HasDpiOrScaleChanged(uint previousDpi, double previousRasterizationScale, uint currentDpi, double currentRasterizationScale) + { + previousDpi = NormalizeDpi(previousDpi); + currentDpi = NormalizeDpi(currentDpi); + + if (previousDpi != currentDpi) + return true; + + var previousScale = NormalizeScale(previousRasterizationScale); + var currentScale = NormalizeScale(currentRasterizationScale); + return Math.Abs(previousScale - currentScale) > ScaleTolerance; + } + public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100) { if (contentHeight < 0) contentHeight = 0; @@ -25,4 +40,9 @@ public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, i var desiredHeight = Math.Max(contentHeight, minimumVisibleHeight); return Math.Min(desiredHeight, workAreaHeight); } + + private static uint NormalizeDpi(uint dpi) => dpi == 0 ? 96u : dpi; + + private static double NormalizeScale(double scale) => + double.IsFinite(scale) && scale > 0 ? scale : 1.0; } diff --git a/src/OpenClaw.Shared/MergedInstance.cs b/src/OpenClaw.Shared/MergedInstance.cs new file mode 100644 index 000000000..4b503d48c --- /dev/null +++ b/src/OpenClaw.Shared/MergedInstance.cs @@ -0,0 +1,138 @@ +namespace OpenClaw.Shared; + +/// +/// Status bucket for a , drives the colored presence dot +/// in the Instances page. Mirrors thresholds in macOS InstancesSettings.swift. +/// +public enum PresenceStatus +{ + /// Online and seen within the active window (default ≤120s). + Active, + + /// Online but past the active window (default ≤300s). + Idle, + + /// Presence beacon exists but is past the idle window. + Stale, + + /// Node is paired with the gateway but no presence beacon was received. + Offline, + + /// Row represents the gateway itself — status dot is suppressed in UI. + Gateway, +} + +/// +/// A unified row for the Instances page. Combines a +/// (broad: gateway + every connected platform) with an optional +/// (rich: paired Windows nodes only). +/// +/// +/// Either or is always non-null. When both are +/// set, the row represents a paired Windows node that is currently online — the page may +/// expose Rename/Forget and capability/command/permission expanders. When only +/// is set, the node is paired-but-offline and we still render a row +/// so the user can manage (rename/forget) it. +/// +public sealed class MergedInstance +{ + /// + /// Stable identity used as dictionary key for state preservation across re-renders + /// (e.g. remembering which Manage expanders are open). Falls back through + /// NodeId → DeviceId → InstanceId → Host|Ip so something is always non-empty. + /// + public required string Key { get; init; } + + public PresenceEntry? Presence { get; init; } + + public GatewayNodeInfo? Node { get; init; } + + public PresenceStatus Status { get; init; } + + /// True when this row represents the gateway itself (mode == "gateway"). + public bool IsGateway { get; init; } + + /// True when this row matches the local OpenClaw tray's own node identity. + public bool IsThisInstance { get; init; } + + /// True when a Manage expander (rename/forget/caps/etc.) should be available. + public bool IsManaged => CanManageNode; + + /// + /// True when node management actions are safe for this row. Weak host/display-name + /// matches may carry node data for display enrichment, but must not expose Rename/Forget. + /// + public bool CanManageNode { get; init; } + + public string DisplayName { get; init; } = ""; + public string? Ip { get; init; } + public string? Version { get; init; } + public string? Platform { get; init; } + public string? DeviceFamily { get; init; } + public string? ModelIdentifier { get; init; } + public string? Mode { get; init; } + public int? LastInputSeconds { get; init; } + public string? Reason { get; init; } + public DateTime? Timestamp { get; init; } + + /// Capability count for paired Windows nodes (from node.list). 0 when unknown. + public int CapabilityCount { get; init; } + + /// Command count for paired Windows nodes (from node.list). 0 when unknown. + public int CommandCount { get; init; } + + /// + /// Roles asserted by the connected client (e.g. ["operator","node"]). Sourced + /// from . Empty when not provided. + /// + public IReadOnlyList Roles { get; init; } = Array.Empty(); + + /// + /// Best-effort identifier for the row, shown as the small monospace caption + /// under the metadata line. Prefers , + /// then , then . + /// + public string? IdentityCaption { get; init; } + + /// + /// Raw protocol status string from (e.g. + /// "online", "pairing"). Surfaced as a supplementary caption when meaningful + /// (i.e. non-empty and not redundant with the computed PresenceStatus). + /// + public string? NodeStatusRaw { get; init; } + + /// Original debug text from the presence beacon (for copy-debug context menu). + public string? DebugText { get; init; } +} + +/// +/// Options controlling behavior. +/// +public sealed class InstanceMergeOptions +{ + /// + /// Stable local node identity (e.g. from the registered gateway record). Preferred + /// over when set — avoids hostname collisions in multi-machine + /// scenarios with the same Windows hostname. + /// + public string? LocalNodeId { get; init; } + + /// Local machine hostname; fallback when is null. + public string? LocalHost { get; init; } + + /// Presence ≤ this age is Active. Default 120s (matches macOS). + public TimeSpan ActiveThreshold { get; init; } = TimeSpan.FromSeconds(120); + + /// Presence ≤ this age is Idle. Default 300s (matches macOS). + public TimeSpan IdleThreshold { get; init; } = TimeSpan.FromSeconds(300); + + /// + /// Optional debug hook invoked once per that fails to + /// match any presence row. The string is a one-line summary safe to log. + /// Useful for surfacing ID-shape drift between presence beacons and node.list payloads. + /// + public Action? OnUnmatchedNode { get; init; } + + /// Reference clock; overridable in tests. Defaults to . + public Func? NowUtc { get; init; } +} diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 6c0ceecec..7223077af 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -27,12 +27,14 @@ public class PairingStatusEventArgs : EventArgs public PairingStatus Status { get; } public string DeviceId { get; } public string? Message { get; } + public string? RequestId { get; } - public PairingStatusEventArgs(PairingStatus status, string deviceId, string? message = null) + public PairingStatusEventArgs(PairingStatus status, string deviceId, string? message = null, string? requestId = null) { Status = status; DeviceId = deviceId; Message = message; + RequestId = requestId; } } @@ -489,6 +491,32 @@ public class GatewayNodeInfo public List DisabledCommands { get; set; } = new(); public Dictionary Permissions { get; set; } = new(StringComparer.OrdinalIgnoreCase); + // Identity / hardware (from gateway NodeListNode schema) + public string? Version { get; set; } + public string? CoreVersion { get; set; } + public string? UiVersion { get; set; } + public string? ClientId { get; set; } + public string? ClientMode { get; set; } + public string? DeviceFamily { get; set; } + public string? ModelIdentifier { get; set; } + public string? RemoteIp { get; set; } + public string? PathEnv { get; set; } + + // Timestamps and state + public DateTime? ConnectedAt { get; set; } + public DateTime? ApprovedAt { get; set; } + public string? LastSeenReason { get; set; } + + // True when the node is in the gateway's paired set (regardless of current + // connection state). Distinct from IsOnline — a paired node can be offline. + public bool IsPaired { get; set; } + + // True when the gateway provided an explicit displayName/name/label. + // False when the parser had to fall back to shortId or nodeId. UI surfaces + // (e.g. the rename dialog) use this to distinguish "the user gave this + // node a name" from "we showed the id because there was nothing better". + public bool HasExplicitDisplayName { get; set; } + public string ShortId => NodeId.Length <= 12 ? NodeId : NodeId[..12] + "…"; public string DisplayText @@ -520,6 +548,26 @@ public string DetailText private static string FormatAge(DateTime timestampUtc) => ModelFormatting.FormatAge(timestampUtc); } +/// +/// Result of a node.rename request to the gateway. +/// +/// True when the gateway accepted the rename and persisted it. +/// Node id the gateway returned; same as the requested id on success. +/// Updated display name as persisted by the gateway. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeRenameResult( + bool Success, + string? NodeId = null, + string? DisplayName = null, + string? ErrorMessage = null); + +/// +/// Result of a node.pair.remove request to the gateway. +/// +/// True when the gateway accepted the removal. +/// Gateway-supplied or transport-derived error description; null on success. +public sealed record NodeForgetResult(bool Success, string? ErrorMessage = null); + public enum GatewayDiagnosticSeverity { Info, @@ -762,7 +810,7 @@ public static List BuildDefaultWindowsMatrix() { Name = "Microphone", Status = "review", - Detail = "Required only for camera clips with audio or future voice features.", + Detail = "Required for camera clips with audio and for stt.transcribe speech-to-text capture.", SettingsUri = "ms-settings:privacy-microphone" }, new() @@ -1019,7 +1067,7 @@ public static class CommandCenterCommandGroups public static readonly FrozenSet SafeCompanionCommandSet = SafeCompanionCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - public static readonly string[] DangerousCommands = + public static readonly string[] CommonDangerousCommands = [ "camera.snap", "camera.clip", @@ -1027,6 +1075,14 @@ public static class CommandCenterCommandGroups "tts.speak" ]; + public static readonly string[] DangerousCommands = + [ + .. CommonDangerousCommands, + "stt.transcribe", + "stt.listen", + "stt.status" + ]; + public static readonly FrozenSet DangerousCommandSet = DangerousCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase); @@ -1235,7 +1291,7 @@ public static List BuildNodeWarnings(NodeCapabilityHea Severity = GatewayDiagnosticSeverity.Info, Category = "allowlist", Title = "Privacy-sensitive commands are currently blocked", - Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera or screen recording access for this node.", + Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.", RepairAction = "Copy opt-in guidance", CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands) }); @@ -1480,18 +1536,28 @@ private static string BuildLocalTunnelUrl(int localPort) => } /// Shared display-formatting helpers used by model classes. -internal static class ModelFormatting +public static class ModelFormatting { /// - /// Formats a UTC timestamp as a human-readable age string (e.g. "just now", "5m ago", "2h ago", "3d ago"). + /// Formats a UTC timestamp as a human-readable age string. + /// Examples: "just now", "5m ago", "12h ago", "3d ago", "2026-03-12". + /// Public so all UI surfaces share one canonical formatter — divergent + /// thresholds between callers can otherwise show the same timestamp as + /// "1d ago" in one place and "36h ago" in another for the same node. /// - internal static string FormatAge(DateTime timestampUtc) + public static string FormatAge(DateTime timestampUtc) { var delta = DateTime.UtcNow - timestampUtc; + // Clock skew between gateway host and local machine can produce + // timestamps slightly in the future. Treat those as "just now". + if (delta < TimeSpan.Zero) return "just now"; if (delta.TotalSeconds < 60) return "just now"; if (delta.TotalMinutes < 60) return $"{(int)delta.TotalMinutes}m ago"; if (delta.TotalHours < 48) return $"{(int)delta.TotalHours}h ago"; - return $"{(int)delta.TotalDays}d ago"; + if (delta.TotalDays < 30) return $"{(int)delta.TotalDays}d ago"; + // For very old timestamps the relative form loses meaning; show + // an absolute local date instead. + return timestampUtc.ToLocalTime().ToString("yyyy-MM-dd"); } /// @@ -1507,6 +1573,90 @@ internal static string FormatLargeNumber(long n) // ── Agent Events ── +/// +/// Chat message broadcast by the gateway via a "chat" event. Emitted for +/// both user echoes and final assistant messages. Streaming deltas are not +/// currently produced by the gateway; consumers should treat each message as +/// the complete final text for the given role. +/// +public class ChatMessageInfo +{ + /// Session this message belongs to (e.g. "main"). + public string SessionKey { get; set; } = ""; + + /// "user", "assistant", "system", etc. + public string Role { get; set; } = ""; + + /// Full text content of the message. + public string Text { get; set; } = ""; + + /// + /// Optional gateway-assigned message state. "final" indicates a complete + /// terminal message; absent or other values indicate intermediate state. + /// + public string? State { get; set; } + + /// True when the message represents a final (non-streaming) state. + public bool IsFinal => string.Equals(State, "final", StringComparison.OrdinalIgnoreCase); + + /// Unix epoch milliseconds when the gateway logged this message (0 if unknown). + public long Ts { get; set; } + + /// + /// Optional cumulative input (prompt) token count for the turn this + /// message belongs to, when the gateway includes a usage block on + /// the chat event payload. null when not reported (deltas usually + /// don't carry this; the final summary or lifecycle event does). + /// + public int? InputTokens { get; set; } + + /// Optional cumulative output token count. + public int? OutputTokens { get; set; } + + /// + /// Optional total response token count (input + output, surfaced as + /// R<n> in the assistant footer). + /// + public int? ResponseTokens { get; set; } + + /// + /// Optional percentage of model context window consumed (0–100), shown as + /// 23% ctx in the footer. + /// + public int? ContextPercent { get; set; } + + /// Gateway-assigned unique message ID from the __openclaw.id field. + public string? OpenClawId { get; set; } + + /// Monotonic sequence number within the session from __openclaw.seq. + public int? OpenClawSeq { get; set; } + + /// + /// Stop reason for assistant messages (e.g. "stop", "toolUse", possibly "abort"). + /// Only present on assistant messages in chat.history. + /// + public string? StopReason { get; set; } +} + +/// +/// Result of a chat.history RPC: the full transcript for a session. +/// The gateway already applies display normalization (strips delivery +/// directive tags, tool-call XML, control tokens, silent NO_REPLY entries, +/// reasoning-flagged payloads, and oversized messages) so consumers can +/// render the messages directly. +/// +public class ChatHistoryInfo +{ + /// Immutable session UUID assigned by the gateway. + public string? SessionId { get; set; } + + /// Session key the history was requested for (e.g. "main"). + public string SessionKey { get; set; } = ""; + + /// Ordered transcript messages (oldest first). + public IReadOnlyList Messages { get; set; } = Array.Empty(); +} + /// Raw agent event from gateway broadcast. public class AgentEventInfo { @@ -1522,12 +1672,27 @@ public class AgentEventInfo public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff"); - public string StreamUpper => Stream.ToUpperInvariant(); + /// Resolved event kind — for "item" stream events, uses data.kind instead. + public string ResolvedStream + { + get + { + var s = Stream.ToLowerInvariant(); + if (s == "item" && Data.ValueKind == JsonValueKind.Object && + Data.TryGetProperty("kind", out var k)) + { + return k.GetString()?.ToLowerInvariant() ?? s; + } + return s; + } + } + + public string StreamUpper => ResolvedStream.ToUpperInvariant(); /// Color hex for stream badge (used by UI to create brush). - public string BadgeColorHex => Stream.ToLowerInvariant() switch + public string BadgeColorHex => ResolvedStream switch { - "tool" => "#FFDC781E", // Orange + "tool" => "#FFB45D3A", // Burnt sienna "assistant" => "#FF28A050", // Green "error" => "#FFC83232", // Red "lifecycle" => "#FF3C78C8", // Blue @@ -1546,17 +1711,23 @@ public string SummaryLine if (!string.IsNullOrEmpty(Summary)) return Summary; try { - var s = Stream.ToLowerInvariant(); + var s = ResolvedStream; if (s == "tool" && Data.ValueKind == JsonValueKind.Object) { var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null; + var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null; var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null; - if (name != null) return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}"; + var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null; + // Prefer title (richer) over just name + if (title != null) + return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}"; + if (name != null) + return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}"; } if (s == "assistant" && Data.ValueKind == JsonValueKind.Object) { var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null; - if (text != null) return text.Length > 120 ? text[..120] + "…" : text; + if (text != null) return text.Length > 300 ? text[..300] + "…" : text; } if (s == "error" && Data.ValueKind == JsonValueKind.Object) { @@ -1566,8 +1737,11 @@ public string SummaryLine } if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object) { - var state = Data.TryGetProperty("state", out var st) ? st.GetString() : null; - if (state != null) return $"⚡ {state}"; + var state = Data.TryGetProperty("state", out var st) ? st.GetString() + : Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null; + var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null; + if (state != null) + return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}"; } } catch { } @@ -1577,22 +1751,62 @@ public string SummaryLine public bool HasSummary => !string.IsNullOrEmpty(SummaryLine); + /// Full assistant message text (no truncation), for expanded view. + public string? FullAssistantText + { + get + { + if (ResolvedStream != "assistant" || Data.ValueKind != JsonValueKind.Object) return null; + try { return Data.TryGetProperty("text", out var t) ? t.GetString() : null; } + catch { return null; } + } + } + + /// Whether this event is an assistant stream (expanded view shows full text instead of JSON). + public bool IsAssistantStream => ResolvedStream == "assistant"; + + /// Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient. + public bool ShowDataJson + { + get + { + var s = ResolvedStream; + if (s is "assistant" or "error" or "lifecycle") return false; + return true; + } + } + + // UI-only state for expand/collapse (not serialized) + [System.Text.Json.Serialization.JsonIgnore] + public bool IsExpanded { get; set; } + + private string? _cachedDataJson; + public string DataJson { get { + if (_cachedDataJson != null) return _cachedDataJson; try { - return JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true }); + _cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true }); } catch { - return Data.ToString() ?? "{}"; + _cachedDataJson = Data.ToString() ?? "{}"; } + return _cachedDataJson; } } } +public sealed class ChatSendResult +{ + public string? RunId { get; init; } + public string? SessionKey { get; init; } + public bool Cached { get; init; } +} + // ── Node/Device Pairing ── public class PairingRequest diff --git a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs new file mode 100644 index 000000000..21abae99e --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Abstraction for executing a capability invocation inside containment. +/// Mirrors the pattern that already exists for +/// system.run, broadened to cover any capability shape. +/// +/// +/// Implementations: +/// +/// — per-call AppContainer via Node + mxc-sdk. +/// HostFallbackExecutor — when containment unavailable in BestEffort mode. +/// +/// All implementations are expected to throw +/// when they cannot serve the request because of a missing backend (e.g. unsupported +/// Windows build, missing wxc-exec.exe). Callers in fail-closed mode translate that +/// into a denied invocation; callers in best-effort mode swap to a host runner. +/// +public interface ISandboxExecutor +{ + /// Stable identifier for telemetry and the activity-stream badge. + /// "mxc-oneshot-appc", "mxc-isosession-worker", "host-fallback" + string Name { get; } + + /// True if this executor enforces containment. False = host fallback path. + bool IsContained { get; } + + /// Execute the request inside containment. + /// + /// Thrown when the executor's backend cannot serve this request. + /// + Task ExecuteAsync( + SandboxExecutionRequest request, + CancellationToken ct = default); +} + +/// +/// Capability invocation routed through an . +/// Generic across capability shapes (shell exec, structured-data fetch, etc.). +/// +/// +/// Effective timeout in ms. Already capped by user-settings policy if applicable. +/// Pass <= 0 to let the executor use its default. +/// +/// +/// Maximum stdout/stderr the executor will return. Pass null to use the +/// executor's default (typically 4 MiB). The host capture cap and the bridge +/// cap (run-command.cjs) honor this value. +/// +public sealed record SandboxExecutionRequest( + string CapabilityCommand, + JsonElement Args, + SandboxPolicy Policy, + int TimeoutMs, + string? Cwd = null, + IReadOnlyDictionary? Env = null, + long? MaxOutputBytes = null); + +/// +/// Result of a sandboxed capability invocation. Mirrors +/// for shell-shaped invocations, and adds for +/// capability-shaped invocations whose output is JSON. +/// +public sealed record SandboxExecutionResult( + int ExitCode, + string Stdout, + string Stderr, + bool TimedOut, + long DurationMs, + string ContainmentTag, + JsonElement? StructuredResult = null); + +/// +/// Thrown by an when its backend cannot serve a +/// request (e.g. unsupported Windows build, missing wxc-exec.exe, OS feature off). +/// Caller policy decides whether to fail-closed or fall back. +/// +public sealed class SandboxUnavailableException : Exception +{ + public SandboxUnavailableException(string reason) : base(reason) { } + public SandboxUnavailableException(string reason, Exception inner) : base(reason, inner) { } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs new file mode 100644 index 000000000..9794e1dcf --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs @@ -0,0 +1,226 @@ +using Microsoft.Win32; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Per-backend availability probe for MXC. Cached for the process lifetime. +/// +/// +/// Backends checked: +/// +/// — Windows 11 build >= 26100, UBR >= 7965 (per @microsoft/mxc-sdk README), x64 / arm64. +/// — wxc-exec.exe found at the expected node_modules path or via override. +/// — requires AppContainer plus IsolationProxy.exe in System32. +/// +/// +public sealed class MxcAvailability +{ + /// + /// Optional override path for wxc-exec.exe. When set, used instead of the + /// default node_modules/@microsoft/mxc-sdk/bin/<arch>/wxc-exec.exe probe. + /// Wired through environment variable OPENCLAW_WXC_EXEC. + /// + public const string WxcExecOverrideEnvVar = "OPENCLAW_WXC_EXEC"; + + private const int MinSupportedBuild = 26100; + + /// + /// Highest base build for which an explicit UBR floor applies. Per the + /// @microsoft/mxc-sdk README, builds in [26100, 26500] require the + /// cumulative update bringing UBR ≥ 7965; builds beyond 26500 (e.g. + /// Canary / Dev channels) ship the feature natively. + /// + private const int UbrCheckMaxBuild = 26500; + private const int MinSupportedUbrInRange = 7965; + + public bool IsAppContainerAvailable { get; } + public bool IsIsolationSessionAvailable { get; } + public bool IsWxcExecResolvable { get; } + public string? WxcExecPath { get; } + + /// + /// Resolved path to tools/mxc/run-command.cjs (the productized Node bridge + /// for MxcCommandRunner). The tray build copies this under the app base + /// directory; probing intentionally does not walk parent directories so a + /// user-writable parent cannot inject a replacement bridge. + /// + public string? RunCommandScriptPath { get; } + + /// + /// Human-readable list of reasons MXC may not be available. Empty when fully supported. + /// Surface to UX so users know why the sandbox toggle is disabled. + /// + public IReadOnlyList UnsupportedReasons { get; } + + /// True iff at least one MXC backend is supported, the bridge script is found, + /// AND wxc-exec.exe is resolvable. (Without wxc-exec the executor will refuse + /// to run, so reporting "available" would lie to the UI.) + public bool HasAnyBackend => + (IsAppContainerAvailable || IsIsolationSessionAvailable) + && RunCommandScriptPath is not null + && IsWxcExecResolvable; + + public MxcAvailability( + bool isAppContainerAvailable, + bool isIsolationSessionAvailable, + bool isWxcExecResolvable, + string? wxcExecPath, + string? runCommandScriptPath, + IReadOnlyList unsupportedReasons) + { + IsAppContainerAvailable = isAppContainerAvailable; + IsIsolationSessionAvailable = isIsolationSessionAvailable; + IsWxcExecResolvable = isWxcExecResolvable; + WxcExecPath = wxcExecPath; + RunCommandScriptPath = runCommandScriptPath; + UnsupportedReasons = unsupportedReasons; + } + + /// + /// Probe the running environment. Designed to be called once at app startup + /// and the result cached. + /// + public static MxcAvailability Probe(IOpenClawLogger? logger = null) + { + var log = logger ?? NullLogger.Instance; + var reasons = new List(); + + if (!OperatingSystem.IsWindows()) + { + reasons.Add("MXC requires Windows."); + return new MxcAvailability(false, false, false, null, null, reasons); + } + + var (build, ubr) = ReadWindowsBuildAndUbr(); + var buildOk = build >= MinSupportedBuild; + // UBR floor only applies to builds in the [26100, 26500] window; + // newer Canary / Dev builds ship MXC primitives natively. + var ubrCheckApplies = build <= UbrCheckMaxBuild; + var ubrOk = !ubrCheckApplies || ubr >= MinSupportedUbrInRange; + if (!buildOk) + reasons.Add($"Windows build {build} below MXC minimum {MinSupportedBuild}."); + if (buildOk && !ubrOk) + reasons.Add( + $"Windows UBR {ubr} below MXC minimum {MinSupportedUbrInRange} " + + $"(for builds {MinSupportedBuild}-{UbrCheckMaxBuild}). " + + "Install latest cumulative update."); + + var isAppContainerSupported = buildOk && ubrOk; + + var (wxcResolvable, wxcPath) = ResolveWxcExec(); + if (!wxcResolvable) + reasons.Add($"wxc-exec.exe not found. Set {WxcExecOverrideEnvVar} or run `npm ci` at the repository root."); + + var runCommandScriptPath = ResolveRunCommandScript(); + if (runCommandScriptPath is null) + reasons.Add("tools/mxc/run-command.cjs not found in any expected location."); + + // isolation_session additionally requires Feature_IsoBrokerSessionApis on the OS + // and IsolationProxy.exe in System32. We currently only check file presence. + var isolationProxyPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "IsolationProxy.exe"); + var isIsolationSessionSupported = isAppContainerSupported + && wxcResolvable + && File.Exists(isolationProxyPath); + + log.Info( + $"[mxc] availability: appcontainer={isAppContainerSupported} " + + $"isolation_session={isIsolationSessionSupported} " + + $"wxc-exec={(wxcResolvable ? wxcPath : "")} " + + $"run-command.cjs={(runCommandScriptPath ?? "")} " + + $"reasons=[{string.Join(", ", reasons)}]"); + + return new MxcAvailability( + isAppContainerSupported, + isIsolationSessionSupported, + wxcResolvable, + wxcPath, + runCommandScriptPath, + reasons); + } + + private static (int build, int ubr) ReadWindowsBuildAndUbr() + { + var build = Environment.OSVersion.Version.Build; + var ubr = 0; + if (!OperatingSystem.IsWindows()) + return (build, ubr); + + try + { +#pragma warning disable CA1416 // OperatingSystem.IsWindows() guard above; analyzer doesn't recognize it through callee. + using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + var value = key?.GetValue("UBR"); + if (value is int ubrInt) + ubr = ubrInt; +#pragma warning restore CA1416 + } + catch + { + // Best-effort registry read; failure leaves ubr = 0 which fails the gate. + } + + return (build, ubr); + } + + private static (bool resolvable, string? path) ResolveWxcExec() + { + var overridePath = Environment.GetEnvironmentVariable(WxcExecOverrideEnvVar); + if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath)) + return (true, overridePath); + + var arch = MxcArchHelper.GetSdkArchString(); + var probeRoots = new[] + { + AppContext.BaseDirectory, + Path.GetDirectoryName(typeof(MxcAvailability).Assembly.Location) ?? string.Empty, + }; + + foreach (var root in probeRoots) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + + var candidate = Path.Combine( + root, + "node_modules", "@microsoft", "mxc-sdk", "bin", arch, "wxc-exec.exe"); + if (File.Exists(candidate)) + return (true, candidate); + } + + return (false, null); + } + + private static string? ResolveRunCommandScript() + { + var probeRoots = new[] + { + AppContext.BaseDirectory, + Path.GetDirectoryName(typeof(MxcAvailability).Assembly.Location) ?? string.Empty, + }; + + foreach (var root in probeRoots) + { + if (string.IsNullOrWhiteSpace(root)) + continue; + + var candidate = Path.Combine(root, "tools", "mxc", "run-command.cjs"); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } +} + +internal static class MxcArchHelper +{ + /// Returns "arm64" or "x64" matching the @microsoft/mxc-sdk bin/<arch>/ layout. + public static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch + { + System.Runtime.InteropServices.Architecture.Arm64 => "arm64", + System.Runtime.InteropServices.Architecture.X64 => "x64", + _ => "x64", + }; +} diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs new file mode 100644 index 000000000..f84e7180a --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs @@ -0,0 +1,189 @@ +using System.Text.Json; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Adapts the existing seam so production +/// system.run invocations get sandboxed via MXC AppContainer. +/// Plugs into SystemCapability.SetCommandRunner(...) exactly where +/// LocalCommandRunner plugs in today. +/// +/// +/// Honors : +/// +/// true (default) — sandbox via MXC; deny invocation if MXC unavailable. +/// false — bypass MXC; route through the host runner. +/// +/// There is no host-fallback path when sandbox is enabled and MXC is missing — +/// the call is denied with an explanatory error. Per user directive: "if sandbox +/// enabled, only run on sandbox." +/// +public sealed class MxcCommandRunner : ICommandRunner +{ + public string Name => "mxc"; + + private readonly ISandboxExecutor _executor; + private readonly ICommandRunner _hostFallback; + private readonly Func _settingsProvider; + private readonly Func _settingsDirectoryPathProvider; + private readonly Func _isSandboxAvailable; + private readonly Action? _invalidateAvailability; + private readonly IOpenClawLogger _logger; + + public MxcCommandRunner( + ISandboxExecutor executor, + ICommandRunner hostFallback, + Func settingsProvider, + Func settingsDirectoryPathProvider, + Func isSandboxAvailable, + Action? invalidateAvailability = null, + IOpenClawLogger? logger = null) + { + _executor = executor; + _hostFallback = hostFallback; + _settingsProvider = settingsProvider; + _settingsDirectoryPathProvider = settingsDirectoryPathProvider; + _isSandboxAvailable = isSandboxAvailable; + _invalidateAvailability = invalidateAvailability; + _logger = logger ?? NullLogger.Instance; + } + + public async Task RunAsync(CommandRequest request, CancellationToken ct = default) + { + var settings = _settingsProvider(); + + // Fail-closed when MXC is unavailable. We do NOT route to host even if the + // persisted toggle is OFF — the UI hides the toggle in that state so any + // OFF value is stale (e.g., flipped on a previous run / different machine). + // The UI's "Sandbox unavailable — commands blocked" claim must match + // actual behavior or it's a lie. + if (!_isSandboxAvailable()) + { + _logger.Warn( + "[mxc] system.run DENIED: sandbox unavailable. " + + "Update Windows or install missing components to enable."); + return new CommandResult + { + Stdout = string.Empty, + Stderr = + "Sandboxing is unavailable on this machine, so agent-started Windows " + + "commands are blocked. Open the Sandbox page for fix instructions.", + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + + if (!settings.SystemRunSandboxEnabled) + { + _logger.Info("[mxc] sandbox=disabled; routing system.run through host runner"); + return await _hostFallback.RunAsync(request, ct); + } + + var policy = MxcPolicyBuilder.ForSystemRun(settings, _settingsDirectoryPathProvider()); + var argsJson = SerializeArgs(request); + + // Compute the effective timeout: take the smaller of the agent-supplied + // timeout (request.TimeoutMs) and the user's sandbox cap (policy.TimeoutMs). + // A zero/null on either side means "no cap from that side". + var effectiveTimeoutMs = CombineTimeouts(request.TimeoutMs, policy.TimeoutMs); + + var sandboxRequest = new SandboxExecutionRequest( + CapabilityCommand: "system.run", + Args: argsJson, + Policy: policy, + TimeoutMs: effectiveTimeoutMs, + Cwd: request.Cwd, + Env: request.Env, + MaxOutputBytes: settings.SandboxMaxOutputBytes > 0 + ? settings.SandboxMaxOutputBytes + : null); + + try + { + var sandboxed = await _executor.ExecuteAsync(sandboxRequest, ct); + return new CommandResult + { + Stdout = sandboxed.Stdout, + Stderr = sandboxed.Stderr, + ExitCode = sandboxed.ExitCode, + TimedOut = sandboxed.TimedOut, + DurationMs = sandboxed.DurationMs, + }; + } + catch (SandboxUnavailableException ex) + { + // Invalidate any cached availability — what we thought was available + // turned out not to be. Next command re-probes. This handles the + // case where MXC components were uninstalled (or wxc-exec moved) + // between this NodeService starting and now. + _invalidateAvailability?.Invoke(); + + _logger.Warn( + $"[mxc] system.run DENIED (sandbox enabled but unavailable: {ex.Message}). " + + "Disable the sandbox toggle in Debug to fall back to host execution."); + return new CommandResult + { + Stdout = string.Empty, + Stderr = + "Sandboxing is enabled for system.run on this machine, but MXC is unavailable. " + + $"Reason: {ex.Message}. " + + "Update Windows or disable the system.run sandbox in the Debug page to run on host.", + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + catch (OperationCanceledException) + { + // Caller cancelled (gateway disconnect, agent abort). Propagate so the + // caller sees the cancellation rather than a fake "exited 0" response. + throw; + } + catch (Exception ex) + { + // Fail closed for ANY other error (bridge crashed, JSON malformed, IO + // failure on stdin). Returning a -1 CommandResult is what the agent + // pipeline understands — letting the exception escape here can crash + // the node loop and ultimately the tray. + _logger.Warn($"[mxc] system.run sandbox execution failed: {ex.GetType().Name}: {ex.Message}"); + return new CommandResult + { + Stdout = string.Empty, + Stderr = + "Sandboxed system.run failed with an unexpected error: " + + $"{ex.GetType().Name}: {ex.Message}", + ExitCode = -1, + TimedOut = false, + DurationMs = 0, + }; + } + } + + private static JsonElement SerializeArgs(CommandRequest request) + { + var payload = new + { + command = request.Command, + shell = request.Shell ?? "powershell", + args = request.Args ?? Array.Empty(), + cwd = request.Cwd, + env = request.Env, + timeoutMs = request.TimeoutMs, + }; + var json = JsonSerializer.Serialize(payload); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + internal static int CombineTimeouts(int agentMs, int? policyMs) + { + // Treat <= 0 as "no cap on this side." + var hasAgent = agentMs > 0; + var hasPolicy = policyMs is > 0; + if (hasAgent && hasPolicy) return Math.Min(agentMs, policyMs!.Value); + if (hasAgent) return agentMs; + if (hasPolicy) return policyMs!.Value; + return 0; + } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcEnvironment.cs b/src/OpenClaw.Shared/Mxc/MxcEnvironment.cs new file mode 100644 index 000000000..6abb2456a --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcEnvironment.cs @@ -0,0 +1,61 @@ +using System.Runtime.InteropServices; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Win32 probe for "am I currently running inside an AppContainer?". +/// No SDK API exists for this (verified in the sq-agos-tessera-process detection +/// thread). Reads the same data Task Manager's "Isolation" column reads, via +/// GetTokenInformation(TokenIsAppContainer). +/// +public static class MxcEnvironment +{ + private const int TOKEN_QUERY = 0x0008; + private const int TokenIsAppContainer = 29; + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + IntPtr tokenHandle, + int tokenInformationClass, + out uint tokenInformation, + uint tokenInformationLength, + out uint returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + /// + /// Returns true if the calling process token has TokenIsAppContainer == 1. + /// Not all sandboxes set this (LPAC, isolation_session may differ); this is a + /// best-effort probe matching MXC's confirmed AppContainer backend. + /// Returns false on non-Windows or if any Win32 call fails. + /// + public static bool IsInsideAppContainer() + { + if (!OperatingSystem.IsWindows()) + return false; + + var processHandle = GetCurrentProcess(); + if (!OpenProcessToken(processHandle, TOKEN_QUERY, out var tokenHandle)) + return false; + + try + { + if (!GetTokenInformation(tokenHandle, TokenIsAppContainer, out var isAppContainer, sizeof(uint), out _)) + return false; + + return isAppContainer != 0; + } + finally + { + CloseHandle(tokenHandle); + } + } +} diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs new file mode 100644 index 000000000..721cc4a8d --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs @@ -0,0 +1,233 @@ +namespace OpenClaw.Shared.Mxc; + +/// +/// Pure function: + capability name → . +/// +/// +/// Currently covers system.run only. The signature is stable so adding other +/// capabilities is an internal extension. +/// +/// Policy decisions: +/// +/// readonlyPaths — populated from user-granted folders (Documents, +/// Downloads, Desktop, custom). The Node bridge additionally merges PATH-specific +/// tool directories so spawned shells can find git/node/python/etc. +/// readwritePaths — user-granted read+write folders. The Node bridge +/// adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR to point +/// at it, so the user's real %TEMP% stays out of reach. +/// deniedPaths — settings directory (protect MCP token, gateway +/// credentials, ElevenLabs key), ~/.ssh, and the common browser profile +/// roots (Chrome / Edge / Firefox / Brave). Always blocked regardless of grants. +/// network.allowOutbound — bound by . +/// ui — default-deny across the board; shell exec doesn't need windows. +/// +/// +public static class MxcPolicyBuilder +{ + /// + /// Policy schema version. Per the @microsoft/mxc-sdk validator, this must be + /// in the supported range (currently MIN 0.4.0-alpha, SUPPORTED 0.5.0-alpha). + /// + public const string SupportedPolicyVersion = "0.4.0-alpha"; + + /// + /// Build the policy for a system.run invocation given current settings. + /// + /// Live settings snapshot from (or test stub). + /// + /// Path to . Passed in (rather than + /// read statically) so tests can isolate via OPENCLAW_TRAY_DATA_DIR. + /// + public static SandboxPolicy ForSystemRun(SettingsData settings, string settingsDirectoryPath) + { + var deniedPaths = new List(); + if (!string.IsNullOrWhiteSpace(settingsDirectoryPath)) + deniedPaths.Add(settingsDirectoryPath); + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var sshPath = Path.Combine(userProfile, ".ssh"); + if (!string.IsNullOrWhiteSpace(sshPath)) + deniedPaths.Add(sshPath); + + // Always-blocked browser profile roots. Cookies, saved passwords, autofill, + // and session tokens live here — they must remain unreachable even if the + // user (or a malicious settings.json) tries to grant a parent folder. + // Add these regardless of whether the browser is installed; the AppContainer + // policy treats nonexistent denies as a no-op. + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + deniedPaths.Add(Path.Combine(localAppData, "Google", "Chrome", "User Data")); + deniedPaths.Add(Path.Combine(localAppData, "Microsoft", "Edge", "User Data")); + deniedPaths.Add(Path.Combine(localAppData, "BraveSoftware", "Brave-Browser", "User Data")); + } + if (!string.IsNullOrWhiteSpace(appData)) + { + deniedPaths.Add(Path.Combine(appData, "Mozilla", "Firefox", "Profiles")); + deniedPaths.Add(Path.Combine(appData, "Microsoft", "Windows", "PowerShell", "PSReadLine")); + } + + var readonlyPaths = new List(); + var readwritePaths = new List(); + + AddWellKnownFolder(Environment.SpecialFolder.MyDocuments, settings.SandboxDocumentsAccess, readonlyPaths, readwritePaths); + AddWellKnownFolder(Environment.SpecialFolder.Desktop, settings.SandboxDesktopAccess, readonlyPaths, readwritePaths); + AddDownloadsFolder(userProfile, settings.SandboxDownloadsAccess, readonlyPaths, readwritePaths); + + if (settings.SandboxCustomFolders is { Count: > 0 } customFolders) + { + foreach (var folder in customFolders) + { + if (string.IsNullOrWhiteSpace(folder.Path)) continue; + if (folder.Access == SandboxFolderAccess.ReadWrite) + readwritePaths.Add(folder.Path); + else + readonlyPaths.Add(folder.Path); + } + } + + // Defense-in-depth: strip any allow-list entry that is equal to, or a + // child of, a denied path. The AppContainer policy SHOULD already + // prioritize deny over allow per the @microsoft/mxc-sdk schema, but + // that's an undocumented invariant on an alpha SDK. Filtering here + // means a misconfigured / malicious custom-folder grant pointing at + // `~\.ssh` or a browser profile cannot bleed through. + readonlyPaths = FilterOutDenied(readonlyPaths, deniedPaths); + readwritePaths = FilterOutDenied(readwritePaths, deniedPaths); + + return new SandboxPolicy( + Version: SupportedPolicyVersion, + Filesystem: new FilesystemPolicy( + ReadwritePaths: readwritePaths, + ReadonlyPaths: readonlyPaths, + DeniedPaths: deniedPaths, + ClearPolicyOnExit: true), + Network: new NetworkPolicy( + AllowOutbound: settings.SystemRunAllowOutbound, + // LAN access (privateNetworkClientServer capability) intentionally not + // exposed: MXC team confirmed only internetClient is validated today. + AllowLocalNetwork: false), + Ui: new UiPolicy( + AllowWindows: false, + Clipboard: MapClipboard(settings.SandboxClipboard), + AllowInputInjection: false), + TimeoutMs: settings.SandboxTimeoutMs > 0 ? settings.SandboxTimeoutMs : null); + } + + /// + /// Remove any allow-list entry that overlaps a denied path. + /// Case-insensitive (NTFS semantics) and tolerant of trailing slashes. + /// + private static List FilterOutDenied(List allowed, List denied) + { + if (allowed.Count == 0 || denied.Count == 0) return allowed; + var normalizedDenied = denied + .Select(NormalizePath) + .Where(p => !string.IsNullOrEmpty(p)) + .ToList(); + return allowed + .Where(a => + { + var na = NormalizePath(a); + if (string.IsNullOrEmpty(na)) return false; + foreach (var d in normalizedDenied) + { + if (PathsOverlap(na, d)) return false; + } + return true; + }) + .ToList(); + } + + private static bool PathsOverlap(string left, string right) + { + return IsSameOrNested(left, right) || IsSameOrNested(right, left); + } + + private static bool IsSameOrNested(string path, string candidateParent) + { + if (string.Equals(path, candidateParent, StringComparison.OrdinalIgnoreCase)) + return true; + + return path.StartsWith(candidateParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(candidateParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return string.Empty; + try + { + // Path.GetFullPath collapses .. / . and resolves relative parts. + // Trim trailing separators so "C:\foo\" and "C:\foo" compare equal. + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + catch + { + return path; + } + } + + private static void AddWellKnownFolder( + Environment.SpecialFolder folder, + SandboxFolderAccess? access, + List readonlyPaths, + List readwritePaths) + { + if (access is null) return; + var path = Environment.GetFolderPath(folder); + if (string.IsNullOrWhiteSpace(path)) return; + if (access == SandboxFolderAccess.ReadWrite) readwritePaths.Add(path); + else readonlyPaths.Add(path); + } + + private static void AddDownloadsFolder( + string userProfile, + SandboxFolderAccess? access, + List readonlyPaths, + List readwritePaths) + { + if (access is null) return; + // .NET has no SpecialFolder.Downloads. Use the Win32 known-folder API so we + // honor user redirection (e.g., OneDrive\Downloads). Fall back to the + // %USERPROFILE%\Downloads convention if the API isn't available. + var path = ResolveKnownFolderDownloads() ?? Path.Combine(userProfile, "Downloads"); + if (access == SandboxFolderAccess.ReadWrite) readwritePaths.Add(path); + else readonlyPaths.Add(path); + } + + private static readonly Guid s_folderIdDownloads = + new("374DE290-123F-4565-9164-39C4925E467B"); + + private static string? ResolveKnownFolderDownloads() + { + if (!OperatingSystem.IsWindows()) return null; + try + { + var hr = SHGetKnownFolderPath(s_folderIdDownloads, 0, IntPtr.Zero, out var ptr); + if (hr != 0 || ptr == IntPtr.Zero) return null; + try { return System.Runtime.InteropServices.Marshal.PtrToStringUni(ptr); } + finally { System.Runtime.InteropServices.Marshal.FreeCoTaskMem(ptr); } + } + catch + { + return null; + } + } + + [System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, ExactSpelling = true)] + private static extern int SHGetKnownFolderPath( + [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPStruct)] Guid rfid, + uint dwFlags, + IntPtr hToken, + out IntPtr ppszPath); + + private static ClipboardPolicy MapClipboard(SandboxClipboardMode mode) => mode switch + { + SandboxClipboardMode.Read => ClipboardPolicy.Read, + SandboxClipboardMode.Write => ClipboardPolicy.Write, + SandboxClipboardMode.Both => ClipboardPolicy.All, + _ => ClipboardPolicy.None, + }; +} diff --git a/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs new file mode 100644 index 000000000..422dd8125 --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs @@ -0,0 +1,286 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenClaw.Shared.Mxc; + +/// +/// Implements by spawning node.exe with +/// tools/mxc/run-command.cjs, which calls +/// @microsoft/mxc-sdk.spawnSandboxFromConfig({usePty:false}) to run the +/// payload inside a one-shot AppContainer. +/// +public sealed class OneShotAppContainerExecutor : ISandboxExecutor +{ + public string Name => "mxc-oneshot-appc"; + public bool IsContained => true; + + private readonly MxcAvailability _availability; + private readonly string _runCommandScriptPath; + private readonly string _nodeExecutablePath; + private readonly IOpenClawLogger _logger; + + /// Default cap on stdout/stderr returned to the host (4 MiB). + public const long DefaultMaxOutputBytes = 4 * 1024 * 1024; + + /// + /// Optional environment variable override for the Node executable used by the + /// runner. Falls back to node.exe on PATH. + /// + public const string NodeExecutableOverrideEnvVar = "OPENCLAW_NODE_EXEC"; + + public OneShotAppContainerExecutor( + MxcAvailability availability, + string runCommandScriptPath, + IOpenClawLogger? logger = null, + string? nodeExecutableOverride = null) + { + _availability = availability; + _runCommandScriptPath = runCommandScriptPath; + _logger = logger ?? NullLogger.Instance; + _nodeExecutablePath = nodeExecutableOverride + ?? Environment.GetEnvironmentVariable(NodeExecutableOverrideEnvVar) + ?? "node.exe"; + } + + public async Task ExecuteAsync( + SandboxExecutionRequest request, + CancellationToken ct = default) + { + if (!_availability.IsAppContainerAvailable) + throw new SandboxUnavailableException( + _availability.UnsupportedReasons.FirstOrDefault() ?? "AppContainer unavailable"); + + if (!_availability.IsWxcExecResolvable) + throw new SandboxUnavailableException("wxc-exec.exe not found"); + + if (!File.Exists(_runCommandScriptPath)) + throw new SandboxUnavailableException( + $"run-command.cjs not found at {_runCommandScriptPath}"); + + // Per-request output cap. Default applies only when the caller doesn't + // pass one. Used to be baked at construction; that caused stale floors + // when the user lowered SandboxMaxOutputBytes after the executor was + // built (Math.Max(stale, new) kept the larger old value). + var capBytes = request.MaxOutputBytes is > 0 ? request.MaxOutputBytes.Value : DefaultMaxOutputBytes; + + var bridgeRequest = new BridgeRequest( + CapabilityCommand: request.CapabilityCommand, + Args: request.Args, + Policy: request.Policy, + Cwd: request.Cwd, + Env: request.Env, + TimeoutMs: request.TimeoutMs, + MaxOutputBytes: capBytes, + WxcExecPath: _availability.WxcExecPath); + + var requestJson = JsonSerializer.Serialize(bridgeRequest, BridgeJson); + + var psi = new ProcessStartInfo + { + FileName = _nodeExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + psi.ArgumentList.Add(_runCommandScriptPath); + + var sw = Stopwatch.StartNew(); + using var process = new Process { StartInfo = psi }; + + try + { + process.Start(); + } + catch (Exception ex) + { + throw new SandboxUnavailableException( + $"Failed to start node.exe at '{_nodeExecutablePath}': {ex.Message}", ex); + } + + // Caller-controlled timeout governs how long the bridge has to return. + // Add a small grace so the bridge can clean up before we kill it. + var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs + 5000 : 0; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + if (timeoutMs > 0) + cts.CancelAfter(timeoutMs); + + try + { + await process.StandardInput.WriteAsync(requestJson.AsMemory(), cts.Token); + await process.StandardInput.FlushAsync(cts.Token); + process.StandardInput.Close(); + } + catch (OperationCanceledException) + { + // Either the caller cancelled or the timeout fired — in both cases + // the spawned node + sandboxed payload must be killed so we don't + // leak processes after the host gives up. + KillProcessTree(process); + throw; + } + + // Envelope cap: caller-cap covers stdout AND stderr each. Allow up to + // 2× that plus envelope/JSON overhead so a worst-case bridge response + // (large stdout + large stderr) still fits without truncation. + var envelopeCap = (capBytes * 2L) + (256L * 1024L); + + var stdoutTask = ReadCappedAsync(process.StandardOutput, envelopeCap, cts.Token); + var stderrTask = ReadCappedAsync(process.StandardError, envelopeCap, cts.Token); + + bool timedOut = false; + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + // ALWAYS kill the process tree on cancellation, whether the source is + // the caller's CancellationToken (agent abort) or our local timeout. + // Without this the sandboxed payload keeps running after we return. + KillProcessTree(process); + + // Distinguish caller cancel from local-timeout for the return path. + if (!ct.IsCancellationRequested) + timedOut = true; + else + throw; + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + + sw.Stop(); + + if (timedOut) + { + return new SandboxExecutionResult( + ExitCode: -1, + Stdout: stdout, + Stderr: stderr.Length > 0 ? stderr : "Sandboxed invocation timed out.", + TimedOut: true, + DurationMs: sw.ElapsedMilliseconds, + ContainmentTag: "mxc", + StructuredResult: null); + } + + // Bridge writes a single JSON envelope to stdout on completion. + if (TryParseBridgeResponse(stdout, out var response)) + { + return new SandboxExecutionResult( + ExitCode: response.ExitCode, + Stdout: response.Stdout, + Stderr: response.Stderr, + TimedOut: response.TimedOut, + DurationMs: response.DurationMs == 0 ? sw.ElapsedMilliseconds : response.DurationMs, + ContainmentTag: response.ContainmentTag ?? "mxc", + StructuredResult: response.StructuredResult); + } + + // Bridge crashed or returned malformed output. Surface as a sandbox failure + // — node-side stderr likely has the diagnostic. + _logger.Warn($"[mxc] bridge returned malformed output ({stdout.Length} bytes); stderr={Truncate(stderr, 200)}"); + return new SandboxExecutionResult( + ExitCode: process.ExitCode, + Stdout: stdout, + Stderr: stderr, + TimedOut: false, + DurationMs: sw.ElapsedMilliseconds, + ContainmentTag: "mxc", + StructuredResult: null); + } + + private static async Task ReadCappedAsync(StreamReader reader, long maxBytes, CancellationToken ct) + { + var sb = new StringBuilder(); + var buffer = new char[8192]; + long bytesRead = 0; + while (true) + { + int read; + try { read = await reader.ReadAsync(buffer, ct); } + catch (OperationCanceledException) { break; } + catch (IOException) { break; } + + if (read == 0) + break; + + // Approximate cap: chars × 2 bytes upper bound for UTF-16. + bytesRead += read * 2; + sb.Append(buffer, 0, read); + if (bytesRead >= maxBytes) + { + sb.Append("\n[output truncated]"); + break; + } + } + return sb.ToString(); + } + + private static void KillProcessTree(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch { /* best-effort */ } + } + + private static bool TryParseBridgeResponse(string json, out BridgeResponse response) + { + response = default!; + if (string.IsNullOrWhiteSpace(json)) + return false; + try + { + response = JsonSerializer.Deserialize(json.Trim(), BridgeJson)!; + return response is not null; + } + catch (JsonException) + { + return false; + } + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : string.Concat(s.AsSpan(0, max), "…"); + + private static readonly JsonSerializerOptions BridgeJson = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + // Enums must serialize as camelCase strings so @microsoft/mxc-sdk + // (which expects "none" / "read" / "write" / "all") accepts them. + new System.Text.Json.Serialization.JsonStringEnumConverter( + System.Text.Json.JsonNamingPolicy.CamelCase), + }, + }; + + private sealed record BridgeRequest( + string CapabilityCommand, + JsonElement Args, + SandboxPolicy Policy, + string? Cwd, + IReadOnlyDictionary? Env, + int TimeoutMs, + long MaxOutputBytes, + string? WxcExecPath); + + private sealed record BridgeResponse( + int ExitCode, + string Stdout, + string Stderr, + bool TimedOut, + long DurationMs, + string? ContainmentTag, + JsonElement? StructuredResult); +} diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs new file mode 100644 index 000000000..d78d1680a --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs @@ -0,0 +1,54 @@ +namespace OpenClaw.Shared.Mxc; + +/// +/// Cross-platform sandbox policy expressing what a contained payload can access. +/// Mirrors the SandboxPolicy shape from @microsoft/mxc-sdk's +/// TypeScript types (see microsoft/mxc/sdk/src/types.ts). C# representation +/// so we can build policy without going through the Node bridge. +/// +public sealed record SandboxPolicy( + string Version, + FilesystemPolicy? Filesystem = null, + NetworkPolicy? Network = null, + UiPolicy? Ui = null, + int? TimeoutMs = null); + +public sealed record FilesystemPolicy( + IReadOnlyList? ReadwritePaths = null, + IReadOnlyList? ReadonlyPaths = null, + IReadOnlyList? DeniedPaths = null, + bool? ClearPolicyOnExit = null); + +public sealed record NetworkPolicy( + bool AllowOutbound = false, + bool AllowLocalNetwork = false, + IReadOnlyList? AllowedHosts = null, + IReadOnlyList? BlockedHosts = null); + +public sealed record UiPolicy( + bool AllowWindows = false, + ClipboardPolicy Clipboard = ClipboardPolicy.None, + bool AllowInputInjection = false); + +public enum ClipboardPolicy +{ + None, + Read, + Write, + All, +} + +/// +/// When is true, system.run +/// is contained via MXC AppContainer. When MXC is unavailable on the host, the call +/// is denied (no fallback). When the toggle is false, system.run runs on the +/// host as before. +/// +public enum SandboxMode +{ + /// Sandbox required; fail-closed if unavailable. + Enabled, + + /// Bypass MXC entirely. + Disabled, +} diff --git a/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs new file mode 100644 index 000000000..953d45a5f --- /dev/null +++ b/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs @@ -0,0 +1,29 @@ +namespace OpenClaw.Shared.Mxc; + +/// +/// implementation that always throws +/// . Used when MXC is not installed +/// on the host so can still honor the +/// toggle: when sandbox +/// is enabled and MXC is absent, the invocation is denied (fail-closed) +/// rather than silently routed to the host. +/// +public sealed class UnavailableSandboxExecutor : ISandboxExecutor +{ + public string Name => "mxc-unavailable"; + public bool IsContained => false; + + private readonly string _reason; + + public UnavailableSandboxExecutor(string reason) + { + _reason = reason; + } + + public Task ExecuteAsync( + SandboxExecutionRequest request, + CancellationToken ct = default) + { + throw new SandboxUnavailableException(_reason); + } +} diff --git a/src/OpenClaw.Shared/NonFatalAction.cs b/src/OpenClaw.Shared/NonFatalAction.cs new file mode 100644 index 000000000..5e4b3f3b7 --- /dev/null +++ b/src/OpenClaw.Shared/NonFatalAction.cs @@ -0,0 +1,18 @@ +using System; + +namespace OpenClaw.Shared; + +public static class NonFatalAction +{ + public static void Run(Action action, Action onError) + { + try + { + action(); + } + catch (Exception ex) + { + onError(ex.Message); + } + } +} diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj index ac8329552..dc5e54fc0 100644 --- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj +++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj @@ -9,12 +9,20 @@ + + + + + + + + diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 0a3e402cd..ecf065315 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -5,12 +5,13 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace OpenClaw.Shared; -public class OpenClawGatewayClient : WebSocketClientBase +public class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatewayClient { private const string OperatorClientId = "cli"; private const string OperatorClientDisplayName = "OpenClaw Windows Tray"; @@ -18,13 +19,10 @@ public class OpenClawGatewayClient : WebSocketClientBase private const string OperatorRole = "operator"; private const string OperatorPlatform = "windows"; private const string OperatorDeviceFamily = "desktop"; + private static readonly Regex s_pairingRequestIdRegex = new("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled); private static readonly string[] s_operatorScopes = [ "operator.admin", - "operator.read", - "operator.write", - "operator.approvals", - "operator.pairing" ]; private static readonly string[] s_operatorBootstrapScopes = [ @@ -34,30 +32,50 @@ public class OpenClawGatewayClient : WebSocketClientBase "operator.write" ]; - private enum SignatureTokenMode - { - V3AuthToken, - V3EmptyToken, - V2AuthToken, - V2EmptyToken - } - // Tracked state private readonly Dictionary _sessions = new(); private GatewayUsageInfo? _usage; private GatewayUsageStatusInfo? _usageStatus; private GatewayCostUsageInfo? _usageCost; private readonly Dictionary _pendingRequestMethods = new(); - private readonly Dictionary> _pendingChatSendRequests = new(); + private readonly Dictionary> _pendingChatSendRequests = new(); private readonly object _pendingRequestLock = new(); private readonly object _pendingChatSendLock = new(); private readonly object _sessionsLock = new(); private readonly DeviceIdentity _deviceIdentity; - private string _mainSessionKey = "main"; + private readonly string _currentGatewayUrl; + private string? _mainSessionKey; + private bool _hasHandshakeSnapshot; + + /// + /// The gateway's canonical main session key as published in the hello-ok + /// snapshot (preferring the canonical sessionDefaults.mainSessionKey + /// over the legacy alias mainKey). null until handshake + /// completes or after a disconnect. Callers should pass this exact value + /// (or null) to ; never + /// substitute a literal like "main", which can drift from the + /// canonical key the gateway echoes back in chat events. + /// + /// + /// Read via because the field is + /// written from the gateway WebSocket thread and read from the UI thread + /// (through ). + /// + public string? MainSessionKey => Volatile.Read(ref _mainSessionKey); + + /// + /// True once the hello-ok handshake has been processed (i.e. session + /// defaults are known). Reset to false on disconnect. Surfaces to the UI + /// as . + /// + public bool HasHandshakeSnapshot => Volatile.Read(ref _hasHandshakeSnapshot); private string? _operatorDeviceId; private string[] _grantedOperatorScopes = Array.Empty(); private string _connectAuthToken; - private SignatureTokenMode _signatureTokenMode = SignatureTokenMode.V3AuthToken; + private bool _useV2Signature; // true after v3 signature rejected by gateway + + /// Set to true to skip v3 and use v2 signatures directly (for gateways that don't support v3). + public bool UseV2Signature { get => _useV2Signature; set => _useV2Signature = value; } private long? _challengeTimestampMs; private string? _currentChallengeNonce; private bool _usageStatusUnsupported; @@ -72,15 +90,23 @@ private enum SignatureTokenMode private bool _agentFileGetUnsupported; private bool _operatorReadScopeUnavailable; private bool _pairingRequiredAwaitingApproval; + private string? _pairingRequiredRequestId; private bool _authFailed; - private readonly bool _useBootstrapHandoffAuth; + private string? _lastSkillsStatusAgentId; + private readonly bool _tokenIsBootstrapToken; + private readonly bool _bootstrapPairAsNode; /// True when the gateway reported "pairing required" for this device. public bool IsPairingRequired => _pairingRequiredAwaitingApproval; + /// Safe requestId returned in structured pairing-required details, when present. + public string? PairingRequiredRequestId => _pairingRequiredRequestId; + /// True when the device signature was rejected in all supported modes. public bool IsAuthFailed => _authFailed; + /// The gateway auth token used for this connection. + public string ConnectAuthToken => _connectAuthToken; private IReadOnlyList? _userRules; private bool _preferStructuredCategories = true; private readonly System.Collections.Concurrent.ConcurrentDictionary> _pendingWizardResponses = new(); @@ -139,6 +165,13 @@ protected override bool ShouldAutoReconnect() protected override void OnDisconnected() { ClearPendingRequests(); + // Invalidate the handshake snapshot — the next hello-ok must + // re-establish the canonical session key, scopes, etc. Without this, + // a reconnect-after-server-restart could leave the tray sending to a + // stale canonical key that the new server doesn't recognize, and + // HasHandshakeSnapshot would lie about the offline state to callers. + Volatile.Write(ref _mainSessionKey, null); + Volatile.Write(ref _hasHandshakeSnapshot, false); } protected override void OnDisposing() @@ -160,6 +193,7 @@ protected override void OnDisposing() public event EventHandler? GatewaySelfUpdated; public event EventHandler? CronListUpdated; public event EventHandler? CronStatusUpdated; + public event EventHandler? CronRunsUpdated; public event EventHandler? SkillsStatusUpdated; public event EventHandler? ConfigUpdated; public event EventHandler? ConfigSchemaUpdated; @@ -174,25 +208,49 @@ protected override void OnDisposing() public event EventHandler? AgentFilesListUpdated; public event EventHandler? AgentFileContentUpdated; + // ─── Test-only event raisers ─── + // Exposed via [InternalsVisibleTo("OpenClaw.Connection.Tests")] so unit + // tests can drive event-handler code paths without a live WebSocket. + // Avoids the previous reflection-on-private-backing-field approach which + // silently breaks the moment the events grow explicit add/remove blocks. + internal void RaiseNodePairListUpdatedForTests(PairingListInfo info) + => NodePairListUpdated?.Invoke(this, info); + /// + /// Raised when the gateway broadcasts a "chat" event (assistant or user + /// message echo). Use this to drive a chat-UI timeline; the existing + /// path continues to fire toast + /// notifications and is unaffected. + /// + public event EventHandler? ChatMessageReceived; + public event EventHandler? ChatEventReceived; + + /// Raised when a device token is received from the gateway during hello-ok handshake. + public event EventHandler? DeviceTokenReceived; + /// Raised when the hello-ok handshake completes successfully. + public event EventHandler? HandshakeSucceeded; + /// Raised when the gateway requires pairing approval for this device. + public event EventHandler? PairingRequired; + /// Raised when v3 signature was rejected and client fell back to v2. + public event EventHandler? V2SignatureFallback; + public string? OperatorDeviceId => _operatorDeviceId; public IReadOnlyList GrantedOperatorScopes => _grantedOperatorScopes; - public bool IsConnectedToGateway => IsConnected; + public virtual bool IsConnectedToGateway => IsConnected; - public OpenClawGatewayClient( - string gatewayUrl, - string token, - IOpenClawLogger? logger = null, - bool useBootstrapHandoffAuth = false) + public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null, bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false, string? identityPath = null) : base(gatewayUrl, token, logger) { - _useBootstrapHandoffAuth = useBootstrapHandoffAuth; - var dataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + _tokenIsBootstrapToken = tokenIsBootstrapToken; + _bootstrapPairAsNode = bootstrapPairAsNode; + _currentGatewayUrl = gatewayUrl; + var dataPath = identityPath ?? Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); - _connectAuthToken = _deviceIdentity.DeviceToken ?? _token; + _connectAuthToken = _deviceIdentity.DeviceToken ?? (_tokenIsBootstrapToken ? string.Empty : _token); } public async Task DisconnectAsync() @@ -240,32 +298,50 @@ public async Task CheckHealthAsync() } } - public async Task SendChatMessageAsync(string message, string? sessionKey = null) + public async Task SendChatMessageAsync(string message, string? sessionKey = null, string? sessionId = null, IReadOnlyList? attachments = null) + { + _ = await SendChatMessageForRunAsync(message, sessionKey, sessionId, attachments).ConfigureAwait(false); + } + + public async Task SendChatMessageForRunAsync(string message, string? sessionKey = null, string? sessionId = null, IReadOnlyList? attachments = null) { if (!IsConnected) throw new InvalidOperationException("Gateway connection is not open"); - if (string.IsNullOrWhiteSpace(message)) - throw new ArgumentException("Message is required", nameof(message)); - var effectiveSessionKey = string.IsNullOrWhiteSpace(sessionKey) - ? _mainSessionKey - : sessionKey.Trim(); + var hasAttachments = attachments is { Count: > 0 }; + if (string.IsNullOrWhiteSpace(message) && !hasAttachments) + throw new ArgumentException("Message or attachment is required", nameof(message)); + + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.send"); var requestId = Guid.NewGuid().ToString(); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); TrackPendingChatSend(requestId, completion); + // The gateway's `chat.send` validates strictly on (sessionKey, message, + // idempotencyKey). The spec document mentioned `sessionId` and `text` + // as Control-UI variants, but the live operator endpoint rejects them + // ("unexpected property"). The `sessionId` parameter is therefore only + // tracked client-side (used for display correlation) and not forwarded. + _ = sessionId; // reserved for future protocol versions + + var chatParams = new Dictionary + { + ["sessionKey"] = effectiveSessionKey, + ["message"] = message, + ["idempotencyKey"] = Guid.NewGuid().ToString() + }; + + if (hasAttachments) + chatParams["attachments"] = attachments; + var req = new { type = "req", id = requestId, method = "chat.send", - @params = new - { - sessionKey = effectiveSessionKey, - message, - idempotencyKey = Guid.NewGuid().ToString() - } + @params = chatParams }; await SendRawAsync(JsonSerializer.Serialize(req)); @@ -277,8 +353,138 @@ public async Task SendChatMessageAsync(string message, string? sessionKey = null throw new TimeoutException("Timed out waiting for chat.send response from gateway"); } - await completion.Task; - _logger.Info($"Sent chat message ({message.Length} chars)"); + var result = await completion.Task.ConfigureAwait(false); + _logger.Info($"Sent chat message ({message.Length} chars{(hasAttachments ? $", {attachments!.Count} attachment(s)" : "")})"); + return result; + } + + Task IOperatorGatewayClient.SendChatMessageAsync(string message, string? sessionKey) => + SendChatMessageAsync(message, sessionKey); + + Task IOperatorGatewayClient.SendChatMessageForRunAsync(string message, string? sessionKey) => + SendChatMessageForRunAsync(message, sessionKey); + + /// + /// Fetches the conversation transcript for a session. The gateway applies + /// display normalization (strips delivery directives, tool-call XML, etc.) + /// before returning. Throws on RPC failure or timeout. + /// + public async Task RequestChatHistoryAsync(string? sessionKey = null, int timeoutMs = 15000) + { + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.history"); + + var payload = await SendWizardRequestAsync( + "chat.history", + new { sessionKey = effectiveSessionKey }, + timeoutMs); + + return ParseChatHistory(payload, effectiveSessionKey); + } + + /// + /// Aborts an in-flight agent run. Per spec, partial assistant output may + /// still be visible after the abort and is persisted into the transcript + /// with abort metadata. + /// + public async Task SendChatAbortAsync(string runId, string? sessionKey = null, int timeoutMs = 5000) + { + if (string.IsNullOrWhiteSpace(runId)) + throw new ArgumentException("runId is required", nameof(runId)); + var effectiveSessionKey = ResolveEffectiveSessionKey( + sessionKey, Volatile.Read(ref _mainSessionKey), "chat.abort"); + await SendWizardRequestAsync("chat.abort", new { runId, sessionKey = effectiveSessionKey }, timeoutMs); + } + + private static ChatHistoryInfo ParseChatHistory(JsonElement payload, string sessionKey) + { + var info = new ChatHistoryInfo { SessionKey = sessionKey }; + if (payload.ValueKind != JsonValueKind.Object) return info; + + if (payload.TryGetProperty("sessionId", out var sidProp) && sidProp.ValueKind == JsonValueKind.String) + info.SessionId = sidProp.GetString(); + + if (!payload.TryGetProperty("messages", out var msgs) || msgs.ValueKind != JsonValueKind.Array) + return info; + + var list = new List(msgs.GetArrayLength()); + foreach (var m in msgs.EnumerateArray()) + { + var role = m.TryGetProperty("role", out var r) ? r.GetString() ?? "" : ""; + // Spec doc lists `ts`, but gateway 2026.4.23 actually returns + // `timestamp` on chat.history rows (verified via WS RX trace). + // Accept both for forward/back compat. + long ts = 0; + if (m.TryGetProperty("timestamp", out var tsProp1) && tsProp1.ValueKind == JsonValueKind.Number) + ts = tsProp1.GetInt64(); + else if (m.TryGetProperty("ts", out var tsProp2) && tsProp2.ValueKind == JsonValueKind.Number) + ts = tsProp2.GetInt64(); + + // __openclaw metadata: unique message ID + sequence number + string? openClawId = null; + int? openClawSeq = null; + if (m.TryGetProperty("__openclaw", out var oc) && oc.ValueKind == JsonValueKind.Object) + { + if (oc.TryGetProperty("id", out var ocId)) + openClawId = ocId.GetString(); + if (oc.TryGetProperty("seq", out var ocSeq) && ocSeq.ValueKind == JsonValueKind.Number) + openClawSeq = ocSeq.GetInt32(); + } + + // stopReason on assistant messages (e.g. "stop", "toolUse", possibly "abort") + string? stopReason = null; + if (m.TryGetProperty("stopReason", out var sr)) + stopReason = sr.GetString(); + + // content can be a plain string OR an array of {type:"text", text:"..."} blocks + string text = ExtractMessageText(m); + if (string.IsNullOrEmpty(text)) continue; + if (string.IsNullOrEmpty(role)) continue; + + list.Add(new ChatMessageInfo + { + SessionKey = sessionKey, + Role = role, + Text = text, + State = "final", + Ts = ts, + OpenClawId = openClawId, + OpenClawSeq = openClawSeq, + StopReason = stopReason + }); + } + info.Messages = list; + return info; + } + + private static string ExtractMessageText(JsonElement message) + { + if (!message.TryGetProperty("content", out var content)) return string.Empty; + + if (content.ValueKind == JsonValueKind.String) + return content.GetString() ?? string.Empty; + + if (content.ValueKind == JsonValueKind.Array) + { + var sb = new StringBuilder(); + foreach (var item in content.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + sb.Append(item.GetString()); + } + else if (item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("type", out var ty) && ty.GetString() == "text" && + item.TryGetProperty("text", out var tx) && tx.ValueKind == JsonValueKind.String) + { + if (sb.Length > 0) sb.Append('\n'); + sb.Append(tx.GetString()); + } + } + return sb.ToString(); + } + + return string.Empty; } /// @@ -290,6 +496,7 @@ public async Task SendWizardRequestAsync(string method, object? par if (!IsConnected) throw new InvalidOperationException("Gateway connection is not open"); + _logger.Info($"[GatewayClient] Sending frame: {method}"); var requestId = Guid.NewGuid().ToString(); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingWizardResponses[requestId] = completion; @@ -326,6 +533,14 @@ public async Task RequestSessionsAsync(string? agentId = null) await SendTrackedRequestAsync("sessions.list"); } + /// Subscribe to session change events so the gateway pushes + /// sessions.changed notifications when sessions are mutated. + public async Task SubscribeSessionEventsAsync() + { + var ok = await TrySendTrackedRequestAsync("sessions.subscribe", new { }); + _logger.Info($"[SUBSCRIBE] sessions.subscribe sent, result={ok}"); + } + /// Request usage/context info from gateway (may not be supported on all gateways). public async Task RequestUsageAsync() { @@ -385,7 +600,7 @@ public async Task RequestSessionPreviewAsync(string[] keys, int limit = 12, int }); } - public Task PatchSessionAsync(string key, string? thinkingLevel = null, string? verboseLevel = null) + public Task PatchSessionAsync(string key, string? model = null, string? thinkingLevel = null, string? verboseLevel = null) { if (string.IsNullOrWhiteSpace(key)) return Task.FromResult(false); @@ -393,6 +608,8 @@ public Task PatchSessionAsync(string key, string? thinkingLevel = null, st { ["key"] = key }; + if (model is not null) + payload["model"] = model; if (thinkingLevel is not null) payload["thinkingLevel"] = thinkingLevel; if (verboseLevel is not null) @@ -423,7 +640,7 @@ public Task CompactSessionAsync(string key, int maxLines = 400) public async Task RequestCronListAsync() { - await SendTrackedRequestAsync("cron.list"); + await SendTrackedRequestAsync("cron.list", new { includeDisabled = true }); } public async Task RequestCronStatusAsync() @@ -433,7 +650,7 @@ public async Task RequestCronStatusAsync() public Task RunCronJobAsync(string jobId, bool force = true) { - return TrySendTrackedRequestAsync("cron.run", new { jobId, force }); + return TrySendTrackedRequestAsync("cron.run", new { id = jobId, force }); } public Task RemoveCronJobAsync(string jobId) @@ -441,12 +658,31 @@ public Task RemoveCronJobAsync(string jobId) return TrySendTrackedRequestAsync("cron.remove", new { id = jobId }); } + public Task AddCronJobAsync(object jobDefinition) + { + return TrySendTrackedRequestAsync("cron.add", jobDefinition); + } + + public Task UpdateCronJobAsync(string id, object patch) + { + // Wire format uses "id" consistently with cron.run / cron.remove + return TrySendTrackedRequestAsync("cron.update", new { id, patch }); + } + + public async Task RequestCronRunsAsync(string? id = null, int limit = 20, int offset = 0) + { + // Wire format uses "id" consistently with cron.run / cron.remove + await SendTrackedRequestAsync("cron.runs", new { id, limit, offset }); + } + // Skills/plugin management public async Task RequestSkillsStatusAsync(string? agentId = null) { - if (!string.IsNullOrEmpty(agentId)) - await SendTrackedRequestAsync("skills.status", new { agentId }); + _lastSkillsStatusAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId; + + if (_lastSkillsStatusAgentId is not null) + await SendTrackedRequestAsync("skills.status", new { agentId = _lastSkillsStatusAgentId }); else await SendTrackedRequestAsync("skills.status"); } @@ -456,9 +692,9 @@ public Task InstallSkillAsync(string skillId) return TrySendTrackedRequestAsync("skills.install", new { id = skillId }); } - public Task UpdateSkillAsync(string skillId) + public Task SetSkillEnabledAsync(string skillKey, bool enabled) { - return TrySendTrackedRequestAsync("skills.update", new { id = skillId }); + return TrySendTrackedRequestAsync("skills.update", new { skillKey, enabled }); } // Gateway config management @@ -490,6 +726,66 @@ public Task PatchConfigAsync(JsonElement fullConfig, string? baseHash) return TrySendTrackedRequestAsync("config.patch", new { raw }); } + /// + /// Response-aware variant of . Uses the + /// wizard request mechanism () so we + /// actually await the gateway's response and return a + /// with the real error message on failure. The fire-and-forget + /// stays for legacy callers that don't + /// care about the gateway's reply. + /// + public async Task PatchConfigDetailedAsync(JsonElement fullConfig, string? baseHash, int timeoutMs = 15000) + { + var raw = fullConfig.GetRawText(); + object payload = baseHash != null ? new { raw, baseHash } : (object)new { raw }; + try + { + var response = await SendWizardRequestAsync("config.patch", payload, timeoutMs); + _logger.Info("config.patch succeeded"); + return new ConfigPatchResult + { + Ok = true, + RawResponse = response.ValueKind == JsonValueKind.Undefined ? null : response.GetRawText(), + }; + } + catch (Exception ex) + { + // Sanitize before logging — the gateway sometimes echoes patched + // field values in validation errors, so logging ex.Message verbatim + // can leak secrets to the on-disk tray log (Hanselman review LOW-7). + // The full unsanitized message stays in ConfigPatchResult.Error so + // the UI banner can show it. + _logger.Warn($"config.patch failed: {SanitizeErrorForLog(ex.Message)}"); + return new ConfigPatchResult + { + Ok = false, + Error = ex.Message, + RawResponse = ex.ToString(), + }; + } + } + + /// + /// Best-effort scrub of a gateway error message before it lands in the + /// tray log: caps length and masks token-shaped values for the channel + /// credential keys we know about (botToken / signingSecret / webhookUrl + /// / nsec / privateKey / generic token|secret|key|password fields). The + /// gateway may put raw field values in validation errors, and the log + /// is persistent on disk — see Hanselman review LOW-7. + /// + private static string SanitizeErrorForLog(string? raw) + { + if (string.IsNullOrEmpty(raw)) return raw ?? ""; + const int MaxLen = 500; + var truncated = raw.Length > MaxLen ? raw[..MaxLen] + "…(truncated)" : raw; + // Mask JSON-style "field": "value" pairs for sensitive field names. + truncated = System.Text.RegularExpressions.Regex.Replace( + truncated, + @"(""(?i:botToken|signingSecret|webhookUrl|nsec|privateKey|token|secret|apiKey|password)""\s*:\s*"")[^""]+("")", + "$1$2"); + return truncated; + } + // Agent methods public async Task RequestAgentsListAsync() @@ -526,7 +822,7 @@ public async Task RequestNodePairListAsync() await SendTrackedRequestAsync("node.pair.list"); } - public Task NodePairApproveAsync(string requestId) + public virtual Task NodePairApproveAsync(string requestId) { return TrySendTrackedRequestAsync("node.pair.approve", new { requestId }); } @@ -536,13 +832,108 @@ public Task NodePairRejectAsync(string requestId) return TrySendTrackedRequestAsync("node.pair.reject", new { requestId }); } + /// + /// Removes a paired node from the gateway and waits for the gateway's + /// application-level response. Returns Success=true only when the + /// gateway confirms the removal — Success=false on transport failure, + /// missing scope, unknown nodeId, or any server-side rejection. The + /// gateway also broadcasts node.pair.resolved with + /// decision="removed" after success, which the broadcast handler + /// turns into a node.list + node.pair.list refresh. + /// + public async Task NodePairRemoveAsync(string nodeId) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeForgetResult(false, "nodeId required"); + if (!IsConnected) + return new NodeForgetResult(false, "Gateway connection is not open"); + + try + { + // SendWizardRequestAsync awaits the matching ack frame and + // throws InvalidOperationException when the gateway responds + // with ok=false, so callers see a real failure result on + // rejection (missing scope, unknown nodeId) rather than a + // false success the moment the WS frame is sent. + await SendWizardRequestAsync("node.pair.remove", new { nodeId }); + return new NodeForgetResult(true); + } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.pair.remove rejected: {ex.Message}"); + return new NodeForgetResult(false, ex.Message); + } + catch (Exception ex) + { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. + _logger.Warn($"node.pair.remove failed: {ex.Message}"); + return new NodeForgetResult(false, ErrorMessage: null); + } + } + + /// + /// Renames the display name of a paired node. Awaits the gateway's + /// response, so callers can rely on + /// before refreshing UI state. The gateway does not broadcast a rename, + /// so callers should follow a successful rename with + /// to pick up the new value. + /// + public async Task NodeRenameAsync(string nodeId, string displayName) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return new NodeRenameResult(false, ErrorMessage: "nodeId required"); + var trimmed = displayName?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(trimmed)) + return new NodeRenameResult(false, ErrorMessage: "displayName required"); + if (!IsConnected) + return new NodeRenameResult(false, ErrorMessage: "Gateway connection is not open"); + + try + { + var response = await SendWizardRequestAsync( + "node.rename", + new { nodeId, displayName = trimmed }); + + var returnedNodeId = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("nodeId", out var idEl) + ? idEl.GetString() + : nodeId; + var returnedDisplayName = response.ValueKind == JsonValueKind.Object && + response.TryGetProperty("displayName", out var nameEl) + ? nameEl.GetString() ?? trimmed + : trimmed; + return new NodeRenameResult(true, returnedNodeId, returnedDisplayName); + } + catch (InvalidOperationException ex) + { + // Gateway business error (e.g. "missing scope: operator.pairing", + // "unknown nodeId"). Surface this verbatim so the user sees an + // actionable message. + _logger.Warn($"node.rename rejected: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: ex.Message); + } + catch (Exception ex) + { + // Transport / timeout / unexpected exception. Don't leak raw + // exception text into the UI — return null so the caller uses + // its localized fallback string. + _logger.Warn($"node.rename failed: {ex.Message}"); + return new NodeRenameResult(false, ErrorMessage: null); + } + } + public async Task RequestDevicePairListAsync() { if (_devicePairListUnsupported) return; await SendTrackedRequestAsync("device.pair.list"); } - public Task DevicePairApproveAsync(string requestId) + public virtual Task DevicePairApproveAsync(string requestId) { return TrySendTrackedRequestAsync("device.pair.approve", new { requestId }); } @@ -552,85 +943,239 @@ public Task DevicePairRejectAsync(string requestId) return TrySendTrackedRequestAsync("device.pair.reject", new { requestId }); } - /// Start a channel (telegram, whatsapp, etc). + /// + /// Start a channel. Sends channels.start { channel } — the gateway's + /// canonical wire method per src/gateway/server-methods-list.ts:21. + /// (Note: previously this sent channel.start singular which the gateway + /// rejects as an unknown method; that was a latent bug.) Returns true when + /// the gateway acknowledges the start, false on any failure. For the rich + /// error payload — including "unknown channel" which means the channel + /// plugin isn't installed on the gateway host — use + /// . + /// public async Task StartChannelAsync(string channelName) { - if (!IsConnected) return false; + var result = await StartChannelDetailedAsync(channelName); + return result != null && result.Ok && result.Started; + } + + /// + /// Start a channel via channels.start and return the full gateway + /// response (including error message + raw JSON). The page uses this to + /// distinguish "channel started" from "plugin not loaded on gateway" so + /// the user gets accurate guidance instead of a generic failure. + /// + public async Task StartChannelDetailedAsync(string channelName, int timeoutMs = 12000) + { + if (!IsConnected) return null; try { - var req = new + var response = await SendWizardRequestAsync( + "channels.start", + new { channel = channelName }, + timeoutMs); + var raw = response.GetRawText(); + string? acctId = null; + string? channel = null; + bool started = false; + if (response.ValueKind == System.Text.Json.JsonValueKind.Object) { - type = "req", - id = Guid.NewGuid().ToString(), - method = "channel.start", - @params = new { channel = channelName } + if (response.TryGetProperty("channel", out var ch) && ch.ValueKind == System.Text.Json.JsonValueKind.String) + channel = ch.GetString(); + if (response.TryGetProperty("accountId", out var aid) && aid.ValueKind == System.Text.Json.JsonValueKind.String) + acctId = aid.GetString(); + if (response.TryGetProperty("started", out var st) && st.ValueKind == System.Text.Json.JsonValueKind.True) + started = true; + } + _logger.Info($"channels.start {channelName} → started={started}"); + return new ChannelStartResult + { + Channel = channel ?? channelName, + AccountId = acctId, + Started = started, + Ok = true, + RawResponse = raw, }; - await SendRawAsync(JsonSerializer.Serialize(req)); - _logger.Info($"Sent channel.start for {channelName}"); + } + catch (Exception ex) + { + _logger.Warn($"channels.start {channelName} failed: {ex.Message}"); + return new ChannelStartResult + { + Channel = channelName, + Started = false, + Ok = false, + Error = ex.Message, + RawResponse = ex.ToString(), + }; + } + } + + /// Stop a channel. Sends channels.stop { channel }. + public async Task StopChannelAsync(string channelName) + { + if (!IsConnected) return false; + try + { + await SendWizardRequestAsync("channels.stop", new { channel = channelName }, 12000); + _logger.Info($"channels.stop {channelName} succeeded"); return true; } catch (Exception ex) { - _logger.Error($"Failed to start channel {channelName}", ex); + _logger.Warn($"channels.stop {channelName} failed: {ex.Message}"); return false; } } - /// Stop a channel (telegram, whatsapp, etc). - public async Task StopChannelAsync(string channelName) + /// + /// Fetch the rich channels.status snapshot — the canonical channel-status API + /// used by macOS and the web UI. Returns null on failure. + /// The is propagated to the gateway so slow + /// environments can extend the probe budget without recompiling. + /// + public async Task GetChannelsStatusAsync(bool probe = false, int timeoutMs = 12000) + { + if (!IsConnected) return null; + try + { + // Pass the caller's timeoutMs through to the gateway. We give the + // request envelope a slightly larger overall budget than the probe + // budget so a slow but successful probe still returns in time. + var probeTimeoutMs = Math.Max(1000, timeoutMs - 2000); + var response = await SendWizardRequestAsync( + "channels.status", + new { probe, timeoutMs = probeTimeoutMs }, + timeoutMs); + return ChannelsStatusParser.Parse(response); + } + catch (Exception ex) + { + _logger.Warn($"channels.status request failed: {ex.Message}"); + return null; + } + } + + /// Log out / unlink a channel. Sends channels.logout { channel }. + public async Task LogoutChannelAsync(string channelName, int timeoutMs = 12000) { if (!IsConnected) return false; try { - var req = new - { - type = "req", - id = Guid.NewGuid().ToString(), - method = "channel.stop", - @params = new { channel = channelName } - }; - await SendRawAsync(JsonSerializer.Serialize(req)); - _logger.Info($"Sent channel.stop for {channelName}"); + await SendWizardRequestAsync("channels.logout", new { channel = channelName }, timeoutMs); + _logger.Info($"channels.logout {channelName} succeeded"); return true; } catch (Exception ex) { - _logger.Error($"Failed to stop channel {channelName}", ex); + _logger.Warn($"channels.logout {channelName} failed: {ex.Message}"); return false; } } + /// Begin a web/QR linking flow for the current default linking channel. + public async Task WebLoginStartAsync(bool force = false, int timeoutMs = 30000) + { + if (!IsConnected) return null; + try + { + var response = await SendWizardRequestAsync( + "web.login.start", + new { force, timeoutMs }, + timeoutMs + 5000); + return new WebLoginStartResult + { + Message = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null, + QrDataUrl = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("qrDataUrl", out var q) && q.ValueKind == JsonValueKind.String ? q.GetString() : null, + Connected = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("connected", out var c) && c.ValueKind == JsonValueKind.True, + RawResponse = response.ValueKind != JsonValueKind.Undefined ? response.GetRawText() : null, + }; + } + catch (Exception ex) + { + _logger.Warn($"web.login.start failed: {ex.Message}"); + // Return a populated result with the error so the UI can surface + // it in the diagnostic disclosure. Returning null would lose the + // gateway's actual reason for failing. + return new WebLoginStartResult + { + Error = ex.Message, + RawResponse = ex.ToString(), + }; + } + } + + /// Long-poll for QR linking completion. + public async Task WebLoginWaitAsync(string? currentQrDataUrl = null, int timeoutMs = 30000) + { + if (!IsConnected) return null; + try + { + var response = await SendWizardRequestAsync( + "web.login.wait", + new { currentQrDataUrl, timeoutMs }, + timeoutMs + 5000); + return new WebLoginWaitResult + { + Message = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null, + QrDataUrl = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("qrDataUrl", out var q) && q.ValueKind == JsonValueKind.String ? q.GetString() : null, + Connected = response.ValueKind == JsonValueKind.Object && response.TryGetProperty("connected", out var c) && c.ValueKind == JsonValueKind.True, + RawResponse = response.ValueKind != JsonValueKind.Undefined ? response.GetRawText() : null, + }; + } + catch (Exception ex) + { + _logger.Warn($"web.login.wait failed: {ex.Message}"); + return new WebLoginWaitResult + { + Error = ex.Message, + RawResponse = ex.ToString(), + }; + } + } + private async Task SendConnectMessageAsync(string? nonce = null) { var requestId = Guid.NewGuid().ToString(); TrackPendingRequest(requestId, "connect"); - var requestedScopes = GetRequestedOperatorScopes(); + var role = GetConnectRole(); + var requestedScopes = GetRequestedScopes(role); var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var connectNonce = nonce ?? string.Empty; - var signatureToken = _signatureTokenMode is SignatureTokenMode.V3EmptyToken or SignatureTokenMode.V2EmptyToken - ? string.Empty - : _connectAuthToken; - - var signature = _signatureTokenMode is SignatureTokenMode.V2AuthToken or SignatureTokenMode.V2EmptyToken + var signatureToken = GetSignatureToken(); + var authPayload = BuildAuthPayload(); + + // Log complete handshake details for diagnostics + _logger.Info($"[HANDSHAKE] → Sending connect:"); + _logger.Info($" role={role}, clientId={OperatorClientId}, mode={OperatorClientMode}"); + _logger.Info($" scopes=[{string.Join(", ", requestedScopes)}]"); + _logger.Info($" isBootstrap={_tokenIsBootstrapToken}, hasDeviceToken={!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)}"); + _logger.Info($" deviceId={_deviceIdentity.DeviceId[..Math.Min(16, _deviceIdentity.DeviceId.Length)]}..."); + _logger.Info($" nonce={(!string.IsNullOrEmpty(connectNonce) ? connectNonce[..Math.Min(12, connectNonce.Length)] + "..." : "(empty)")}"); + _logger.Info($" signedAt={signedAt}"); + _logger.Info($" sigToken(len)={signatureToken.Length}, preview=[REDACTED]"); + _logger.Info($" signature format={(_useV2Signature ? "v2" : "v3")}, platform={OperatorPlatform}, family={OperatorDeviceFamily}"); + + var signedPayload = _useV2Signature + ? _deviceIdentity.BuildConnectPayloadV2(connectNonce, signedAt, OperatorClientId, OperatorClientMode, role, requestedScopes, signatureToken) + : _deviceIdentity.BuildConnectPayloadV3(connectNonce, signedAt, OperatorClientId, OperatorClientMode, role, requestedScopes, signatureToken, OperatorPlatform, OperatorDeviceFamily); + _logger.Info($"[HANDSHAKE] signed: {TokenSanitizer.Sanitize(signedPayload)}"); + + // Also log what auth field we're sending + var authObj = BuildAuthPayload(); + var authJson = JsonSerializer.Serialize(authObj); + _logger.Info($"[HANDSHAKE] auth: {RedactAuthPayload(authJson)}"); + + // Try v3 first (matches reference client). Fall back to v2 if gateway rejects v3. + var signature = _useV2Signature ? _deviceIdentity.SignConnectPayloadV2( - connectNonce, - signedAt, - OperatorClientId, - OperatorClientMode, - OperatorRole, - requestedScopes, - signatureToken) + connectNonce, signedAt, OperatorClientId, OperatorClientMode, + role, requestedScopes, signatureToken) : _deviceIdentity.SignConnectPayloadV3( - connectNonce, - signedAt, - OperatorClientId, - OperatorClientMode, - OperatorRole, - requestedScopes, - signatureToken, - OperatorPlatform, - OperatorDeviceFamily); + connectNonce, signedAt, OperatorClientId, OperatorClientMode, + role, requestedScopes, signatureToken, + OperatorPlatform, OperatorDeviceFamily); // Use "cli" client ID for native apps - no browser security checks var msg = new @@ -641,7 +1186,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) @params = new { minProtocol = 3, - maxProtocol = 3, + maxProtocol = 4, client = new { id = OperatorClientId, // Native client ID @@ -650,7 +1195,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) mode = OperatorClientMode, displayName = OperatorClientDisplayName }, - role = OperatorRole, + role, scopes = requestedScopes, caps = Array.Empty(), commands = Array.Empty(), @@ -680,10 +1225,32 @@ private async Task SendConnectMessageAsync(string? nonce = null) } } - private string[] GetRequestedOperatorScopes() => - _useBootstrapHandoffAuth && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) - ? s_operatorBootstrapScopes + private string GetConnectRole() + { + return _bootstrapPairAsNode && _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) + ? "node" + : OperatorRole; + } + + private string[] GetRequestedScopes(string role) + { + if (role == "node") + return []; + + if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + { + // Shared gateway token (non-bootstrap) → request admin scope. + // Bootstrap tokens get bounded scopes. + if (!_tokenIsBootstrapToken) + return s_operatorScopes; + + return s_operatorBootstrapScopes; + } + + return _deviceIdentity.DeviceTokenScopes is { Count: > 0 } scopes + ? scopes.ToArray() : s_operatorScopes; + } /// /// Builds the auth payload for the connect handshake, matching the gateway's @@ -693,27 +1260,34 @@ private string[] GetRequestedOperatorScopes() => /// private Dictionary BuildAuthPayload() { - var auth = new Dictionary { ["token"] = _connectAuthToken }; - - if (!_useBootstrapHandoffAuth) - { - return auth; - } + var auth = new Dictionary(); if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) { - // Paired device: send explicit device token for cleaner auth path auth["deviceToken"] = _deviceIdentity.DeviceToken; } - else + else if (_tokenIsBootstrapToken) { - // Fresh device: send bootstrap token for initial pairing + // Fresh QR/setup-code device: do not also send auth.token, which upstream treats + // as an explicit gateway token and therefore suppresses bootstrap pairing. auth["bootstrapToken"] = _token; } + else + { + auth["token"] = _connectAuthToken; + } return auth; } + private string GetSignatureToken() + { + if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + return _deviceIdentity.DeviceToken; + + return _tokenIsBootstrapToken ? _token : _connectAuthToken; + } + private async Task SendTrackedRequestAsync(string method, object? parameters = null) { if (!IsConnected) return; @@ -809,9 +1383,16 @@ private void ClearPendingRequests() _pendingChatSendRequests.Clear(); } + + foreach (var completion in _pendingWizardResponses.Values) + { + completion.TrySetException(new OperationCanceledException("Gateway connection lost while waiting for wizard response")); + } + + _pendingWizardResponses.Clear(); } - private void TrackPendingChatSend(string requestId, TaskCompletionSource completion) + private void TrackPendingChatSend(string requestId, TaskCompletionSource completion) { lock (_pendingChatSendLock) { @@ -827,7 +1408,7 @@ private void RemovePendingChatSend(string requestId) } } - private TaskCompletionSource? TakePendingChatSend(string? requestId) + private TaskCompletionSource? TakePendingChatSend(string? requestId) { if (string.IsNullOrWhiteSpace(requestId)) { @@ -864,7 +1445,7 @@ private void ProcessMessage(string json) HandleResponse(root); break; case "event": - HandleEvent(root); + HandleEvent(root, json.Length); break; } } @@ -900,7 +1481,7 @@ private void HandleResponse(JsonElement root) return; } - pendingChatSend.TrySetResult(true); + pendingChatSend.TrySetResult(ParseChatSendResult(root)); return; } @@ -914,8 +1495,12 @@ private void HandleResponse(JsonElement root) } else if (root.TryGetProperty("payload", out var wizPayload)) { - // Log the payload kind for debugging - _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizPayload.ToString()?.Substring(0, Math.Min(200, wizPayload.ToString()?.Length ?? 0))}"); + // HIGH: never log the wizard payload body — even after token + // sanitisation it can include prompts, tool args, and chat + // content. Log shape only; full payload is available in the + // gateway's own server-side logs if engineering needs it. + var wizardPayloadLen = wizPayload.ValueKind == JsonValueKind.Undefined ? 0 : wizPayload.GetRawText().Length; + _logger.Info($"Wizard response payload kind={wizPayload.ValueKind} len={wizardPayloadLen}"); wizardCompletion.TrySetResult(wizPayload.Clone()); } else @@ -942,22 +1527,49 @@ private void HandleResponse(JsonElement root) // Handle handshake acknowledgement payload. if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") { + _logger.Info($"[HANDSHAKE] Received hello-ok!"); _pairingRequiredAwaitingApproval = false; + _pairingRequiredRequestId = null; _authFailed = false; ResetReconnectAttempts(); _operatorDeviceId = TryGetHandshakeDeviceId(payload); _grantedOperatorScopes = TryGetHandshakeScopes(payload); - _mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main"; + // Write the key first, then publish the readiness flag. Pair with + // Volatile.Read on the public getters so a reader observing + // HasHandshakeSnapshot==true is guaranteed to see the populated + // MainSessionKey (release/acquire ordering). + Volatile.Write(ref _mainSessionKey, TryGetHandshakeMainSessionKey(payload)); + Volatile.Write(ref _hasHandshakeSnapshot, true); + _logger.Info($"[HANDSHAKE] deviceId={_operatorDeviceId}, scopes=[{string.Join(", ", _grantedOperatorScopes)}], mainSession={_mainSessionKey ?? "(unset)"}"); PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); - var newDeviceToken = TryGetHandshakeDeviceToken(payload); + if (_bootstrapPairAsNode) + { + var nodeDeviceToken = TryGetHandshakeDeviceTokenCore(payload, "node", allowDirectDeviceTokenFallback: true); + if (!string.IsNullOrWhiteSpace(nodeDeviceToken)) + { + var nodeDeviceTokenScopes = TryGetHandshakeDeviceTokenScopesCore(payload, "node", allowDirectDeviceTokenFallback: true); + _deviceIdentity.StoreDeviceTokenForRole("node", nodeDeviceToken, nodeDeviceTokenScopes); + _logger.Info("Node device token stored for Windows tray node reconnect"); + DeviceTokenReceived?.Invoke(this, new DeviceTokenReceivedEventArgs(nodeDeviceToken, nodeDeviceTokenScopes, "node")); + } + } + + var newDeviceToken = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); if (!string.IsNullOrWhiteSpace(newDeviceToken)) { - _deviceIdentity.StoreDeviceToken(newDeviceToken); + var deviceTokenScopes = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenScopesCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole: null); + _deviceIdentity.StoreDeviceTokenWithScopes(newDeviceToken, deviceTokenScopes); _connectAuthToken = newDeviceToken; _logger.Info("Operator device token stored for reconnect"); + DeviceTokenReceived?.Invoke(this, new DeviceTokenReceivedEventArgs(newDeviceToken, deviceTokenScopes, "operator")); } _logger.Info("Handshake complete (hello-ok)"); + HandshakeSucceeded?.Invoke(this, EventArgs.Empty); if (!string.IsNullOrWhiteSpace(_operatorDeviceId)) { _logger.Info($"Operator device ID: {_operatorDeviceId}"); @@ -966,7 +1578,7 @@ private void HandleResponse(JsonElement root) { _logger.Info($"Granted operator scopes: {string.Join(", ", _grantedOperatorScopes)}"); } - _logger.Info($"Main session key: {_mainSessionKey}"); + _logger.Info($"Main session key: {_mainSessionKey ?? "(unset)"}"); // Extract presence from snapshot TryParsePresence(payload); @@ -979,6 +1591,7 @@ private void HandleResponse(JsonElement root) await Task.Delay(500); await CheckHealthAsync(); await RequestSessionsAsync(); + await SubscribeSessionEventsAsync(); await RequestUsageAsync(); await RequestNodesAsync(); await RequestAgentsListAsync(); @@ -1053,12 +1666,21 @@ private bool HandleKnownResponse(string method, JsonElement payload) return true; case "cron.run": case "cron.remove": + case "cron.add": + case "cron.update": + // After add/update/remove, refresh the list + _ = RequestCronListAsync(); + return true; + case "cron.runs": + CronRunsUpdated?.Invoke(this, payload.Clone()); return true; case "skills.status": SkillsStatusUpdated?.Invoke(this, payload.Clone()); return true; case "skills.install": case "skills.update": + // Re-fetch the same skills scope so filtered views do not jump back to all agents. + _ = RequestSkillsStatusAsync(_lastSkillsStatusAgentId); return true; case "config.get": ConfigUpdated?.Invoke(this, payload.Clone()); @@ -1100,6 +1722,36 @@ private bool HandleKnownResponse(string method, JsonElement payload) } } + private static ChatSendResult ParseChatSendResult(JsonElement root) + { + string? runId = null; + string? sessionKey = null; + var cached = false; + + if (root.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object) + { + if (payload.TryGetProperty("runId", out var runIdProp)) + runId = runIdProp.GetString(); + if (payload.TryGetProperty("sessionKey", out var sessionKeyProp)) + sessionKey = sessionKeyProp.GetString(); + } + + if (root.TryGetProperty("meta", out var meta) && + meta.ValueKind == JsonValueKind.Object && + meta.TryGetProperty("cached", out var cachedProp) && + cachedProp.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + cached = cachedProp.GetBoolean(); + } + + return new ChatSendResult + { + RunId = runId, + SessionKey = sessionKey, + Cached = cached + }; + } + private void HandleRequestError(string? method, JsonElement root) { var message = TryGetErrorMessage(root) ?? "request failed"; @@ -1110,43 +1762,52 @@ private void HandleRequestError(string? method, JsonElement root) return; } - if (method == "connect" && - message.Contains("device signature invalid", StringComparison.OrdinalIgnoreCase)) + if (method == "connect") { - var previousMode = _signatureTokenMode; - _signatureTokenMode = _signatureTokenMode switch - { - SignatureTokenMode.V3AuthToken => SignatureTokenMode.V3EmptyToken, - SignatureTokenMode.V3EmptyToken => SignatureTokenMode.V2AuthToken, - SignatureTokenMode.V2AuthToken => SignatureTokenMode.V2EmptyToken, - _ => SignatureTokenMode.V2EmptyToken - }; + var detailCode = TryGetErrorDetailCode(root); + _logger.Warn($"[HANDSHAKE] Connect error from gateway: message=\"{message}\", detailCode={detailCode ?? "none"}"); + // Log raw JSON for debugging (truncated) + var rawJson = root.ToString() ?? ""; + if (rawJson.Length > 500) rawJson = rawJson[..500] + "..."; + _logger.Info($"[HANDSHAKE] Raw error response: {rawJson}"); + } - if (_signatureTokenMode != previousMode) + if (method == "connect" && + (message.Contains("device signature invalid", StringComparison.OrdinalIgnoreCase) || + TryGetErrorDetailCode(root) == "DEVICE_AUTH_SIGNATURE_INVALID")) + { + if (!_useV2Signature) { - _logger.Warn($"Gateway rejected device signature with mode {previousMode}; retrying with mode {_signatureTokenMode}"); - _ = SendConnectMessageAsync(_currentChallengeNonce); + // v3 rejected — set flag so next connect uses v2. + // Don't retry on this socket — gateway closes it after rejection. + // The auto-reconnect will use v2 on the fresh connection. + _useV2Signature = true; + _logger.Warn($"[HANDSHAKE] v3 signature rejected, will use v2 on reconnect"); + V2SignatureFallback?.Invoke(this, EventArgs.Empty); return; } - - _logger.Warn("Gateway rejected device signature in all supported payload modes"); + // v2 also rejected — real auth error + _logger.Warn($"[HANDSHAKE] v2 signature also rejected — wrong key or token. Raw: {message}"); _authFailed = true; - RaiseAuthenticationFailed("device signature rejected in all modes — the gateway may require a different auth protocol version"); + RaiseAuthenticationFailed($"device signature rejected — {message}"); RaiseStatusChanged(ConnectionStatus.Error); return; } + var pairingDetails = TryGetPairingConnectErrorDetails(root); if (method == "connect" && - message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)) + (pairingDetails.IsPairingRequired || message.Contains("pairing required", StringComparison.OrdinalIgnoreCase))) { _pairingRequiredAwaitingApproval = true; - _logger.Warn("Pairing approval required for this device; auto-reconnect paused until manual reconnect or app restart"); - RaiseStatusChanged(ConnectionStatus.Error); + _pairingRequiredRequestId = pairingDetails.RequestId; + _logger.Warn($"[HANDSHAKE] Pairing required (requestId={pairingDetails.RequestId}). Waiting for approval."); + PairingRequired?.Invoke(this, pairingDetails.RequestId); return; } // Permanent auth failures — stop retrying and notify the app - if (method == "connect" && IsTerminalAuthError(message)) + var detailCode2 = TryGetErrorDetailCode(root); + if (method == "connect" && (IsTerminalAuthError(message) || IsTerminalAuthDetailCode(detailCode2))) { _authFailed = true; RaiseAuthenticationFailed(message); @@ -1263,6 +1924,17 @@ private static bool TryGetNodesPayload(JsonElement payload, out JsonElement node return false; } + private static readonly Regex AuthPayloadTokenPattern = new( + @"""(token|deviceToken|bootstrapToken)""\s*:\s*""[^""]+""", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static string RedactAuthPayload(string authJson) + { + return AuthPayloadTokenPattern.Replace( + authJson, + m => $"\"{m.Groups[1].Value}\":\"[REDACTED]\""); + } + private static string? TryGetErrorMessage(JsonElement root) { if (!root.TryGetProperty("error", out var error)) return null; @@ -1273,6 +1945,64 @@ private static bool TryGetNodesPayload(JsonElement payload, out JsonElement node return null; } + /// + /// Extract the structured error detail code from the gateway error response. + /// Checks error.details.code and error.data.details.code. + /// + private static string? TryGetErrorDetailCode(JsonElement root) + { + if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object) + return null; + if (TryGetPairingDetailsElement(error, out var details) && + details.ValueKind == JsonValueKind.Object && + details.TryGetProperty("code", out var code) && + code.ValueKind == JsonValueKind.String) + return code.GetString(); + return null; + } + + private static PairingConnectErrorDetails TryGetPairingConnectErrorDetails(JsonElement root) + { + if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object) + return default; + + if (!TryGetPairingDetailsElement(error, out var details) || details.ValueKind != JsonValueKind.Object) + return default; + + var isPairingRequired = details.TryGetProperty("code", out var code) + && code.ValueKind == JsonValueKind.String + && string.Equals(code.GetString(), "PAIRING_REQUIRED", StringComparison.Ordinal); + var requestId = TryGetSafePairingRequestId(details); + return new PairingConnectErrorDetails(isPairingRequired, requestId); + } + + private static bool TryGetPairingDetailsElement(JsonElement error, out JsonElement details) + { + if (error.TryGetProperty("details", out details)) + return true; + + if (error.TryGetProperty("data", out var data) + && data.ValueKind == JsonValueKind.Object + && data.TryGetProperty("details", out details)) + { + return true; + } + + details = default; + return false; + } + + private static string? TryGetSafePairingRequestId(JsonElement details) + { + if (!details.TryGetProperty("requestId", out var requestId) || requestId.ValueKind != JsonValueKind.String) + return null; + + var value = requestId.GetString()?.Trim(); + return value is not null && s_pairingRequestIdRegex.IsMatch(value) ? value : null; + } + + private readonly record struct PairingConnectErrorDetails(bool IsPairingRequired, string? RequestId); + private static bool IsUnknownMethodError(string errorMessage) { return errorMessage.Contains("unknown method", StringComparison.OrdinalIgnoreCase); @@ -1282,9 +2012,15 @@ private static bool IsTerminalAuthError(string errorMessage) { return errorMessage.Contains("token mismatch", StringComparison.OrdinalIgnoreCase) || errorMessage.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) || - errorMessage.Contains("too many failed", StringComparison.OrdinalIgnoreCase); + errorMessage.Contains("too many failed", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("bootstrap token invalid", StringComparison.OrdinalIgnoreCase); } + private static bool IsTerminalAuthDetailCode(string? code) => code is + "AUTH_TOKEN_MISMATCH" or "AUTH_BOOTSTRAP_TOKEN_INVALID" or + "AUTH_DEVICE_TOKEN_MISMATCH" or "AUTH_RATE_LIMITED" or + "AUTH_TOKEN_NOT_CONFIGURED"; + private static bool IsMissingScopeError(string errorMessage, string scope) { if (string.IsNullOrWhiteSpace(errorMessage) || string.IsNullOrWhiteSpace(scope)) @@ -1326,25 +2062,63 @@ private static bool IsSessionCommandMethod(string method) private static string[] TryGetHandshakeScopes(JsonElement payload) { + if (payload.TryGetProperty("auth", out var authPayload) && + authPayload.ValueKind == JsonValueKind.Object && + authPayload.TryGetProperty("scopes", out var authScopes) && + authScopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(authScopes); + } + if (payload.TryGetProperty("scopes", out var scopesProp) && scopesProp.ValueKind == JsonValueKind.Array) { - var buffer = new string[scopesProp.GetArrayLength()]; - var count = 0; - foreach (var scope in scopesProp.EnumerateArray()) + return ReadStringArray(scopesProp); + } + + return []; + } + + private static string[] ReadStringArray(JsonElement array) + { + var buffer = new string[array.GetArrayLength()]; + var count = 0; + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) { - if (scope.ValueKind == JsonValueKind.String) - { - var value = scope.GetString(); - if (!string.IsNullOrWhiteSpace(value)) - buffer[count++] = value; - } + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + buffer[count++] = value; } - - return buffer[..count]; } - return []; + return buffer[..count]; + } + + /// + /// Resolves the effective sessionKey for a chat-related RPC, + /// preferring a non-empty caller-supplied value over the handshake + /// . Throws + /// if neither is usable — + /// callers MUST NOT fall back to a literal like "main", which + /// can drift from the canonical key the gateway echoes back. + /// + /// + /// Extracted as an internal static so unit tests can exercise the + /// pre-handshake throw without needing a live WebSocket — see + /// OpenClawGatewayClientSessionKeyTests. + /// + internal static string ResolveEffectiveSessionKey( + string? callerSessionKey, string? resolvedMainSessionKey, string operationName) + { + var effective = string.IsNullOrWhiteSpace(callerSessionKey) + ? resolvedMainSessionKey + : callerSessionKey.Trim(); + if (string.IsNullOrWhiteSpace(effective)) + throw new InvalidOperationException( + $"{operationName} requires a sessionKey, but the gateway handshake has not resolved one yet."); + return effective; } private static string? TryGetHandshakeMainSessionKey(JsonElement payload) @@ -1359,22 +2133,75 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) return null; } - if (!sessionDefaults.TryGetProperty("mainKey", out var mainKey) || mainKey.ValueKind != JsonValueKind.String) + // Prefer the canonical "mainSessionKey" (e.g. "agent:main:main") over + // the legacy alias "mainKey" (e.g. "main"). The gateway accepts both + // for chat.send routing, but the chat/session events it emits back + // are keyed by the canonical form. Using the alias here would cause + // the tray's local timeline (keyed by the alias) to diverge from the + // gateway's echo (keyed by canonical), stranding optimistic state. + if (sessionDefaults.TryGetProperty("mainSessionKey", out var canonical) && + canonical.ValueKind == JsonValueKind.String) { - return null; + var canonicalValue = canonical.GetString(); + if (!string.IsNullOrWhiteSpace(canonicalValue)) + return canonicalValue; } - var value = mainKey.GetString(); - return string.IsNullOrWhiteSpace(value) ? null : value; + if (sessionDefaults.TryGetProperty("mainKey", out var mainKey) && + mainKey.ValueKind == JsonValueKind.String) + { + var value = mainKey.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + + return null; } private static string? TryGetHandshakeDeviceToken(JsonElement payload) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) { if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) { return null; } + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) && + entry.TryGetProperty("deviceToken", out var roleToken) && + roleToken.ValueKind == JsonValueKind.String) + { + var roleTokenValue = roleToken.GetString(); + if (!string.IsNullOrWhiteSpace(roleTokenValue)) + return roleTokenValue; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + if (!authPayload.TryGetProperty("deviceToken", out var deviceToken) || deviceToken.ValueKind != JsonValueKind.String) { return null; @@ -1384,6 +2211,54 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) return string.IsNullOrWhiteSpace(value) ? null : value; } + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) + { + if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase)) + { + return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array + ? ReadStringArray(roleScopes) + : []; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + + if (authPayload.TryGetProperty("deviceToken", out var deviceToken) && + deviceToken.ValueKind == JsonValueKind.String && + authPayload.TryGetProperty("scopes", out var scopes) && + scopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(scopes); + } + + return null; + } + public string BuildMissingScopeFixCommands(string missingScope) { var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim(); @@ -1462,10 +2337,11 @@ public string BuildPairingApprovalFixCommands() return sb.ToString().TrimEnd(); } - private void HandleEvent(JsonElement root) + private void HandleEvent(JsonElement root, int rawMessageLength) { if (!root.TryGetProperty("event", out var eventProp)) return; var eventType = eventProp.GetString(); + _logger.Info($"[EVENT] Received event: {eventType}"); switch (eventType) { @@ -1473,7 +2349,7 @@ private void HandleEvent(JsonElement root) HandleConnectChallenge(root); break; case "agent": - HandleAgentEvent(root); + HandleAgentEvent(root, rawMessageLength); break; case "health": if (root.TryGetProperty("payload", out var hp) && @@ -1484,15 +2360,20 @@ private void HandleEvent(JsonElement root) } break; case "chat": - HandleChatEvent(root); + HandleChatEvent(root, rawMessageLength); break; case "session": HandleSessionEvent(root); break; case "node.pair.requested": case "node.pair.resolved": - // Refresh node pair list when pairing state changes + // Refresh node pair list when pairing state changes. Also + // refresh node.list because resolved decisions (in particular + // "removed") drop the node from the gateway's known set, so + // any UI mirroring node.list would otherwise show stale data + // until the next poll. _ = RequestNodePairListAsync(); + _ = RequestNodesAsync(); break; case "device.pair.requested": case "device.pair.resolved": @@ -1504,6 +2385,17 @@ private void HandleEvent(JsonElement root) if (root.TryGetProperty("payload", out var presPayload)) TryParsePresenceFromBroadcast(presPayload); break; + case "sessions.changed": + // Gateway broadcasts this after session mutations (patch, send, etc.). + // Re-request the full sessions list so we pick up model/thinking changes. + _logger.Info("[EVENT] sessions.changed received — refreshing sessions list"); + _ = RequestSessionsAsync(); + break; + case "cron": + // Gateway pushes cron events when jobs run/change — refresh the list + _ = RequestCronListAsync(); + _ = RequestCronStatusAsync(); + break; } } @@ -1525,19 +2417,46 @@ private void HandleConnectChallenge(JsonElement root) _challengeTimestampMs = ts; _currentChallengeNonce = nonce; - _logger.Info($"Received challenge, nonce: {nonce}"); - _ = SendConnectMessageAsync(nonce); + _logger.Info($"[HANDSHAKE] Received connect.challenge: nonce={nonce}, ts={ts}"); + _ = SendConnectSafeAsync(nonce); } - private void HandleAgentEvent(JsonElement root) + private async Task SendConnectSafeAsync(string? nonce) + { + try + { + await SendConnectMessageAsync(nonce); + } + catch (Exception ex) + { + _logger.Error($"[HANDSHAKE] FATAL: SendConnectMessageAsync threw: {ex}"); + } + } + + private void HandleAgentEvent(JsonElement root, int rawMessageLength) { if (!root.TryGetProperty("payload", out var payload)) return; - // sessionKey is inside payload, not root - var sessionKey = "unknown"; + // HIGH: never log raw agent event JSON — it can carry prompts, + // tool args/outputs, and URLs. Log shape only (type + length). + try + { + var streamHint = payload.TryGetProperty("stream", out var sh) ? sh.GetString() ?? "" : ""; + _logger.Debug($"Agent event received: stream={streamHint} len={rawMessageLength}"); + } + catch { } + + // sessionKey is inside payload, not root. We deliberately do NOT + // substitute a fallback like "unknown" or "main" — empty must + // propagate so the provider can drop the event and surface the + // protocol gap, rather than silently routing into a synthetic bucket. + var sessionKey = ""; if (payload.TryGetProperty("sessionKey", out var sk)) - sessionKey = sk.GetString() ?? "unknown"; - var isMain = sessionKey == "main" || sessionKey.Contains(":main:"); + sessionKey = sk.GetString() ?? ""; + if (string.IsNullOrEmpty(sessionKey)) + _logger.Warn("[GatewayClient] Agent event missing sessionKey; will be dropped downstream."); + var isMain = !string.IsNullOrEmpty(sessionKey) + && (sessionKey == "main" || sessionKey.Contains(":main:")); // Emit raw agent event (cloned for thread safety) try @@ -1659,7 +2578,10 @@ private void HandleToolEvent(JsonElement payload, string sessionKey, bool isMain Label = label }; - _logger.Info($"Tool: {toolName} ({phase}) — {label}"); + // HIGH: the activity Label may include user-provided values + // (commands, queries, file paths, URLs from tool args). Log only + // the tool name + phase — the label is for UI consumption. + _logger.Info($"Tool: {toolName} ({phase})"); ActivityChanged?.Invoke(this, activity); // Update tracked session @@ -1669,36 +2591,55 @@ private void HandleToolEvent(JsonElement payload, string sessionKey, bool isMain } } - private void HandleChatEvent(JsonElement root) + private void HandleChatEvent(JsonElement root, int rawMessageLength) { - var rawText = root.GetRawText(); - _logger.Debug($"Chat event received: {rawText[..Math.Min(200, rawText.Length)]}"); + // HIGH 4: never log chat content. Log shape only — the raw payload + // can include user prompts, assistant text, tool output, and even + // bearer tokens routed through the gateway in some flows. + _logger.Debug($"Chat event received: len={rawMessageLength}"); if (!root.TryGetProperty("payload", out var payload)) return; + EmitRawChatEvent(payload); + + // Extract sessionKey for the timeline-driving event. As with agent + // events, do NOT substitute a fallback like "main" — empty must + // propagate so the provider's empty-key drop policy can surface the + // protocol gap instead of silently routing into a synthetic bucket. + var sessionKey = ""; + if (payload.TryGetProperty("sessionKey", out var skProp)) + sessionKey = skProp.GetString() ?? ""; + if (string.IsNullOrEmpty(sessionKey)) + _logger.Warn("[GatewayClient] Chat event missing sessionKey; will be dropped downstream."); + + // Best-effort usage extraction — gateway emits this only on terminal + // (state="final") events in practice; we still read it defensively + // from common locations so any reasonable shape lights up the chat + // footer pills. + var (inTok, outTok, respTok, ctxPct) = ExtractChatUsage(payload); // Try new format: payload.message.role + payload.message.content[].text if (payload.TryGetProperty("message", out var message)) { - if (message.TryGetProperty("role", out var role) && role.GetString() == "assistant") + var role = message.TryGetProperty("role", out var roleProp) ? roleProp.GetString() ?? "" : ""; + var state = payload.TryGetProperty("state", out var stateProp) ? stateProp.GetString() : null; + + // Usage block may also live on the inner ``message`` object. + if (inTok is null && outTok is null && respTok is null && ctxPct is null) + (inTok, outTok, respTok, ctxPct) = ExtractChatUsage(message); + + if (message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) { - // Extract text from content array - if (message.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + var text = ExtractMessageText(message); + if (string.IsNullOrEmpty(text)) return; + + EmitChatMessageReceived(sessionKey, role, text, state, inTok, outTok, respTok, ctxPct); + + if (role == "assistant" && string.Equals(state, "final", StringComparison.OrdinalIgnoreCase)) { - foreach (var item in content.EnumerateArray()) - { - if (item.TryGetProperty("type", out var type) && type.GetString() == "text" && - item.TryGetProperty("text", out var textProp)) - { - var text = textProp.GetString() ?? ""; - if (!string.IsNullOrEmpty(text) && - payload.TryGetProperty("state", out var state) && - state.GetString() == "final") - { - _logger.Info($"Assistant response: {text[..Math.Min(100, text.Length)]}..."); - EmitChatNotification(text); - } - } - } + // HIGH 4: log shape only — content previously + // surfaced in the operator log. + _logger.Info($"Assistant response: role={role} state={state} len={text.Length}"); + EmitChatNotification(text); } } } @@ -1707,16 +2648,128 @@ private void HandleChatEvent(JsonElement root) else if (payload.TryGetProperty("text", out var textProp)) { var text = textProp.GetString() ?? ""; - if (payload.TryGetProperty("role", out var role) && - role.GetString() == "assistant" && - !string.IsNullOrEmpty(text)) + var role = payload.TryGetProperty("role", out var roleProp) ? roleProp.GetString() ?? "" : ""; + var state = payload.TryGetProperty("state", out var stateProp) ? stateProp.GetString() : null; + + if (!string.IsNullOrEmpty(text)) { - _logger.Info($"Assistant response (legacy): {text[..Math.Min(100, text.Length)]}"); - EmitChatNotification(text); + EmitChatMessageReceived(sessionKey, role, text, state, inTok, outTok, respTok, ctxPct); + + if (role == "assistant") + { + // HIGH 4: log shape only. + _logger.Info($"Assistant response (legacy): role={role} state={state} len={text.Length}"); + EmitChatNotification(text); + } } } } + /// + /// Defensive extraction of a usage / token block from a chat event + /// payload. Walks several known shapes: usage.{input,output,total, + /// inputTokens,outputTokens,totalTokens,promptTokens,completionTokens} + /// plus a few alternate top-level keys (tokens, contextPercent, + /// contextUsage). Returns nulls when nothing matches; callers + /// surface those as omitted footer pills. + /// + private static (int? input, int? output, int? response, int? contextPct) + ExtractChatUsage(JsonElement node) + { + if (node.ValueKind != JsonValueKind.Object) return (null, null, null, null); + + int? input = null, output = null, response = null, ctx = null; + + static int? ReadInt(JsonElement e, string key) + { + if (!e.TryGetProperty(key, out var v)) return null; + return v.ValueKind switch + { + JsonValueKind.Number when v.TryGetInt32(out var i) => i, + JsonValueKind.Number => (int)v.GetDouble(), + _ => null + }; + } + + // Walk usage / tokens nested objects. + foreach (var key in new[] { "usage", "tokens", "tokenUsage" }) + { + if (!node.TryGetProperty(key, out var u) || u.ValueKind != JsonValueKind.Object) + continue; + input ??= ReadInt(u, "input") ?? ReadInt(u, "inputTokens") ?? ReadInt(u, "promptTokens"); + output ??= ReadInt(u, "output") ?? ReadInt(u, "outputTokens") ?? ReadInt(u, "completionTokens"); + response ??= ReadInt(u, "total") ?? ReadInt(u, "totalTokens") ?? ReadInt(u, "response") ?? ReadInt(u, "responseTokens"); + ctx ??= ReadInt(u, "contextPercent") ?? ReadInt(u, "context") ?? ReadInt(u, "ctxPercent"); + } + + // Top-level fallbacks. + input ??= ReadInt(node, "inputTokens") ?? ReadInt(node, "promptTokens"); + output ??= ReadInt(node, "outputTokens") ?? ReadInt(node, "completionTokens"); + response ??= ReadInt(node, "totalTokens") ?? ReadInt(node, "responseTokens"); + ctx ??= ReadInt(node, "contextPercent") ?? ReadInt(node, "ctxPercent"); + + // Synthesize total from input+output when only the parts are known. + if (response is null && input is int inN && output is int outN) + response = inN + outN; + + return (input, output, response, ctx); + } + + private void EmitChatMessageReceived(string sessionKey, string role, string text, string? state, + int? inputTokens = null, int? outputTokens = null, int? responseTokens = null, int? contextPct = null) + { + try + { + ChatMessageReceived?.Invoke(this, new ChatMessageInfo + { + SessionKey = sessionKey, + Role = role, + Text = text, + State = state, + InputTokens = inputTokens, + OutputTokens = outputTokens, + ResponseTokens = responseTokens, + ContextPercent = contextPct + }); + } + catch (Exception ex) + { + _logger.Warn($"ChatMessageReceived handler threw: {ex.Message}"); + } + } + + private void EmitRawChatEvent(JsonElement payload) + { + try + { + var stream = "chat"; + if (payload.TryGetProperty("message", out var message) && + message.TryGetProperty("role", out var roleProp)) + { + stream = roleProp.GetString() ?? stream; + } + else if (payload.TryGetProperty("role", out var legacyRoleProp)) + { + stream = legacyRoleProp.GetString() ?? stream; + } + + var evt = new AgentEventInfo + { + RunId = payload.TryGetProperty("runId", out var rid) ? rid.GetString() ?? "" : "", + Seq = payload.TryGetProperty("seq", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number ? seqProp.GetInt32() : 0, + Stream = stream, + Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Data = payload.Clone(), + SessionKey = payload.TryGetProperty("sessionKey", out var sk) ? sk.GetString() : null + }; + ChatEventReceived?.Invoke(this, evt); + } + catch (Exception ex) + { + _logger.Warn($"Failed to emit chat event: {ex.Message}"); + } + } + private void EmitChatNotification(string text) { var displayText = text.Length > 200 ? text[..200] + "…" : text; @@ -1933,7 +2986,12 @@ private void PopulateSessionFromObject(SessionInfo session, JsonElement item) if (item.TryGetProperty("status", out var status)) session.Status = status.GetString() ?? "active"; if (item.TryGetProperty("model", out var model)) - session.Model = model.GetString(); + { + var newModel = model.GetString(); + if (session.Model != newModel) + _logger.Info($"[SESSION] {session.Key}: model changed '{session.Model}' → '{newModel}'"); + session.Model = newModel; + } if (item.TryGetProperty("channel", out var channel)) session.Channel = channel.GetString(); if (item.TryGetProperty("displayName", out var displayName)) @@ -2021,40 +3079,73 @@ private void ParseNodeList(JsonElement nodesPayload) "unknown"); var connected = GetOptionalBool(nodeElement, "connected"); var online = GetOptionalBool(nodeElement, "online"); + var paired = GetOptionalBool(nodeElement, "paired"); var capabilities = GetStringArray(nodeElement, "caps"); if (capabilities.Length == 0) capabilities = GetStringArray(nodeElement, "capabilities"); var commands = GetStringArray(nodeElement, "declaredCommands"); if (commands.Length == 0) commands = GetStringArray(nodeElement, "commands"); + var disabledCommands = GetStringArray(nodeElement, "disabledCommands"); var permissions = GetBoolDictionary(nodeElement, "permissions"); + var clientMode = GetString(nodeElement, "clientMode"); + + // Distinguish "user gave this node a name" from "we fell back + // to the id". The rename dialog uses this so it can prefill + // empty when the node has no explicit name (rather than + // pre-seeding the textbox with the id, which would otherwise + // get persisted as the new display name on Enter). + var explicitName = FirstNonEmpty( + GetString(nodeElement, "displayName"), + GetString(nodeElement, "name"), + GetString(nodeElement, "label")); + buffer[count++] = new GatewayNodeInfo { NodeId = nodeId!, - DisplayName = FirstNonEmpty( - GetString(nodeElement, "displayName"), - GetString(nodeElement, "name"), - GetString(nodeElement, "label"), - GetString(nodeElement, "shortId"), - nodeId)!, + DisplayName = !string.IsNullOrWhiteSpace(explicitName) + ? explicitName! + : FirstNonEmpty(GetString(nodeElement, "shortId"), nodeId)!, + HasExplicitDisplayName = !string.IsNullOrWhiteSpace(explicitName), Mode = FirstNonEmpty( GetString(nodeElement, "mode"), - GetString(nodeElement, "clientMode"), + clientMode, "node")!, Status = status!, Platform = FirstNonEmpty( GetString(nodeElement, "platform"), GetString(nodeElement, "os")), - LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? + // Gateway NodeListNode wire schema uses *Ms suffix; older + // fallbacks kept for compatibility with mocks/tests. + // ConnectedAt is parsed independently below — do NOT fall + // back to it here, otherwise the UI shows the same value + // twice as both "Connected Xm ago" and "Seen Xm ago". + LastSeen = ParseUnixTimestampMs(nodeElement, "lastSeenAtMs") ?? + ParseUnixTimestampMs(nodeElement, "lastSeenAt") ?? ParseUnixTimestampMs(nodeElement, "lastSeen") ?? - ParseUnixTimestampMs(nodeElement, "updatedAt") ?? - ParseUnixTimestampMs(nodeElement, "connectedAt"), + ParseUnixTimestampMs(nodeElement, "updatedAt"), + ConnectedAt = ParseUnixTimestampMs(nodeElement, "connectedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "connectedAt"), + ApprovedAt = ParseUnixTimestampMs(nodeElement, "approvedAtMs") ?? + ParseUnixTimestampMs(nodeElement, "approvedAt"), + LastSeenReason = GetString(nodeElement, "lastSeenReason"), Capabilities = capabilities.ToList(), Commands = commands.ToList(), + DisabledCommands = disabledCommands.ToList(), Permissions = permissions, CapabilityCount = capabilities.Length, CommandCount = commands.Length, + Version = GetString(nodeElement, "version"), + CoreVersion = GetString(nodeElement, "coreVersion"), + UiVersion = GetString(nodeElement, "uiVersion"), + ClientId = GetString(nodeElement, "clientId"), + ClientMode = clientMode, + DeviceFamily = GetString(nodeElement, "deviceFamily"), + ModelIdentifier = GetString(nodeElement, "modelIdentifier"), + RemoteIp = GetString(nodeElement, "remoteIp"), + PathEnv = GetString(nodeElement, "pathEnv"), + IsPaired = paired ?? false, IsOnline = online ?? connected ?? status is "ok" or "online" or "connected" or "ready" or "active" }; } diff --git a/src/OpenClaw.Shared/SandboxAccessTypes.cs b/src/OpenClaw.Shared/SandboxAccessTypes.cs new file mode 100644 index 000000000..372a0aa37 --- /dev/null +++ b/src/OpenClaw.Shared/SandboxAccessTypes.cs @@ -0,0 +1,31 @@ +namespace OpenClaw.Shared; + +/// +/// Clipboard access policy for sandboxed payloads. Mirrors MXC's +/// ClipboardPolicy values (none / read / write / all). +/// +public enum SandboxClipboardMode +{ + None, + Read, + Write, + Both, +} + +/// +/// Whether a folder is exposed read-only or read-write to the sandbox. +/// +public enum SandboxFolderAccess +{ + ReadOnly, + ReadWrite, +} + +/// +/// User-picked custom folder grant. Persisted in SettingsData. +/// +public sealed class SandboxCustomFolder +{ + public string Path { get; set; } = ""; + public SandboxFolderAccess Access { get; set; } = SandboxFolderAccess.ReadOnly; +} diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index f1983d88a..a0e850652 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -9,15 +9,19 @@ namespace OpenClaw.Shared; public class SettingsData { public string? GatewayUrl { get; set; } - public string? Token { get; set; } - public string? BootstrapToken { get; set; } public bool UseSshTunnel { get; set; } = false; public string? SshTunnelUser { get; set; } public string? SshTunnelHost { get; set; } public int SshTunnelRemotePort { get; set; } = 18789; public int SshTunnelLocalPort { get; set; } = 18789; - public bool AutoStart { get; set; } + public bool AutoStart { get; set; } = true; public bool GlobalHotkeyEnabled { get; set; } = true; + /// + /// One-shot gate: set to true after the post-onboarding "first-run" bootstrap + /// kickoff message has been injected into the chat exactly once. Subsequent + /// chat-window launches skip injection. + /// + public bool HasInjectedFirstRunBootstrap { get; set; } public bool ShowNotifications { get; set; } = true; public string? NotificationSound { get; set; } public bool NotifyHealth { get; set; } = true; @@ -32,10 +36,28 @@ public class SettingsData public bool NodeCanvasEnabled { get; set; } = true; public bool NodeScreenEnabled { get; set; } = true; public bool NodeCameraEnabled { get; set; } = true; + public bool ScreenRecordingConsentGiven { get; set; } = false; + public bool CameraRecordingConsentGiven { get; set; } = false; public bool NodeLocationEnabled { get; set; } = true; public bool NodeBrowserProxyEnabled { get; set; } = true; + public bool NodeSttEnabled { get; set; } = false; + /// STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US". + public string SttLanguage { get; set; } = "auto"; + /// Whisper model name: "tiny", "base", or "small". + public string SttModelName { get; set; } = "base"; + /// Seconds of silence before auto-submit in voice chat mode. + public float SttSilenceTimeout { get; set; } = 2.5f; + /// Enable TTS playback of responses during voice sessions. + public bool VoiceTtsEnabled { get; set; } = true; + /// Play audio feedback chimes on listen start/stop. + public bool VoiceAudioFeedback { get; set; } = true; public bool NodeTtsEnabled { get; set; } = false; - public string TtsProvider { get; set; } = "windows"; + public string TtsProvider { get; set; } = OpenClaw.Shared.Capabilities.TtsCapability.PiperProvider; + /// Persisted: whether the Hub's NavigationView pane is expanded + /// (true) or collapsed/compact (false). Default true. + public bool HubNavPaneOpen { get; set; } = true; + /// Optional Windows TTS voice id (or display name). Empty = system default. + public string? TtsWindowsVoiceId { get; set; } /// /// ElevenLabs API key storage slot. When persisted by the Windows tray's /// SettingsManager this is an opaque dpapi:-prefixed blob, not plaintext. @@ -43,6 +65,8 @@ public class SettingsData public string? TtsElevenLabsApiKey { get; set; } public string? TtsElevenLabsModel { get; set; } public string? TtsElevenLabsVoiceId { get; set; } + /// Piper voice identifier, e.g. "en_US-amy-low". Voice file is downloaded on first use. + public string TtsPiperVoiceId { get; set; } = "en_US-amy-low"; /// Run the local MCP HTTP server. Independent of EnableNodeMode. public bool EnableMcpServer { get; set; } = false; /// @@ -62,8 +86,64 @@ public class SettingsData public string? SkippedUpdateTag { get; set; } public bool NotifyChatResponses { get; set; } = true; public bool PreferStructuredCategories { get; set; } = true; + /// + /// When true, the Hub Chat tab and tray Chat popup host the legacy + /// WebView2-based gateway chat UI instead of the native chat surface. + /// Default false (native chat). Surfaced as a toggle in SettingsPage's + /// "User interface" section. + /// + public bool UseLegacyWebChat { get; set; } = false; public List? UserRules { get; set; } + // ── MXC sandbox ───────────────────────────────────────────────────── + /// + /// Master switch for system.run containment. When true (default), + /// system.run runs inside an MXC AppContainer; if MXC is unavailable on + /// this host the invocation is denied — there is no host fallback. When + /// false, system.run runs on the host as it did before MXC support + /// was added. + /// + public bool SystemRunSandboxEnabled { get; set; } = true; + + /// + /// When sandboxed, allow system.run commands to reach the public internet. + /// Default false — most shell commands are local-only. + /// + public bool SystemRunAllowOutbound { get; set; } = false; + + /// + /// Clipboard access policy inside the sandbox. Default None — the + /// sandboxed payload cannot see or change the user's clipboard. + /// + public SandboxClipboardMode SandboxClipboard { get; set; } = SandboxClipboardMode.None; + + /// + /// Per-folder access grants. Each well-known user folder can be + /// individually opened to the sandbox in read-only or read-write mode. + /// Default for all: null (blocked). + /// + public SandboxFolderAccess? SandboxDocumentsAccess { get; set; } + public SandboxFolderAccess? SandboxDownloadsAccess { get; set; } + public SandboxFolderAccess? SandboxDesktopAccess { get; set; } + + /// + /// User-picked custom folders to expose to the sandbox. + /// + public List? SandboxCustomFolders { get; set; } + + /// + /// Maximum execution time per sandboxed command. Default 30s. + /// Range enforced in UI: 5_000 .. 300_000 ms. + /// + public int SandboxTimeoutMs { get; set; } = 30_000; + + /// + /// Maximum stdout/stderr returned from a sandboxed command. Default 4 MiB. + /// + public long SandboxMaxOutputBytes { get; set; } = 4 * 1024 * 1024; + + // ── (Voice / STT settings consolidated into the block above.) ── + private static readonly JsonSerializerOptions s_options = new() { WriteIndented = true, diff --git a/src/OpenClaw.Shared/SshTunnelCommandLine.cs b/src/OpenClaw.Shared/SshTunnelCommandLine.cs index e9a26d70e..54bc5cbd1 100644 --- a/src/OpenClaw.Shared/SshTunnelCommandLine.cs +++ b/src/OpenClaw.Shared/SshTunnelCommandLine.cs @@ -8,6 +8,16 @@ public static class SshTunnelCommandLine private static readonly Regex s_validSshUser = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled); private static readonly Regex s_validSshHost = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled); + // Fixed SSH options shared by every tunnel invocation. + // Centralised here so the connection policy is visible and easy to review or adjust. + private const string BaseOptions = + "-o BatchMode=yes " + + "-o ExitOnForwardFailure=yes " + + "-o ServerAliveInterval=15 " + + "-o ServerAliveCountMax=3 " + + "-o TCPKeepAlive=yes " + + "-N "; + public static string BuildArguments(string user, string host, int remotePort, int localPort) => BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward: false); @@ -33,13 +43,7 @@ public static string BuildArguments( ValidateBrowserProxyPort(localPort, nameof(localPort)); } - var sb = new StringBuilder(); - sb.Append("-o BatchMode=yes "); - sb.Append("-o ExitOnForwardFailure=yes "); - sb.Append("-o ServerAliveInterval=15 "); - sb.Append("-o ServerAliveCountMax=3 "); - sb.Append("-o TCPKeepAlive=yes "); - sb.Append("-N "); + var sb = new StringBuilder(BaseOptions); AppendLocalForward(sb, localPort, remotePort); if (includeBrowserProxyForward) AppendLocalForward(sb, localPort + 2, remotePort + 2); diff --git a/src/OpenClaw.Shared/TokenSanitizer.cs b/src/OpenClaw.Shared/TokenSanitizer.cs index 7e5026c09..e8fa22054 100644 --- a/src/OpenClaw.Shared/TokenSanitizer.cs +++ b/src/OpenClaw.Shared/TokenSanitizer.cs @@ -12,6 +12,10 @@ public static class TokenSanitizer @"""(?[^""]*(?:token|secret|bearer|authorization)[^""]*)""\s*:\s*""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private static readonly Regex BareGatewayHexTokenPattern = new( + @"(? $"\"{match.Groups["key"].Value}\":\"[REDACTED]\""); + sanitized = BareGatewayHexTokenPattern.Replace(sanitized, "[REDACTED_TOKEN]"); return LongBase64UrlPattern.Replace(sanitized, "[REDACTED_TOKEN]"); } } diff --git a/src/OpenClaw.Shared/WebSocketClientBase.cs b/src/OpenClaw.Shared/WebSocketClientBase.cs index aecde0a02..713574c67 100644 --- a/src/OpenClaw.Shared/WebSocketClientBase.cs +++ b/src/OpenClaw.Shared/WebSocketClientBase.cs @@ -21,6 +21,7 @@ public abstract class WebSocketClientBase : IDisposable private bool _disposed; private int _reconnectAttempts; private int _reconnectLoopActive; + private readonly SemaphoreSlim _sendLock = new(1, 1); private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 }; protected readonly string _token; @@ -251,6 +252,10 @@ protected async Task ReconnectWithBackoffAsync() while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect()) { var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)]; + // Add 0-25% jitter to prevent thundering herd when multiple clients + // (operator + node) reconnect on the same schedule + var jitter = Random.Shared.Next(0, delay / 4); + delay += jitter; _reconnectAttempts++; _logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})"); RaiseStatusChanged(ConnectionStatus.Connecting); @@ -291,33 +296,58 @@ protected async Task ReconnectWithBackoffAsync() /// Send a text message over the WebSocket. Thread-safe. protected async Task SendRawAsync(string message) { - // Capture local reference to avoid TOCTOU race with reconnect/dispose - var ws = _webSocket; - if (ws?.State != WebSocketState.Open) return; + try + { + await _sendLock.WaitAsync(_cts.Token); + } + catch (OperationCanceledException) + { + return; + } + catch (ObjectDisposedException) + { + return; + } try { - // Rent a pooled buffer to avoid per-send heap allocations on the hot send path. - var byteCount = Encoding.UTF8.GetByteCount(message); - var buffer = ArrayPool.Shared.Rent(byteCount); + // Serialize sends; reconnect/dispose can still close the captured socket, + // so the send below keeps the existing state-change guards. + var ws = _webSocket; + if (ws?.State != WebSocketState.Open) return; + try { - var written = Encoding.UTF8.GetBytes(message, buffer); - await ws.SendAsync(buffer.AsMemory(0, written), - WebSocketMessageType.Text, true, _cts.Token); + // Rent a pooled buffer to avoid per-send heap allocations on the hot send path. + var byteCount = Encoding.UTF8.GetByteCount(message); + var buffer = ArrayPool.Shared.Rent(byteCount); + try + { + var written = Encoding.UTF8.GetBytes(message, buffer); + await ws.SendAsync(buffer.AsMemory(0, written), + WebSocketMessageType.Text, true, _cts.Token); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } - finally + catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested) { - ArrayPool.Shared.Return(buffer); + // Shutdown/reconnect canceled an in-flight send. + } + catch (ObjectDisposedException) + { + // WebSocket was disposed between state check and send. + } + catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.InvalidState) + { + _logger.Warn($"WebSocket send failed (state changed): {ex.Message}"); } } - catch (ObjectDisposedException) - { - // WebSocket was disposed between state check and send - } - catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.InvalidState) + finally { - _logger.Warn($"WebSocket send failed (state changed): {ex.Message}"); + _sendLock.Release(); } } diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index a9d4acdc1..53062377d 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; namespace OpenClaw.Shared; @@ -30,6 +31,16 @@ public class WindowsNodeClient : WebSocketClientBase private bool _isPaired; // Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken. private bool _pairingApprovedAwaitingReconnect; + // Persists across disconnect/error so ShouldAutoReconnect can block reconnect + // even after OnDisconnected clears _isPendingApproval. + private volatile bool _pairingBlocked; + private volatile bool _rateLimited; + private bool _useV2Signature; // true after v3 signature rejected by gateway + public bool UseV2Signature { get => _useV2Signature; set => _useV2Signature = value; } + // Bug 3: source-side idempotency for PairingStatusChanged. HandleHelloOk runs on every + // WS reconnect and re-fires PairingStatus.Paired even when nothing changed, causing a + // toast storm in the tray UI. Track the last emitted status and only fire on transitions. + private PairingStatus? _lastEmittedPairingStatus; private readonly string _gatewayToken; private readonly string? _bootstrapToken; @@ -41,12 +52,22 @@ public class WindowsNodeClient : WebSocketClientBase private static readonly Regex s_commandValidator = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled); + // Bounded concurrency for capability invocations: prevents a slow capability (e.g. a + // 5-minute screen.record) from blocking health pings on the same WS receive loop. + // Invocations are fire-and-forget off the receive loop; this semaphore caps concurrency + // at 8. When full, the gateway receives an immediate "node busy, retry" error response. + private readonly SemaphoreSlim _invokeSemaphore = new(8, 8); + // Events public event EventHandler? InvokeReceived; public event EventHandler? InvokeCompleted; public event EventHandler? PairingStatusChanged; public event EventHandler? HealthReceived; public event EventHandler? GatewaySelfUpdated; + /// Raised when a device token is received from the gateway during hello-ok handshake. + public event EventHandler? DeviceTokenReceived; + /// Raised when the hello-ok handshake completes successfully. + public event EventHandler? HandshakeSucceeded; public new bool IsConnected => _isConnected; public string? NodeId => _nodeId; @@ -57,7 +78,7 @@ public class WindowsNodeClient : WebSocketClientBase public bool IsPendingApproval => _isPendingApproval; /// True if device is paired via a stored token or an explicit gateway approval event. - public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); /// Device ID for display/approval (first 16 chars of full ID) public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16 @@ -70,11 +91,23 @@ public class WindowsNodeClient : WebSocketClientBase /// Human-readable display name surfaced to the gateway and other nodes. public string DisplayName => _registration.DisplayName; + /// Exposes the registration for internal diagnostics only. + internal NodeRegistration Registration => _registration; + + /// Number of registered capabilities (read-only diagnostic accessor). + public int RegisteredCapabilityCount => _registration.Capabilities.Count; + + /// Number of registered commands (read-only diagnostic accessor). + public int RegisteredCommandCount => _registration.Commands.Count; + + /// First few registered command names for diagnostic logging. + public IEnumerable RegisteredCommandsSample => _registration.Commands.Take(5); + protected override int ReceiveBufferSize => 65536; protected override string ClientRole => "node"; public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null) - : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger) + : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger) { _gatewayToken = NormalizeOptionalCredential(token); _bootstrapToken = NormalizeOptionalCredential(bootstrapToken); @@ -98,8 +131,14 @@ private static string NormalizeOptionalCredential(string? credential) return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential; } - private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath) + private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger) { + var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger); + if (!string.IsNullOrEmpty(storedNodeToken)) + { + return storedNodeToken; + } + var gatewayToken = NormalizeOptionalCredential(token); if (!string.IsNullOrEmpty(gatewayToken)) { @@ -112,13 +151,26 @@ private static string ResolveRequiredCredential(string? token, string? bootstrap return bootstrap; } - var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath); - if (!string.IsNullOrEmpty(storedDeviceToken)) + throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); + } + + public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? logger = null) + { + return !string.IsNullOrWhiteSpace(TryLoadStoredNodeToken(dataPath, logger)); + } + + private static string? TryLoadStoredNodeToken(string dataPath, IOpenClawLogger? logger) + { + try { - return storedDeviceToken; + var identity = new DeviceIdentity(dataPath, logger); + identity.Initialize(); + return string.IsNullOrWhiteSpace(identity.NodeDeviceToken) ? null : identity.NodeDeviceToken; + } + catch + { + return null; } - - throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); } /// @@ -126,7 +178,10 @@ private static string ResolveRequiredCredential(string? token, string? bootstrap /// public void RegisterCapability(INodeCapability capability) { - _capabilities.Add(capability); + if (!_capabilities.Contains(capability)) + { + _capabilities.Add(capability); + } // Update registration if (!_registration.Capabilities.Contains(capability.Category)) @@ -186,7 +241,7 @@ protected override async Task ProcessMessageAsync(string json) try { // Log raw messages at debug level (visible in dbgview, not in log file noise) - _logger.Debug($"[NODE RX] {json}"); + _logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}"); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -277,11 +332,12 @@ private void HandlePairingRequestedEvent(JsonElement root, string? eventType) _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _pairingApprovedAwaitingReconnect = false; _logger.Info($"[NODE] Pairing requested for this device via {eventType}"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, $"Run: openclaw devices approve {ShortDeviceId}...")); @@ -310,9 +366,10 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve { _isPendingApproval = false; _isPaired = true; + _pairingBlocked = false; // Allow reconnect after approval _pairingApprovedAwaitingReconnect = true; - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId, "Pairing approved; reconnecting to refresh node state.")); @@ -328,7 +385,7 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve _isPaired = false; _pairingApprovedAwaitingReconnect = false; - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Rejected, _deviceIdentity.DeviceId, null)); @@ -385,7 +442,8 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) JsonElement args = default; if (payload.TryGetProperty("args", out var argsEl)) { - args = argsEl; + // Clone to ensure the JsonElement survives document disposal after fire-and-forget + args = argsEl.Clone(); } else if (payload.TryGetProperty("paramsJSON", out var paramsJsonProp)) { @@ -426,27 +484,49 @@ private async Task HandleNodeInvokeEventAsync(JsonElement root) return; } - var stopwatch = Stopwatch.StartNew(); - try + // Reject immediately if all invoke slots are in use; otherwise fire-and-forget off + // the receive loop so that health/pair events aren't blocked by slow capabilities. + if (!_invokeSemaphore.Wait(0)) { - // Raise event for UI notification - InvokeReceived?.Invoke(this, request); - - // Execute the command - var response = await capability.ExecuteAsync(request); - response.Id = requestId; - - await SendNodeInvokeResultAsync(requestId, response.Ok, response.Payload, response.Error); - stopwatch.Stop(); - RaiseInvokeCompleted(requestId, command, response.Ok, response.Error, stopwatch.Elapsed); + _logger.Warn($"[NODE] Invoke slots full, rejecting {command} ({requestId})"); + await SendNodeInvokeResultAsync(requestId, false, null, "node busy, retry"); + RaiseInvokeCompleted(requestId, command, false, "node busy, retry", TimeSpan.Zero); + return; } - catch (Exception ex) + + var ct = CancellationToken; + _ = Task.Run(async () => { - _logger.Error($"[NODE] Command execution failed: {command}", ex); - await SendNodeInvokeResultAsync(requestId, false, null, $"Execution failed: {ex.Message}"); - stopwatch.Stop(); - RaiseInvokeCompleted(requestId, command, false, $"Execution failed: {ex.Message}", stopwatch.Elapsed); - } + var stopwatch = Stopwatch.StartNew(); + try + { + // Raise event for UI notification + InvokeReceived?.Invoke(this, request); + + // Execute the command + var response = await capability.ExecuteAsync(request, ct); + response.Id = requestId; + + await SendNodeInvokeResultAsync(requestId, response.Ok, response.Payload, response.Error); + stopwatch.Stop(); + RaiseInvokeCompleted(requestId, command, response.Ok, response.Error, stopwatch.Elapsed); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Client is shutting down; response is no longer needed + } + catch (Exception ex) + { + _logger.Error($"[NODE] Command execution failed: {command}", ex); + stopwatch.Stop(); + try { await SendNodeInvokeResultAsync(requestId, false, null, "Command execution failed"); } catch { } + RaiseInvokeCompleted(requestId, command, false, "Command execution failed", stopwatch.Elapsed); + } + finally + { + _invokeSemaphore.Release(); + } + }, CancellationToken.None); } private async Task SendNodeInvokeResultAsync(string requestId, bool success, object? payload, string? error) @@ -474,8 +554,6 @@ private async Task SendNodeInvokeResultAsync(string requestId, bool success, obj private async Task HandleConnectChallengeAsync(JsonElement root) { - _logger.Info("Received connect challenge, sending node registration..."); - string? nonce = null; long ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -490,6 +568,8 @@ private async Task HandleConnectChallengeAsync(JsonElement root) ts = tsProp.GetInt64(); } } + + _logger.Info($"[HANDSHAKE] Received connect.challenge: nonce={nonce}, ts={ts}"); _pendingNonce = nonce; await SendNodeConnectAsync(nonce, ts); @@ -499,13 +579,23 @@ private async Task HandleConnectChallengeAsync(JsonElement root) private async Task SendNodeConnectAsync(string? nonce, long ts) { - var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken); + var (auth, tokenForSig) = BuildConnectAuth(); + var authType = auth.ContainsKey("deviceToken") ? "deviceToken" + : auth.ContainsKey("bootstrapToken") ? "bootstrapToken" : "token"; - _logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})"); + _logger.Info($"[HANDSHAKE] → Sending connect:"); + _logger.Info($"[HANDSHAKE] role=node, clientId={ClientId}, mode=node"); + _logger.Info($"[HANDSHAKE] caps={_registration.Capabilities.Count}: [{string.Join(", ", _registration.Capabilities)}]"); + _logger.Info($"[HANDSHAKE] commands={_registration.Commands.Count}: [{string.Join(", ", _registration.Commands)}]"); + _logger.Info($"[HANDSHAKE] isBootstrap={usingBootstrap}, hasNodeDeviceToken={isPaired}"); + _logger.Info($"[HANDSHAKE] deviceId={_deviceIdentity.DeviceId[..Math.Min(16, _deviceIdentity.DeviceId.Length)]}..."); + _logger.Info($"[HANDSHAKE] nonce={nonce?[..Math.Min(15, nonce?.Length ?? 0)]}..."); + _logger.Info($"[HANDSHAKE] signature format={(_useV2Signature ? "v2" : "v3")}, platform=windows, family=desktop"); + _logger.Info($"[HANDSHAKE] auth: {{{authType}}}"); await SendRawAsync(BuildNodeConnectMessage(nonce, ts)); - _logger.Info($"Sent node registration with device ID: {_deviceIdentity.DeviceId[..16]}..., paired: {isPaired}"); } private string BuildNodeConnectMessage(string? nonce, long ts) @@ -519,7 +609,14 @@ private string BuildNodeConnectMessage(string? nonce, long ts) { try { - signature = _deviceIdentity.SignPayload(nonce, signedAt, ClientId, tokenForSignature); + signature = _useV2Signature + ? _deviceIdentity.SignConnectPayloadV2( + nonce, signedAt, ClientId, "node", "node", + Array.Empty(), tokenForSignature) + : _deviceIdentity.SignConnectPayloadV3( + nonce, signedAt, ClientId, "node", "node", + Array.Empty(), tokenForSignature, + "windows", "desktop"); } catch (Exception ex) { @@ -536,7 +633,7 @@ private string BuildNodeConnectMessage(string? nonce, long ts) @params = new { minProtocol = 3, - maxProtocol = 3, + maxProtocol = 4, client = new { id = ClientId, // Must match what we sign in payload @@ -569,9 +666,9 @@ private string BuildNodeConnectMessage(string? nonce, long ts) private (Dictionary Auth, string TokenForSignature) BuildConnectAuth() { - if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { - return (new Dictionary { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken); + return (new Dictionary { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken); } if (!string.IsNullOrEmpty(_bootstrapToken)) @@ -600,9 +697,11 @@ private void HandleResponse(JsonElement root) // Handle hello-ok (successful registration) if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") { + _logger.Info("[HANDSHAKE] Received hello-ok!"); PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect; _isConnected = true; + _rateLimited = false; // Clear transient rate-limit on successful connect ResetReconnectAttempts(); // Extract node ID if returned @@ -627,8 +726,9 @@ private void HandleResponse(JsonElement root) _isPaired = true; _pairingApprovedAwaitingReconnect = false; _logger.Info("Received device token - we are now paired!"); - _deviceIdentity.StoreDeviceToken(deviceToken); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + _deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload)); + DeviceTokenReceived?.Invoke(this, new DeviceTokenReceivedEventArgs(deviceToken, TryGetAuthScopes(authPayload), "node")); + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId, wasWaiting ? "Pairing approved!" : null)); @@ -641,7 +741,7 @@ private void HandleResponse(JsonElement root) // Skip this block if we already fired PairingStatusChanged above via gotNewToken. if (!gotNewToken) { - if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { if (reconnectingAfterApproval) { @@ -654,9 +754,10 @@ private void HandleResponse(JsonElement root) { _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _logger.Info("Not yet paired - check 'openclaw devices list' for pending approval"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, $"Run: openclaw devices approve {ShortDeviceId}...")); @@ -668,16 +769,33 @@ private void HandleResponse(JsonElement root) _isPaired = true; _pairingApprovedAwaitingReconnect = false; _logger.Info("Already paired with stored device token"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId)); } } RaiseStatusChanged(ConnectionStatus.Connected); + HandshakeSucceeded?.Invoke(this, EventArgs.Empty); } } + /// + /// Bug 3: source-side suppression of duplicate PairingStatusChanged events from + /// HandleHelloOk on WS reconnects. Only fire when the status differs from the last + /// emitted status (or when nothing has been emitted yet). + /// + private void EmitPairingStatusOnTransition(PairingStatusEventArgs args) + { + if (_lastEmittedPairingStatus == args.Status) + { + _logger.Info($"[NODE] Suppressing duplicate pairing status event: {args.Status} for {args.DeviceId}"); + return; + } + _lastEmittedPairingStatus = args.Status; + PairingStatusChanged?.Invoke(this, args); + } + private void HandleRequestError(JsonElement root) { var error = "Unknown error"; @@ -708,6 +826,8 @@ private void HandleRequestError(JsonElement root) } } + _logger.Info($"[HANDSHAKE] Connect error: message=\"{error}\", code={errorCode}"); + if (string.Equals(errorCode, "NOT_PAIRED", StringComparison.OrdinalIgnoreCase)) { if (_isPendingApproval) @@ -717,6 +837,7 @@ private void HandleRequestError(JsonElement root) _isPendingApproval = true; _isPaired = false; + _pairingBlocked = true; _pairingApprovedAwaitingReconnect = false; var detail = !string.IsNullOrWhiteSpace(pairingRequestId) @@ -724,14 +845,38 @@ private void HandleRequestError(JsonElement root) : $"Run: openclaw devices approve {ShortDeviceId}..."; _logger.Info($"[NODE] Pairing required for this device; reason={pairingReason ?? "unknown"}, requestId={pairingRequestId ?? "none"}"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, - detail)); + detail, + requestId: pairingRequestId)); + return; + } + + // Rate-limit / terminal auth errors — stop reconnecting + if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) || + error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) || + error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) || + error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase)) + { + _rateLimited = true; + _logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {TokenSanitizer.Sanitize(error)}"); + RaiseStatusChanged(ConnectionStatus.Error); return; } - _logger.Error($"Node registration failed: {error} (code: {errorCode})"); + // v3 signature rejected — fall back to v2 for this session + if (error.Contains("device signature invalid", StringComparison.OrdinalIgnoreCase) || + errorCode == "DEVICE_AUTH_SIGNATURE_INVALID") + { + if (!_useV2Signature) + { + _useV2Signature = true; + _logger.Warn("[NODE] v3 signature rejected, will use v2 on reconnect"); + } + } + + _logger.Error($"Node registration failed: {TokenSanitizer.Sanitize(error)} (code: {errorCode})"); RaiseStatusChanged(ConnectionStatus.Error); } @@ -779,6 +924,27 @@ private static bool TryGetString(JsonElement element, string propertyName, out s value = prop.GetString(); return !string.IsNullOrWhiteSpace(value); } + + private static string[]? TryGetAuthScopes(JsonElement authPayload) + { + if (!authPayload.TryGetProperty("scopes", out var scopes) || scopes.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = new List(); + foreach (var item in scopes.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + values.Add(value); + } + } + + return values.Count == 0 ? null : values.Distinct(StringComparer.Ordinal).ToArray(); + } private async Task HandleRequestAsync(JsonElement root) { @@ -840,8 +1006,9 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) return; } + // Clone args to ensure it survives document disposal after fire-and-forget var args = paramsEl.TryGetProperty("args", out var argsEl) - ? argsEl + ? argsEl.Clone() : default; _logger.Info($"Received node.invoke: {command}"); @@ -864,27 +1031,49 @@ private async Task HandleNodeInvokeAsync(JsonElement root, string? requestId) return; } - var stopwatch = Stopwatch.StartNew(); - try + // Reject immediately if all invoke slots are in use; otherwise fire-and-forget off + // the receive loop so that health/pair events aren't blocked by slow capabilities. + if (!_invokeSemaphore.Wait(0)) { - // Raise event for UI notification - InvokeReceived?.Invoke(this, request); - - // Execute the command - var response = await capability.ExecuteAsync(request); - response.Id = requestId; - - await SendInvokeResponseAsync(response); - stopwatch.Stop(); - RaiseInvokeCompleted(requestId, command, response.Ok, response.Error, stopwatch.Elapsed); + _logger.Warn($"Invoke slots full, rejecting {command} ({requestId})"); + await SendErrorResponseAsync(requestId, "node busy, retry"); + RaiseInvokeCompleted(requestId, command, false, "node busy, retry", TimeSpan.Zero); + return; } - catch (Exception ex) + + var ct = CancellationToken; + _ = Task.Run(async () => { - _logger.Error($"Command execution failed: {command}", ex); - await SendErrorResponseAsync(requestId, $"Execution failed: {ex.Message}"); - stopwatch.Stop(); - RaiseInvokeCompleted(requestId, command, false, $"Execution failed: {ex.Message}", stopwatch.Elapsed); - } + var stopwatch = Stopwatch.StartNew(); + try + { + // Raise event for UI notification + InvokeReceived?.Invoke(this, request); + + // Execute the command + var response = await capability.ExecuteAsync(request, ct); + response.Id = requestId; + + await SendInvokeResponseAsync(response); + stopwatch.Stop(); + RaiseInvokeCompleted(requestId, command, response.Ok, response.Error, stopwatch.Elapsed); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // Client is shutting down; response is no longer needed + } + catch (Exception ex) + { + _logger.Error($"Command execution failed: {command}", ex); + stopwatch.Stop(); + try { await SendErrorResponseAsync(requestId, "Command execution failed"); } catch { } + RaiseInvokeCompleted(requestId, command, false, "Command execution failed", stopwatch.Elapsed); + } + finally + { + _invokeSemaphore.Release(); + } + }, CancellationToken.None); } private void RaiseInvokeCompleted(string requestId, string command, bool ok, string? error, TimeSpan duration) @@ -930,16 +1119,8 @@ private async Task SendErrorResponseAsync(string requestId, string error) } /// - /// Send a generic node-event to the gateway. Mirrors the Android - /// GatewaySession.sendNodeEvent wire shape: a JSON-RPC request with - /// method node.event and params { event, payloadJSON }, - /// where payloadJSON is the inner payload as a *string*, not a - /// nested object. The gateway's node-event dispatcher - /// (server-node-events.ts) then re-parses it. - /// - /// Returns false when not connected so callers can surface a status to the - /// renderer (e.g. clear a button-loading spinner with an error). Throws on - /// argument problems but swallows transport-layer errors as false. + /// Sends a node.event request with JSON payload. + /// Returns false when not connected or when the transport send fails. /// public async Task SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload) { @@ -947,9 +1128,6 @@ public async Task SendNodeEventAsync(string eventName, System.Text.Json.No if (payload is null) throw new ArgumentNullException(nameof(payload)); if (!_isConnected) return false; - // payloadJSON is a STRING containing JSON, matching the Android wire - // shape and the gateway's parser at server-node-events.ts:380 which - // does JSON.parse(evt.payloadJSON). var msg = new { type = "req", @@ -997,17 +1175,39 @@ private void PublishGatewaySelf(GatewaySelfInfo info) GatewaySelfUpdated?.Invoke(this, info); } + protected override bool ShouldAutoReconnect() + { + // Don't reconnect while awaiting pairing approval — each reconnect + // generates a new pairing request on the gateway, causing a storm. + // _pairingBlocked survives OnDisconnected (which clears _isPendingApproval). + if (_pairingBlocked) + return false; + + if (_rateLimited) + return false; + + return true; + } + protected override void OnDisconnected() { _isConnected = false; - _isPendingApproval = false; - _isPaired = false; + // Don't reset pairing state when disconnected due to pairing — gateway + // closes the socket after PAIRING_REQUIRED but we're still waiting for approval + if (!_pairingBlocked) + { + _isPendingApproval = false; + _isPaired = false; + } } protected override void OnError(Exception ex) { _isConnected = false; - _isPendingApproval = false; - _isPaired = false; + if (!_pairingBlocked) + { + _isPendingApproval = false; + _isPaired = false; + } } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml b/src/OpenClaw.Tray.WinUI/App.xaml index 6c3e4cd78..6a8c281be 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml +++ b/src/OpenClaw.Tray.WinUI/App.xaml @@ -9,18 +9,21 @@ - - - - - - - - + + + + diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index faa5db80b..72d9e7023 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1,34 +1,38 @@ using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Shared; -using OpenClaw.Shared.Capabilities; using OpenClawTray.Dialogs; using OpenClawTray.Helpers; using OpenClawTray.Services; using OpenClawTray.Windows; using OpenClawTray.Onboarding; +using OpenClaw.Connection; +using OpenClawTray.Services.LocalGatewaySetup; using System; using System.Collections.Frozen; using System.Collections.Generic; +using System.Globalization; using System.Diagnostics; using System.Drawing; using System.IO; using System.IO.Pipes; using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Updatum; -using Windows.ApplicationModel.DataTransfer; using WinUIEx; namespace OpenClawTray; -public partial class App : Application +public partial class App : Application, OpenClawTray.Services.IAppCommands { - private const string PipeName = "OpenClawTray-DeepLink"; - internal static readonly UpdatumManager AppUpdater = new("shanselman", "openclaw-windows-hub") { FetchOnlyLatestRelease = true, @@ -36,16 +40,145 @@ public partial class App : Application }; private TrayIcon? _trayIcon; - private OpenClawGatewayClient? _gatewayClient; + private GatewayConnectionManager? _connectionManager; + private GatewayRegistry? _gatewayRegistry; + private OpenClawTray.Chat.OpenClawChatCoordinator? _chatCoordinator; + /// + /// Cached reference to the most recently constructed local-setup engine. Used by + /// to suppress the "copy pairing command" toast + /// during Phase 14 auto-pair (Bug #2, manual test 2026-05-05). Null when no local + /// setup has run in this app lifetime. + /// + private LocalGatewaySetupEngine? _localSetupEngine; + /// + /// When true, the connection manager suppresses node auto-connect after operator handshake. + /// Set during the WSL local-setup flow so the engine controls node pairing in its own phase. + /// + private volatile bool _suppressNodeDuringSetup; /// The persistent gateway client. Used by the onboarding wizard for RPC calls. - public OpenClawGatewayClient? GatewayClient => _gatewayClient; + public IOperatorGatewayClient? GatewayClient => _connectionManager?.OperatorClient; + public GatewayRegistry? Registry => _gatewayRegistry; + public GatewayConnectionManager? ConnectionManager => _connectionManager; + internal SettingsManager Settings => _settings ?? throw new InvalidOperationException("Settings are not initialized."); + + /// The active hub window, exposed so pages can obtain an HWND for file pickers. + internal Microsoft.UI.Xaml.Window? ActiveHubWindow => _hubWindow; + /// The current voice service instance (node or standalone). + internal VoiceService? VoiceService => _nodeService?.VoiceService ?? _standaloneVoiceService; + /// The full device ID of the local node service (if running). + internal string? NodeFullDeviceId => _nodeService?.FullDeviceId; + + public OpenClawTray.Chat.OpenClawChatDataProvider? ChatProvider => _chatCoordinator?.Provider; + + /// + /// Raised after the tray-wide settings have been saved (either via the + /// SettingsPage Save button or a direct toggle from the tray menu). + /// Subscribers can refresh UI that depends on a setting (e.g. switching + /// the chat surface between native chat and WebView2). + /// + public event EventHandler? SettingsChanged; + public event EventHandler? ChatProviderChanged; /// /// Ensures the managed SSH tunnel is started using the current settings. - /// Used by the onboarding ConnectionPage when the user picks the SSH topology. + /// Used by connection settings when the user picks the SSH topology. + /// + public void EnsureSshTunnelStarted() + { + if (_sshTunnelService == null || _settings == null) + return; + + if (!_settings.UseSshTunnel) + { + _sshTunnelService.ResetNotConfigured(); + return; + } + + var includeBrowserProxyForward = + _settings.NodeBrowserProxyEnabled && + SshTunnelCommandLine.CanForwardBrowserProxyPort(_settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort); + if (_settings.NodeBrowserProxyEnabled && !includeBrowserProxyForward) + { + Logger.Warn("SSH tunnel browser proxy forward disabled because the derived port would be invalid"); + } + + _sshTunnelService.EnsureStarted( + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + includeBrowserProxyForward); + } + + /// + /// Creates the WSL local gateway setup engine using the current tray settings. + /// The V2 setup bridge calls this to drive the local-WSL setup flow; + /// the engine pairs the operator + Windows tray node into the gateway it + /// installs, so we eagerly materialize the NodeService when needed (for + /// capability registration via the manager's NodeConnector.ClientCreated bridge). /// - public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings); + public LocalGatewaySetupEngine CreateLocalGatewaySetupEngine( + bool replaceExistingConfigurationConfirmed = false) + { + if (_connectionManager == null || _gatewayRegistry == null || _gatewayService == null) + { + throw new InvalidOperationException( + "GatewayConnectionManager / GatewayRegistry / GatewayService must be initialized before " + + "CreateLocalGatewaySetupEngine. App.OnLaunched initializes them before " + + "ShowOnboardingAsync — if you reach here, the init order has regressed."); + } + + var settings = _settings ?? new SettingsManager(); + // NodeService is still required for capability registration on the manager's + // WindowsNodeClient (via App.xaml.cs ClientCreated → AttachClient bridge). + var nodeService = EnsureNodeService(settings); + // Suppress manager auto-start of node during setup so the engine retains + // strict phase ordering (operator paired → WSL CLI device-approve → node + // pairing). EnsureNodeConnectedAsync (called by ConnectionManagerWindowsNodeConnector + // in the PairWindowsTrayNode phase) bypasses this gate to drive the connect. + _suppressNodeDuringSetup = true; + try + { + // Use the manager-backed connectors so all handshake/pairing events appear + // in the diagnostics window and reuse the manager's v2/v3 signature fallback, + // credential resolution, per-gateway identity store, and device token persistence. + var operatorConnector = new ConnectionManagerOperatorConnector( + _connectionManager, _gatewayRegistry, new AppLogger()); + var windowsNodeConnector = new ConnectionManagerWindowsNodeConnector( + _connectionManager, _gatewayRegistry, new AppLogger()); + var engine = LocalGatewaySetupEngineFactory.CreateLocalOnly( + settings, + operatorConnector, + windowsNodeConnector, + new AppLogger(), + nodeService, + replaceExistingConfigurationConfirmed: replaceExistingConfigurationConfirmed, + gatewayRegistry: _gatewayRegistry); + // Clear suppress flag when engine completes so normal node connections resume. + // Only clear if this engine is still the active one (prevents stale engine #1 + // from clearing the flag while engine #2 is running). + var capturedEngine = engine; + engine.StateChanged += (st) => + { + if (st.Status is LocalGatewaySetupStatus.Complete or LocalGatewaySetupStatus.FailedTerminal + or LocalGatewaySetupStatus.FailedRetryable or LocalGatewaySetupStatus.Cancelled) + { + if (_localSetupEngine == capturedEngine) + _suppressNodeDuringSetup = false; + } + }; + // Bug #2: cache so OnPairingStatusChanged can read engine.IsAutoPairingWindowsNode + // and suppress the "copy pairing command" toast during the Phase 14 blip. + _localSetupEngine = engine; + return engine; + } + catch + { + _suppressNodeDuringSetup = false; + throw; + } + } /// /// Returns the HWND of the active onboarding window, or IntPtr.Zero if none. @@ -57,39 +190,39 @@ public IntPtr GetOnboardingWindowHandle() : IntPtr.Zero; /// - /// Reinitializes the gateway client with current settings. - /// Called by the onboarding wizard after saving URL + Token. + /// Returns the HWND of the Hub window, or IntPtr.Zero if it isn't open. + /// Used by pages hosted in the Hub that need to parent a file picker + /// or other Win32-style dialog. Pages should not hold a reference to + /// the HubWindow directly (single-app-model rule); they call this + /// when they need the handle and discard it afterwards. + /// Guards against the close-window race where `_hubWindow != null` + /// but the window is mid-teardown — every other call site in this + /// file pairs the null check with `!IsClosed` (Hanselman v2 #4). /// - public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => - InitializeGatewayClient(useBootstrapHandoffAuth); + public IntPtr GetHubWindowHandle() + => _hubWindow != null && !_hubWindow.IsClosed + ? WinRT.Interop.WindowNative.GetWindowHandle(_hubWindow) + : IntPtr.Zero; + private SettingsManager? _settings; + private ConnectionSettingsSnapshot? _previousSettingsSnapshot; private SshTunnelService? _sshTunnelService; private GlobalHotkeyService? _globalHotkey; private Mutex? _mutex; private Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue; + private AppState? _appState; + internal AppState? AppState => _appState; + private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; - private ConnectionStatus _currentStatus = ConnectionStatus.Disconnected; - private AgentActivity? _currentActivity; - private ChannelHealth[] _lastChannels = Array.Empty(); - private SessionInfo[] _lastSessions = Array.Empty(); - private GatewayNodeInfo[] _lastNodes = Array.Empty(); - private readonly Dictionary _sessionPreviews = new(); - private readonly object _sessionPreviewsLock = new(); - private DateTime _lastPreviewRequestUtc = DateTime.MinValue; - private GatewayUsageInfo? _lastUsage; - private GatewayUsageStatusInfo? _lastUsageStatus; - private GatewayCostUsageInfo? _lastUsageCost; - private GatewaySelfInfo? _lastGatewaySelf; - private PairingListInfo? _lastNodePairList; - private DevicePairingListInfo? _lastDevicePairList; - private ModelsListInfo? _lastModelsList; - private PresenceEntry[]? _lastPresence; - private UpdateCommandCenterInfo _lastUpdateInfo = BuildInitialUpdateInfo(); - private DateTime _lastCheckTime = DateTime.Now; - private DateTime _lastUsageActivityLogUtc = DateTime.MinValue; - private string? _lastChannelStatusSignature; + /// + /// Cached connection status — sole writer is OnManagerStateChanged. + /// Reads are safe from any thread; derives from the connection manager's state machine. + /// SSH tunnel errors in EnsureSshTunnelConfigured also write this temporarily (Phase 3 moves tunnel to manager). + /// + private WeakReference? _connectionToggleRef; + private bool _suspendConnectionToggleEvent; // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = @@ -106,18 +239,15 @@ public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => ["error"] = s => s.NotifyUrgent, // errors follow urgent setting }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - // Session-aware activity tracking - private readonly Dictionary _sessionActivities = new(); - private string? _displayedSessionKey; - private DateTime _lastSessionSwitch = DateTime.MinValue; - private static readonly TimeSpan SessionSwitchDebounce = TimeSpan.FromSeconds(3); - // Windows (created on demand) private HubWindow? _hubWindow; private TrayMenuWindow? _trayMenuWindow; private QuickSendDialog? _quickSendDialog; private ChatWindow? _chatWindow; - private string? _authFailureMessage; + private ConnectionStatusWindow? _connectionStatusWindow; + + private DiagnosticsClipboardService? _diagnosticsClipboard; + private ToastService? _toastService; // Node service (optional, enabled in settings) private NodeService? _nodeService; @@ -135,6 +265,16 @@ public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray"); + private static readonly string DeepLinkPipeName = + DeepLinkSecurityPolicy.BuildCurrentUserScopedPipeName(DataPath); + // Operator/node identity store (DeviceIdentity). Lives at %APPDATA%\OpenClawTray + // by convention so it follows the user across machines via roaming profile. + // OPENCLAW_TRAY_APPDATA_DIR isolates a test/E2E identity store the same way + // OPENCLAW_TRAY_DATA_DIR isolates the per-machine data directory. + private static readonly string IdentityDataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); private static readonly string CrashLogPath = Path.Combine(DataPath, "crash.log"); private static readonly string RunMarkerPath = Path.Combine(DataPath, "run.marker"); @@ -217,6 +357,156 @@ private static void LogCrash(string source, Exception? ex) } catch { /* Ignore logging failures */ } } + + // ----------------------------------------------------------------------- + // CLI uninstall path + // Invoked when --uninstall is present in argv. Runs headlessly without + // creating the tray UI. Attaches to the parent console so stdout/stderr + // are visible when invoked from PowerShell or cmd. + // ----------------------------------------------------------------------- + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachConsole(int dwProcessId); + + private const int AttachParentProcess = -1; + + private static async Task RunCliUninstallAsync(string[] args) + { + // Attach to parent console so output is visible when invoked from + // PowerShell or cmd. Fails silently if no parent console exists. + AttachConsole(AttachParentProcess); + + bool dryRun = args.Contains("--dry-run", StringComparer.OrdinalIgnoreCase); + bool confirmDestructive = args.Contains("--confirm-destructive", StringComparer.OrdinalIgnoreCase); + + // Locate --json-output argument + string? jsonOutputPath = null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--json-output", StringComparison.OrdinalIgnoreCase)) + { + jsonOutputPath = args[i + 1]; + break; + } + } + + if (!confirmDestructive && !dryRun) + { + Console.Error.WriteLine( + "ERROR: --uninstall requires --confirm-destructive (or --dry-run)."); + Environment.Exit(2); + return; + } + + var settings = new SettingsManager(); + var engine = LocalGatewayUninstall.Build(settings, logger: new AppLogger()); + + LocalGatewayUninstallResult result; + try + { + result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = dryRun, + ConfirmDestructive = confirmDestructive + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Uninstall engine threw: {ex.Message}"); + Environment.Exit(1); + return; + } + + // Human-readable summary (tokens already redacted inside engine steps) + Console.WriteLine("OpenClaw Local Gateway Uninstall"); + Console.WriteLine($"DryRun: {dryRun}"); + Console.WriteLine($"Success: {result.Success}"); + Console.WriteLine($"Steps: {result.Steps.Count} ({result.SkippedSteps.Count} skipped)"); + Console.WriteLine($"Errors: {result.Errors.Count}"); + foreach (var e in result.Errors) + Console.Error.WriteLine($" ERROR: {CliRedact(e)}"); + Console.WriteLine("Postconditions:"); + Console.WriteLine($" WslDistroAbsent: {result.Postconditions.WslDistroAbsent}"); + Console.WriteLine($" AutostartCleared: {result.Postconditions.AutostartCleared}"); + Console.WriteLine($" SetupStateAbsent: {result.Postconditions.SetupStateAbsent}"); + Console.WriteLine($" DeviceTokenCleared: {result.Postconditions.DeviceTokenCleared}"); + Console.WriteLine($" McpTokenPreserved: {result.Postconditions.McpTokenPreserved}"); + Console.WriteLine($" KeepalivesAbsent: {result.Postconditions.KeepalivesAbsent}"); + Console.WriteLine($" VhdDirAbsent: {result.Postconditions.VhdDirAbsent}"); + Console.WriteLine($" LocalGatewayRecordsAbsent: {result.Postconditions.LocalGatewayRecordsAbsent}"); + Console.WriteLine($" LocalGatewayIdentityDirsAbsent: {result.Postconditions.LocalGatewayIdentityDirsAbsent}"); + + // JSON output — redaction applied to step details and error strings + if (jsonOutputPath != null) + { + try + { + var dir = Path.GetDirectoryName(jsonOutputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new + { + success = result.Success, + dry_run = dryRun, + steps = result.Steps.Select(s => new + { + name = s.Name, + status = s.Status.ToString(), + detail = CliRedact(s.Detail) + }), + errors = result.Errors.Select(CliRedact), + skipped_steps = result.SkippedSteps, + postconditions = new + { + wsl_distro_absent = result.Postconditions.WslDistroAbsent, + autostart_cleared = result.Postconditions.AutostartCleared, + setup_state_absent = result.Postconditions.SetupStateAbsent, + device_token_cleared = result.Postconditions.DeviceTokenCleared, + mcp_token_preserved = result.Postconditions.McpTokenPreserved, + keepalives_absent = result.Postconditions.KeepalivesAbsent, + vhd_dir_absent = result.Postconditions.VhdDirAbsent, + local_gateway_records_absent = result.Postconditions.LocalGatewayRecordsAbsent, + local_gateway_identity_dirs_absent = result.Postconditions.LocalGatewayIdentityDirsAbsent + } + }; + + File.WriteAllText(jsonOutputPath, JsonSerializer.Serialize( + payload, new JsonSerializerOptions { WriteIndented = true })); + + Console.WriteLine($"JSON result: {jsonOutputPath}"); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"WARNING: Failed to write JSON output to '{jsonOutputPath}': {ex.Message}"); + } + } + + Environment.Exit(result.Success ? 0 : 1); + } + + /// + /// Redacts token/key material from a string before writing it to CLI + /// stdout or a JSON output file. Mirrors the PowerShell Invoke-Redact + /// pattern in validate-wsl-gateway-uninstall.ps1. + /// + private static string? CliRedact(string? value) + { + if (string.IsNullOrEmpty(value)) return value; + // Redact JSON field values for known secret fields. + value = System.Text.RegularExpressions.Regex.Replace( + value, + @"(""(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)""\s*:\s*"")[^""]+("")", + "$1$2"); + // Redact bare key=value / key: value patterns. + value = System.Text.RegularExpressions.Regex.Replace( + value, + @"(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,""'}{]+", + "$1"); + return value; + } private static void CheckPreviousRun() { @@ -254,6 +544,8 @@ private static void MarkRunEnded() catch { } } + private void OnUiThread(Microsoft.UI.Dispatching.DispatcherQueueHandler action) => _dispatcherQueue?.TryEnqueue(action); + /// /// Check if the app was launched via protocol activation (MSIX deep link). /// In WinUI 3, protocol activation is retrieved via AppInstance, not OnActivated. @@ -278,6 +570,19 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) _startupArgs = Environment.GetCommandLineArgs(); _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + // ----------------------------------------------------------------------- + // CLI uninstall path — headless; never shows tray or any windows. + // Approach: detect in OnLaunched before any UI is created (WinUI3 Main + // is auto-generated; earliest interception point is OnLaunched). + // Bypasses the single-instance mutex so the Inno uninstaller can invoke + // this even while the tray is running. + // ----------------------------------------------------------------------- + if (_startupArgs.Contains("--uninstall", StringComparer.OrdinalIgnoreCase)) + { + await RunCliUninstallAsync(_startupArgs); + return; // Environment.Exit called inside; defensive return + } + // Check for protocol activation (MSIX packaged apps receive deep links this way) string? protocolUri = GetProtocolActivationUri(); @@ -288,6 +593,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // two test runs against the same data dir would otherwise pick different // mutex names — and `Math.Abs(int.MinValue)` overflows. Use a stable // SHA-256 prefix instead. + // NOTE: The bare "OpenClawTray" mutex name is also referenced by + // installer.iss `AppMutex=` for install/uninstall race coordination + // (round 2, Scott #5). The suffixed test-isolation variant is + // intentionally not covered by AppMutex — production installs only + // ever use the unsuffixed name. var mutexName = "OpenClawTray"; if (DataDirOverride is not null) { @@ -315,7 +625,29 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // Initialize settings before update check so skip selections can be remembered. _settings = new SettingsManager(); + _previousSettingsSnapshot = _settings.ToSettingsData().ToConnectionSnapshot(); + _chatCoordinator = new OpenClawTray.Chat.OpenClawChatCoordinator( + _settings, + () => _nodeService, + new AppLogger(), + _dispatcherQueue is null + ? null + : OpenClawTray.Chat.FunctionalChatHostExtensions.AsPost(_dispatcherQueue)); DiagnosticsJsonlService.Configure(DataPath); + + // Central observable model + gateway event handler. + _appState = new AppState(_dispatcherQueue); + _appState.UpdateInfo = BuildInitialUpdateInfo(); + _gatewayService = new GatewayService(_appState, _dispatcherQueue!); + _gatewayService.ConnectionStatusChanged += OnGatewayConnectionStatusChanged; + _gatewayService.AuthenticationFailed += OnGatewayAuthenticationFailed; + _gatewayService.SessionCommandCompleted += OnGatewaySessionCommandCompleted; + _gatewayService.NotificationReceived += OnGatewayNotificationReceived; + _appState.PropertyChanged += OnAppStateChanged; + + _diagnosticsClipboard = new DiagnosticsClipboardService(BuildCommandCenterState); + _toastService = new ToastService(() => _settings); + DiagnosticsJsonlService.Write("app.start", new { nodeMode = _settings.EnableNodeMode, @@ -344,29 +676,142 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) _sshTunnelService = new SshTunnelService(new AppLogger()); _sshTunnelService.TunnelExited += OnSshTunnelExited; - // First-run check (also supports forced onboarding for testing) - if (RequiresSetup(_settings) || - Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1") + // Initialize tray icon FIRST (window-less pattern from WinUIEx). + // The tray is application chrome and must always survive any failure + // in the onboarding wizard. OnLaunched is async void, so a synchronous + // throw inside the OnboardingWindow constructor would otherwise + // propagate through `await ShowOnboardingAsync()` and abort OnLaunched + // before the tray ever initializes. + InitializeTrayIcon(); + // Apply the user's saved default chat preset (if any) before any chat + // surface mounts so initial render uses their preferred styling. + OpenClawTray.Chat.Explorations.ChatExplorationPresetStore.ApplyDefaultIfPresent(); + ShowSurfaceImprovementsTipIfNeeded(); + + // Initialize connection manager BEFORE onboarding so CreateLocalGatewaySetupEngine() + // (called by the easy-button flow) can wire ConnectionManagerOperatorConnector + + // ConnectionManagerWindowsNodeConnector. Without this ordering, the engine factory + // would fall back to the legacy NodeServiceWindowsNodeConnector path, which delegates + // to the now-obsolete NodeService.ConnectAsync and would fail at runtime. + _gatewayRegistry = new GatewayRegistry(SettingsManager.SettingsDirectoryPath); + _gatewayRegistry.Load(); + var credentialResolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var clientFactory = new GatewayClientFactory(); + var appLogger = new AppLogger(); + var diagnostics = new ConnectionDiagnostics(); + var nodeConnector = new NodeConnector(appLogger, diagnostics); + // Bridge: whenever NodeConnector creates a fresh WindowsNodeClient (initial + // connect or reconnect), register the node's capabilities on it BEFORE the + // outbound "connect" handshake runs. Without this hookup the gateway sees + // the node as having no advertised commands and the agent cannot invoke + // anything on it. _nodeService may be null at app startup (constructed + // lazily); when null we no-op and the gateway will see an empty caps list + // until the next reconnect after _nodeService becomes available. + nodeConnector.ClientCreated += (_, args) => + { + try + { + // A node client was just created (manager auto-start OR setup engine + // EnsureNodeConnectedAsync). We MUST have a NodeService to register + // capabilities on this client before the outbound "connect" goes out — + // see NodeConnector.cs:66. Build it lazily here so we never depend on + // OnLaunched ordering or the _suppressNodeDuringSetup gate having + // landed in the right state. EnsureNodeService is + // idempotent — it returns the existing instance if already built. + // Without this, _nodeService?.AttachClient below is a silent no-op and + // the gateway sees the node with caps=0/cmds=0 (regression introduced + // 2026-05-12 in 62533e2 when capability registration moved to this + // lazy bridge pattern). + if (_settings == null) + { + Logger.Warn("[App] NodeConnector.ClientCreated fired before settings were initialized; node may connect without capabilities"); + diagnostics.Record("node", "WARNING: settings unavailable; cannot initialize NodeService for capability binding"); + } + else + { + EnsureNodeService(_settings); + } + + diagnostics.Record("node", $"ClientCreated fired, _nodeService null={_nodeService is null}"); + if (_nodeService == null) + { + Logger.Warn("[App] NodeService unavailable during ClientCreated; node may connect with caps=0/cmds=0"); + diagnostics.Record("node", "WARNING: NodeService unavailable; cannot bind node capabilities"); + return; + } + + _nodeService.AttachClient(args.Client, args.BearerToken); + var client = args.Client; + diagnostics.Record("node", $"After AttachClient: caps={client.Capabilities.Count}, cmds={client.RegisteredCommandCount}"); + if (client.RegisteredCommandCount > 0) + diagnostics.Record("node", $"Commands sample: {string.Join(", ", client.RegisteredCommandsSample)}..."); + else + { + Logger.Warn("[App] Node capability binding produced 0 commands before connect"); + diagnostics.Record("node", "WARNING: 0 commands registered on node client before connect"); + } + } + catch (Exception ex) + { + Logger.Warn($"[App] NodeConnector.ClientCreated handler failed: {ex.Message}"); + diagnostics.Record("node", $"ClientCreated handler THREW: {ex.Message}"); + } + }; + // SshTunnelService implements ISshTunnelManager directly — no shim needed + _connectionManager = new GatewayConnectionManager( + credentialResolver, clientFactory, _gatewayRegistry, appLogger, + identityStore: new DeviceIdentityFileStore(appLogger), + nodeConnector: nodeConnector, + isNodeEnabled: ShouldInitializeNodeService, + diagnostics: diagnostics, + tunnelManager: _sshTunnelService); + _connectionManager.OperatorClientChanged += OnOperatorClientChanged; + _connectionManager.StateChanged += OnManagerStateChanged; + + // First-run check (also supports forced onboarding for testing). + // Wrapped in try/catch so a wizard construction failure cannot tear + // down the tray; user can retry via the Setup Guide menu item. + try + { + if (RequiresSetup(_settings) || + Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1") + { + await ShowOnboardingAsync(); + } + } + catch (Exception ex) { - await ShowOnboardingAsync(); + Logger.Error($"Onboarding failed during launch (tray remains available): {ex}"); } - // Initialize tray icon (window-less pattern from WinUIEx) - InitializeTrayIcon(); - ShowSurfaceImprovementsTipIfNeeded(); + // Ensure NodeService is constructed BEFORE InitializeGatewayClient triggers a + // NodeConnector connect. The NodeConnector.ClientCreated event subscription + // above relies on _nodeService being non-null to register capabilities on the + // new WindowsNodeClient. If we don't pre-construct here, the first connect + // happens with empty caps and the gateway records this node as having no + // advertised commands (which leaves the agent unable to invoke anything on it). + // The method is idempotent — safe to call here AND later if first-run setup runs. + if (ShouldInitializeNodeService() && _settings != null) + { + EnsureNodeService(_settings); + } // Initialize connections — always create operator client for UI data, // additionally create node service for gateway node mode or local MCP. + // Re-arm the WSL keepalive so the local gateway VM stays up across tray + // restarts and across the 20s WSL vmIdleTimeout window observed on some + // hosts. Fire-and-forget on a background task so a slow LxssManager at + // cold logon never delays InitializeGatewayClient. The keepalive itself + // runs detached from the tray — see WslDistroKeepAlive in LocalGatewaySetup.cs. + _ = Task.Run(TryEnsureLocalGatewayKeepAliveAsync); InitializeGatewayClient(); - if (ShouldInitializeNodeService()) - { - InitializeNodeService(); - } // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) - if (_settings != null && !string.IsNullOrWhiteSpace(_settings.GetEffectiveGatewayUrl())) + if (_settings != null && + TryResolveChatCredentials(out var prewarmUrl, out var prewarmToken, out _, out var prewarmIsBootstrapToken) && + !prewarmIsBootstrapToken) { - _chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token); + _chatWindow = new ChatWindow(prewarmUrl, prewarmToken); // Window is created but hidden — WebView2 initializes in the background } @@ -378,6 +823,8 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) { _globalHotkey = new GlobalHotkeyService(); _globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed; + _globalHotkey.VoiceHotkeyPressed += OnVoiceHotkeyPressed; + _globalHotkey.SettingsHotkeyPressed += OnSettingsHotkeyPressed; _globalHotkey.Register(); } @@ -387,7 +834,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) ? _startupArgs[1] : null); if (startupDeepLink != null) { - HandleDeepLink(startupDeepLink); + await HandleDeepLinkAsync(startupDeepLink); } Logger.Info("Application started (WinUI 3)"); @@ -414,8 +861,9 @@ private void InitializeTrayIcon() InitializeTrayMenuWindow(); var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected); - _trayIcon = new TrayIcon(1, iconPath, "OpenClaw Tray — Disconnected"); + _trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip()); _trayIcon.IsVisible = true; + ApplyTrayTooltip(BuildTrayTooltip()); _trayIcon.Selected += OnTrayIconSelected; _trayIcon.ContextMenu += OnTrayContextMenu; } @@ -430,17 +878,45 @@ private void InitializeTrayMenuWindow() private void OnTrayIconSelected(TrayIcon sender, TrayIconEventArgs e) { - ShowChatWindow(); + if (_connectionManager?.CurrentSnapshot.OperatorState == RoleConnectionState.Connected) + { + ShowChatWindow(); + return; + } + + ShowHub("connection"); } - private void ShowChatWindow() + internal void ShowChatWindow() { if (_settings == null) return; + if (!TryResolveChatCredentials(out var url, out var token, out var credentialSource, out var isBootstrapToken)) + { + ShowConnectionSettingsForPairingIssue( + "ChatWindow", + "Gateway URL or credential is not configured"); + return; + } + + if (isBootstrapToken) + { + ShowConnectionSettingsForPairingIssue( + "ChatWindow", + "Gateway pairing is not complete"); + return; + } + + Logger.Info($"[ChatWindow] Quick-chat credentials resolved from {credentialSource}"); if (_chatWindow == null) { - _chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token); + _chatWindow = new ChatWindow(url, token); } + // Bug 2: cached ChatWindow may have been pre-warmed with empty/stale credentials + // (built before pairing completed). Refresh on every tray click so quick-chat + // follows the same resolver path as the companion-app operator client. + _chatWindow.RefreshCredentials(url, token); + // Toggle: if visible, hide; if hidden, show near tray if (_chatWindow.Visible) { @@ -448,8 +924,119 @@ private void ShowChatWindow() } else { - _chatWindow.ShowNearTrayAnimated(); + // Bug 1: When called from the wizard's close handler, OnboardingWindow.Close() + // steals focus on the same UI tick, deactivating ChatWindow → its + // OnWindowActivated auto-hides it immediately. Defer the show to a later + // dispatcher tick (Low priority) so the close + focus-loss cascade settles + // before we make the chat window visible. + var window = _chatWindow; + var dispatcher = _dispatcherQueue; + if (dispatcher != null) + { + dispatcher.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, + () => + { + try { window.ShowNearTrayAnimated(); } + catch (Exception ex) { Logger.Warn($"ShowChatWindow deferred show failed: {ex.Message}"); } + }); + } + else + { + window.ShowNearTrayAnimated(); + } + } + + } + + private void ShowCanvasWindow() + { + if (_settings?.NodeCanvasEnabled == false) + { + Logger.Warn("[Canvas] Canvas capability is disabled; opening capability settings"); + ShowHub("capabilities"); + return; + } + + if (_nodeService == null) + { + ShowConnectionSettingsForPairingIssue( + "Canvas", + "Windows node is not initialized"); + return; + } + + if (_nodeService.IsPendingApproval || !_nodeService.IsPaired) + { + ShowConnectionSettingsForPairingIssue( + "Canvas", + "Windows node pairing is not complete"); + return; + } + + _nodeService.ShowCanvasWindow(); + } + + private void ShowConnectionSettingsForPairingIssue(string source, string reason) + { + Logger.Warn($"[{source}] {reason}; opening connection settings"); + ShowHub("connection"); + } + + // Voice overlay disabled — inline chat voice mode is used instead. + // private VoiceOverlayWindow? _voiceOverlayWindow; + private VoiceService? _standaloneVoiceService; + + /// + /// Gets the current VoiceService instance (from the node service or standalone). + /// Returns null if STT is not enabled. + /// + public VoiceService? VoiceServiceInstance => + _nodeService?.VoiceService ?? _standaloneVoiceService; + + // Voice overlay disabled — inline chat voice mode is used instead. + // Kept for potential future re-enablement. + /* + private void ShowVoiceOverlay() + { + var voiceService = _nodeService?.VoiceService ?? EnsureStandaloneVoiceService(); + if (voiceService == null) + { + // STT not enabled — show settings + ShowHub("voice"); + return; + } + + if (_voiceOverlayWindow == null || _voiceOverlayWindow.AppWindow == null) + { + _voiceOverlayWindow = new VoiceOverlayWindow(voiceService, new AppLogger()); + _voiceOverlayWindow.Closed += (_, _) => _voiceOverlayWindow = null; + // Wire transcription to gateway chat when connected + _voiceOverlayWindow.TextSubmitted += text => + { + var client = _connectionManager?.OperatorClient; + if (client != null && _appState!.Status == ConnectionStatus.Connected) + { + _ = client.SendChatMessageAsync(text); + } + }; + // Wire Settings button → open the Hub on the Voice & Audio page. + _voiceOverlayWindow.SettingsRequested += () => + { + OnUiThread(() => ShowHub("voice")); + }; } + + _voiceOverlayWindow.Activate(); + } + */ + + private VoiceService? EnsureStandaloneVoiceService() + { + if (_settings?.NodeSttEnabled != true) + return null; + + return _standaloneVoiceService ??= new VoiceService(new AppLogger(), _settings); } private void OnTrayContextMenu(TrayIcon sender, TrayIconEventArgs e) @@ -483,7 +1070,6 @@ private async void ShowTrayMenuPopup() // Rebuild menu content _trayMenuWindow!.ClearItems(); BuildTrayMenuPopup(_trayMenuWindow); - _trayMenuWindow.SizeToContent(); _trayMenuWindow.ShowAtCursor(); } catch (Exception ex) @@ -498,23 +1084,30 @@ private void OnTrayMenuItemClicked(object? sender, string action) switch (action) { case "status": ShowStatusDetail(); break; - case "reconnect": ReconnectGateway(); break; + case "reconnect": _ = _connectionManager?.ReconnectAsync(); break; + case "disconnect": + _ = _connectionManager?.DisconnectAsync(); + LocalDisconnectCleanup(); + break; + case "connection": ShowHub("connection"); break; + case "permissions": ShowHub("permissions"); break; case "dashboard": OpenDashboard(); break; - case "canvas": _nodeService?.ShowCanvasWindow(); break; - case "openchat": ShowChatWindow(); break; + case "canvas": ShowCanvasWindow(); break; + case "openchat": ShowHub("chat"); break; + case "voice": ShowHub("voice"); break; // was: ShowVoiceOverlay() case "webchat": ShowWebChat(); break; case "hub": ShowHub(); break; case "companion": - // If disconnected, open General page (has connection settings + discovery) + // If disconnected, open Connection page (status, gateways, add flow) // If connected, open Hub at default page - if (_currentStatus != ConnectionStatus.Connected) - ShowHub("general"); + if (_appState!.Status != ConnectionStatus.Connected) + ShowHub("connection"); else ShowHub(); break; case "quicksend": ShowQuickSend(); break; - case "history": ShowNotificationHistory(); break; - case "activity": ShowActivityStream(); break; + case "history": ShowHub("channels"); break; + case "activity": ShowHub("channels"); break; case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break; case "checkupdates": _ = CheckForUpdatesUserInitiatedAsync(); break; case "settings": ShowSettings(); break; @@ -524,21 +1117,28 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "logfolder": OpenLogFolder(); break; case "configfolder": OpenConfigFolder(); break; case "diagnosticsfolder": OpenDiagnosticsFolder(); break; - case "supportcontext": CopySupportContext(); break; - case "debugbundle": CopyDebugBundle(); break; - case "browsersetup": CopyBrowserSetupGuidance(); break; - case "portdiagnostics": CopyPortDiagnostics(); break; - case "capabilitydiagnostics": CopyCapabilityDiagnostics(); break; - case "nodeinventory": CopyNodeInventory(); break; - case "channelsummary": CopyChannelSummary(); break; - case "activitysummary": CopyActivitySummary(); break; - case "extensibilitysummary": CopyExtensibilitySummary(); break; + case "connectionstatus": ShowConnectionStatusWindow(); break; + case "supportcontext": _diagnosticsClipboard!.CopySupportContext(); break; + case "debugbundle": _diagnosticsClipboard!.CopyDebugBundle(); break; + case "browsersetup": _diagnosticsClipboard!.CopyBrowserSetupGuidance(); break; + case "portdiagnostics": _diagnosticsClipboard!.CopyPortDiagnostics(); break; + case "capabilitydiagnostics": _diagnosticsClipboard!.CopyCapabilityDiagnostics(); break; + case "nodeinventory": _diagnosticsClipboard!.CopyNodeInventory(); break; + case "channelsummary": _diagnosticsClipboard!.CopyChannelSummary(); break; + case "activitysummary": _diagnosticsClipboard!.CopyActivitySummary(); break; + case "extensibilitysummary": _diagnosticsClipboard!.CopyExtensibilitySummary(); break; case "restartsshtunnel": RestartSshTunnel(); break; case "copydeviceid": CopyDeviceIdToClipboard(); break; case "copynodesummary": CopyNodeSummaryToClipboard(); break; case "exit": ExitApplication(); break; + case "about": ShowHub("about"); break; default: - if (action.StartsWith("session-reset|", StringComparison.Ordinal)) + if (action.StartsWith("perm-toggle|", StringComparison.Ordinal) + && _permToggleActions.TryGetValue(action, out var permAction)) + { + permAction(); + } + else if (action.StartsWith("session-reset|", StringComparison.Ordinal)) _ = ExecuteSessionActionAsync("reset", action["session-reset|".Length..]); else if (action.StartsWith("session-compact|", StringComparison.Ordinal)) _ = ExecuteSessionActionAsync("compact", action["session-compact|".Length..]); @@ -561,7 +1161,7 @@ private void OnTrayMenuItemClicked(object? sender, string action) else if (action.StartsWith("dashboard:", StringComparison.Ordinal)) OpenDashboard(action["dashboard:".Length..]); else if (action.StartsWith("activity:", StringComparison.Ordinal)) - ShowActivityStream(action["activity:".Length..]); + ShowHub("channels"); else if (action.StartsWith("channel:", StringComparison.Ordinal)) ToggleChannel(action[8..]); else @@ -577,12 +1177,10 @@ private void CopyDeviceIdToClipboard() try { - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(_nodeService.FullDeviceId); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + CopyTextToClipboard(_nodeService.FullDeviceId); // Show toast confirming copy - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied")) .AddText(string.Format(LocalizationHelper.GetString("Toast_DeviceIdCopiedDetail"), _nodeService.ShortDeviceId))); } @@ -594,11 +1192,11 @@ private void CopyDeviceIdToClipboard() private void CopyNodeSummaryToClipboard() { - if (_lastNodes.Length == 0) return; + if (_appState!.Nodes.Length == 0) return; try { - var lines = _lastNodes.Select(node => + var lines = _appState!.Nodes.Select(node => { var state = node.IsOnline ? "online" : "offline"; var name = string.IsNullOrWhiteSpace(node.DisplayName) ? node.ShortId : node.DisplayName; @@ -606,13 +1204,11 @@ private void CopyNodeSummaryToClipboard() }); var summary = string.Join(Environment.NewLine, lines); - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(summary); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + CopyTextToClipboard(summary); - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_NodeSummaryCopied")) - .AddText(string.Format(LocalizationHelper.GetString("Toast_NodeSummaryCopiedDetail"), _lastNodes.Length))); + .AddText(string.Format(LocalizationHelper.GetString("Toast_NodeSummaryCopiedDetail"), _appState!.Nodes.Length))); } catch (Exception ex) { @@ -622,7 +1218,8 @@ private void CopyNodeSummaryToClipboard() private async Task ExecuteSessionActionAsync(string action, string sessionKey, string? value = null) { - if (_gatewayClient == null || string.IsNullOrWhiteSpace(sessionKey)) return; + var client = _connectionManager?.OperatorClient; + if (client == null || string.IsNullOrWhiteSpace(sessionKey)) return; try { @@ -656,17 +1253,17 @@ private async Task ExecuteSessionActionAsync(string action, string sessionKey, s var sent = action switch { - "reset" => await _gatewayClient.ResetSessionAsync(sessionKey), - "compact" => await _gatewayClient.CompactSessionAsync(sessionKey, 400), - "delete" => await _gatewayClient.DeleteSessionAsync(sessionKey, deleteTranscript: true), - "thinking" => await _gatewayClient.PatchSessionAsync(sessionKey, thinkingLevel: value), - "verbose" => await _gatewayClient.PatchSessionAsync(sessionKey, verboseLevel: value), + "reset" => await client.ResetSessionAsync(sessionKey), + "compact" => await client.CompactSessionAsync(sessionKey, 400), + "delete" => await client.DeleteSessionAsync(sessionKey, deleteTranscript: true), + "thinking" => await client.PatchSessionAsync(sessionKey, thinkingLevel: value), + "verbose" => await client.PatchSessionAsync(sessionKey, verboseLevel: value), _ => false }; if (!sent) { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_SessionActionFailed")) .AddText(LocalizationHelper.GetString("Toast_SessionActionFailedDetail"))); return; @@ -674,7 +1271,7 @@ private async Task ExecuteSessionActionAsync(string action, string sessionKey, s if (action is "thinking" or "verbose") { - _ = _gatewayClient.RequestSessionsAsync(); + _ = client.RequestSessionsAsync(); } } catch (Exception ex) @@ -682,7 +1279,7 @@ private async Task ExecuteSessionActionAsync(string action, string sessionKey, s Logger.Warn($"Session action error ({action}): {ex.Message}"); try { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_SessionActionFailed")) .AddText(ex.Message)); } @@ -708,12 +1305,32 @@ private async Task ConfirmSessionActionAsync(string title, string body, st return result == ContentDialogResult.Primary; } - private static string TruncateMenuText(string text, int maxLength = 96) => - MenuDisplayHelper.TruncateText(text, maxLength); + private async Task ConfirmDeepLinkActionAsync(DeepLinkResult result) + { + var root = _keepAliveWindow?.Content as FrameworkElement; + if (root?.XamlRoot == null) + { + Logger.Warn($"Cannot confirm deep link action without XAML root: {DeepLinkSecurityPolicy.RedactForLog($"openclaw://{result.Path}")}"); + return false; + } + + var dialog = new ContentDialog + { + Title = "Confirm OpenClaw action", + Content = $"A deep link wants to {DeepLinkSecurityPolicy.GetActionDisplayName(result)}.", + PrimaryButtonText = "Allow", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = root.XamlRoot + }; + var dialogResult = await dialog.ShowAsync(); + return dialogResult == ContentDialogResult.Primary; + } private void AddRecentActivity( string line, string category = "general", + string? icon = null, string? dashboardPath = null, string? details = null, string? sessionKey = null, @@ -722,6 +1339,7 @@ private void AddRecentActivity( ActivityStreamService.Add( category: category, title: line, + icon: icon, details: details, dashboardPath: dashboardPath, sessionKey: sessionKey, @@ -735,865 +1353,475 @@ private List GetRecentActivity(int maxItems) .ToList(); } - private void BuildTrayMenuPopup(TrayMenuWindow menu) + private void LocalDisconnectCleanup() { - var isConnected = _currentStatus == ConnectionStatus.Connected; - var statusText = LocalizationHelper.GetConnectionStatusText(_currentStatus); + _appState?.ClearCachedData(); + UpdateTrayIcon(); + // Dismiss the tray menu on disconnect — it will capture fresh data on next open + _trayMenuWindow?.HideCascade(); + } - // ── Brand Header (non-interactive) ── - menu.AddCustomElement(new StackPanel + private void BuildTrayMenuPopup(TrayMenuWindow menu) + { + // Preview data must be applied before snapshot capture so the injected + // values are visible to the builder without coupling it to App state. + ApplyTrayMenuPreviewDataIfRequested(); + var snapshot = CaptureTrayMenuSnapshot(); + var callbacks = new TrayMenuCallbacks( + DispatchAction: action => OnTrayMenuItemClicked(null, action), + SaveAndReconnect: () => { _settings?.Save(); _ = _connectionManager?.ReconnectAsync(); }, + TrackConnectionToggle: toggle => _connectionToggleRef = new WeakReference(toggle), + IsConnectionToggleSuspended: () => _suspendConnectionToggleEvent); + var builder = new TrayMenuStateBuilder(snapshot, _permToggleActions, callbacks); + + // Render the whole menu inside a single update batch so layout + // measures only once instead of once-per-row. Pair with EndUpdate + // in finally so an exception mid-build doesn't wedge layout. + menu.BeginUpdate(); + try { - Padding = new Thickness(14, 10, 14, 6), - Children = - { - new TextBlock - { - Text = "🦞 OpenClaw", - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 14 - } - } - }); + builder.Build(menu); + } + finally + { + menu.EndUpdate(); + } + } + + private TrayMenuSnapshot CaptureTrayMenuSnapshot() + { + var setupMenuLabel = _settings != null + && new OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard(_settings, IdentityDataPath) + .HasExistingConfiguration() + ? LocalizationHelper.GetString("Menu_Reconfigure") + : LocalizationHelper.GetString("Menu_SetupGuide"); - // ── Gateway Section ── - var gwGrid = new Grid + return new TrayMenuSnapshot { - Padding = new Thickness(14, 4, 14, 8), - HorizontalAlignment = HorizontalAlignment.Stretch, - ColumnDefinitions = - { - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - new ColumnDefinition { Width = GridLength.Auto } - } + CurrentStatus = _appState!.Status, + AuthFailureMessage = _appState?.AuthFailureMessage, + GatewayUrl = _settings?.GetEffectiveGatewayUrl(), + GatewaySelf = _appState?.GatewaySelf, + Presence = _appState?.Presence, + EnableNodeMode = _settings?.EnableNodeMode == true && _nodeService != null, + NodeIsPaired = _nodeService?.IsPaired ?? false, + NodeIsPendingApproval = _nodeService?.IsPendingApproval ?? false, + NodeIsConnected = _nodeService?.IsConnected ?? false, + NodePairList = _appState?.NodePairList, + DevicePairList = _appState?.DevicePairList, + Nodes = _appState?.Nodes ?? Array.Empty(), + Sessions = _appState?.Sessions ?? Array.Empty(), + Usage = _appState?.Usage, + UsageStatus = _appState?.UsageStatus, + UsageCost = _appState?.UsageCost, + Settings = _settings, + SetupMenuLabel = setupMenuLabel, }; + } - var gwInfo = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center }; - // Gateway status line - var gwStatusRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - gwStatusRow.Children.Add(new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - isConnected ? Microsoft.UI.Colors.LimeGreen - : _currentStatus == ConnectionStatus.Connecting ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.Gray) - }); - gwStatusRow.Children.Add(new TextBlock + /// + /// Opt-in design preview: when the OPENCLAW_TRAY_PREVIEW_DATA + /// environment variable is set to 1, populate the session/usage + /// caches with synthetic values so the Sessions and Usage flyouts render + /// meaningful progress bars and provider data without a live gateway. + /// Real data takes precedence — preview values are only written when the + /// corresponding cache is empty/null, so attaching to a real gateway + /// after launch immediately replaces the preview. + /// + private void ApplyTrayMenuPreviewDataIfRequested() + { + var flag = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_PREVIEW_DATA"); + if (string.IsNullOrEmpty(flag) || flag == "0") return; + { - Text = $"Gateway · {statusText}", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center - }); - gwInfo.Children.Add(gwStatusRow); - - // Gateway details - if (isConnected) - { - var detailParts = new List(); - if (_lastGatewaySelf != null && !string.IsNullOrEmpty(_lastGatewaySelf.ServerVersion)) - detailParts.Add($"v{_lastGatewaySelf.ServerVersion}"); - var url = _settings?.GetEffectiveGatewayUrl(); - if (!string.IsNullOrEmpty(url) && Uri.TryCreate(url, UriKind.Absolute, out var uri)) - detailParts.Add($"{uri.Host}:{uri.Port}"); - if (_lastPresence != null && _lastPresence.Length > 0) - detailParts.Add($"{_lastPresence.Length} client{(_lastPresence.Length != 1 ? "s" : "")}"); - if (detailParts.Count > 0) + var now = DateTime.UtcNow; + if (_appState != null) { - gwInfo.Children.Add(new TextBlock + _appState.Sessions = new[] { - Text = string.Join(" · ", detailParts), - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - FontSize = 11 - }); - } - } + new SessionInfo + { + Key = "preview:main", IsMain = true, Status = "active", + Model = "claude-opus-4.7", DisplayName = "Main · preview", + InputTokens = 124_000, OutputTokens = 36_000, + ContextTokens = 200_000, + UpdatedAt = now.AddMinutes(-2), LastSeen = now, + }, + new SessionInfo + { + Key = "preview:dashboard", IsMain = false, Status = "idle", + Model = "gpt-5.4", DisplayName = "agent:main:dashboard", + InputTokens = 58_000, OutputTokens = 12_000, + ContextTokens = 128_000, + UpdatedAt = now.AddHours(-1), LastSeen = now, + }, + new SessionInfo + { + Key = "preview:scratch", IsMain = false, Status = "idle", + Model = "claude-haiku-4.5", DisplayName = "agent:main:scratch", + InputTokens = 6_400, OutputTokens = 1_200, + ContextTokens = 64_000, + UpdatedAt = now.AddHours(-4), LastSeen = now, + }, + }; - // Node pairing status - if (_settings?.EnableNodeMode == true && _nodeService != null) - { - var nodeText = _nodeService.IsPaired ? "Node paired" - : _nodeService.IsPendingApproval ? "⏳ Node pairing pending" - : _nodeService.IsConnected ? "Node connected" - : null; - if (nodeText != null) - { - gwInfo.Children.Add(new TextBlock + _appState.Usage = new GatewayUsageInfo { - Text = nodeText, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - FontSize = 11 - }); - } - } + InputTokens = 188_400, + OutputTokens = 49_200, + TotalTokens = 237_600, + CostUsd = 4.82, + RequestCount = 142, + Model = "claude-opus-4.7", + }; - // Auth failure - if (!string.IsNullOrEmpty(_authFailureMessage)) - { - gwInfo.Children.Add(new TextBlock - { - Text = $"⚠️ {_authFailureMessage}", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.OrangeRed), - FontSize = 11, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 240 - }); - } - - Grid.SetColumn(gwInfo, 0); - gwGrid.Children.Add(gwInfo); - - // Gateway connect/disconnect button - var connectBtn = new ToggleButton - { - IsChecked = isConnected, - Content = isConnected ? "Connected" : "Disconnected", - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Right, - Padding = new Thickness(10, 4, 10, 4), - MinHeight = 0, - MinWidth = 0, - FontSize = 11 - }; - ToolTipService.SetToolTip(connectBtn, isConnected ? "Click to disconnect from gateway" : "Click to connect to gateway"); - connectBtn.Click += (s, ev) => - { - var on = connectBtn.IsChecked == true; - connectBtn.Content = on ? "Connected" : "Disconnected"; - ToolTipService.SetToolTip(connectBtn, on ? "Click to disconnect from gateway" : "Click to connect to gateway"); - if (on) - { - InitializeGatewayClient(); - if (ShouldInitializeNodeService()) InitializeNodeService(); - } - else - { - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - var oldNode = _nodeService; - _nodeService = null; - try { oldNode?.Dispose(); } catch { } - _currentStatus = ConnectionStatus.Disconnected; - _lastSessions = Array.Empty(); - _lastNodePairList = null; - _lastDevicePairList = null; - _lastModelsList = null; - UpdateTrayIcon(); - _hubWindow?.UpdateStatus(_currentStatus); - } - // Dismiss menu after toggle — header will rebuild with correct state on next open - _trayMenuWindow?.HideCascade(); - }; - Grid.SetColumn(connectBtn, 1); - gwGrid.Children.Add(connectBtn); - - // Make gateway info area clickable → opens Connection page - gwInfo.Tapped += (s, ev) => - { - ShowHub("connection"); - }; - menu.AddCustomElement(gwGrid); - - // ── Sessions ── - if (_lastSessions.Length > 0) - { - menu.AddSeparator(); - - // Section header: "Sessions 3 active · 45K tokens" - var sessionCount = _lastSessions.Length; - var activeCount = _lastSessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); - var totalTokensAll = _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); - var sessionSummaryRight = $"{activeCount} active · {FormatTokenCount(totalTokensAll)} tokens"; - menu.AddCustomElement(BuildSectionHeader("Sessions", sessionSummaryRight)); - - // Individual session cards - foreach (var session in _lastSessions.Take(5)) - { - var card = BuildSessionCard(session); - var flyoutItems = BuildSessionFlyoutItems(session); - menu.AddFlyoutCustomItem(card, flyoutItems, action: "sessions"); - } - } - - // ── Pairing Pending ── - var nodePendingCount = _lastNodePairList?.Pending.Count ?? 0; - var devicePendingCount = _lastDevicePairList?.Pending.Count ?? 0; - if (nodePendingCount + devicePendingCount > 0) - { - var total = nodePendingCount + devicePendingCount; - menu.AddMenuItem($"⚠️ Pairing approval pending ({total})", "🔗", "hub"); - } - - // ── Connected Devices with inline permission toggles ── - if (_lastNodes.Length > 0) - { - menu.AddSeparator(); - - var onlineCount = _lastNodes.Count(n => n.IsOnline); - var totalCaps = _lastNodes.Sum(n => n.CapabilityCount); - var deviceSummaryRight = $"{onlineCount} online · {totalCaps} caps"; - menu.AddCustomElement(BuildSectionHeader("Devices", deviceSummaryRight)); - - var currentHost = Environment.MachineName; - - foreach (var node in _lastNodes.Take(5)) - { - var card = BuildDeviceCard(node); - var flyoutItems = BuildDeviceFlyoutItems(node); - menu.AddFlyoutCustomItem(card, flyoutItems, action: "nodes"); - - // If this node is the local machine, show capability toggles underneath - bool isLocal = node.DisplayName?.Contains(currentHost, StringComparison.OrdinalIgnoreCase) == true - || node.NodeId?.Contains(currentHost, StringComparison.OrdinalIgnoreCase) == true; - if (isLocal && _settings != null) + _appState.UsageStatus = new GatewayUsageStatusInfo { - // Build compact toggle button grid (3 columns) - var capToggles = new Dictionary Get, Action Set)>(StringComparer.OrdinalIgnoreCase) - { - ["browser"] = (() => _settings.NodeBrowserProxyEnabled, v => _settings.NodeBrowserProxyEnabled = v), - ["camera"] = (() => _settings.NodeCameraEnabled, v => _settings.NodeCameraEnabled = v), - ["canvas"] = (() => _settings.NodeCanvasEnabled, v => _settings.NodeCanvasEnabled = v), - ["screen"] = (() => _settings.NodeScreenEnabled, v => _settings.NodeScreenEnabled = v), - ["location"] = (() => _settings.NodeLocationEnabled, v => _settings.NodeLocationEnabled = v), - ["tts"] = (() => _settings.NodeTtsEnabled, v => _settings.NodeTtsEnabled = v), - ["system"] = (() => _settings.EnableNodeMode, v => _settings.EnableNodeMode = v), - }; - - // Show ALL possible capability toggles (not just gateway-reported ones) - // so disabled capabilities like TTS appear as "off" buttons - var allCaps = capToggles.Keys.ToList(); - - if (allCaps.Count > 0) + UpdatedAt = DateTime.UtcNow, + Providers = new() { - var columns = 3; - var grid = new Grid + new GatewayUsageProviderInfo { - Margin = new Thickness(28, 4, 14, 4), - ColumnSpacing = 4, - RowSpacing = 4, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - for (int c = 0; c < columns; c++) - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - var rowCount = (allCaps.Count + columns - 1) / columns; - for (int r = 0; r < rowCount; r++) - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - for (int i = 0; i < allCaps.Count; i++) - { - var cap = allCaps[i]; - var capToggle = capToggles[cap]; - var icon = CapabilityIcons.TryGetValue(cap, out var emoji) ? emoji : "▪"; - var label = char.ToUpper(cap[0]) + cap[1..]; - var isOn = capToggle.Get(); - - var btn = new ToggleButton + Provider = "anthropic", DisplayName = "Anthropic", + Plan = "Pro", + Windows = new() { - IsChecked = isOn, - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Center, - Padding = new Thickness(6, 5, 6, 5), - MinHeight = 0, - MinWidth = 0, - Content = new TextBlock - { - Text = $"{icon} {label}", - FontSize = 11, - TextTrimming = TextTrimming.CharacterEllipsis - } - }; - var capRef = capToggle; // capture for lambda - btn.Click += (s, ev) => + new() { Label = "5h window", UsedPercent = 64 }, + new() { Label = "Weekly", UsedPercent = 28 }, + new() { Label = "Monthly", UsedPercent = 0 }, + }, + }, + new GatewayUsageProviderInfo + { + Provider = "openai", DisplayName = "OpenAI", + Plan = "Tier 4", + Windows = new() { - var on = ((ToggleButton)s!).IsChecked == true; - capRef.Set(on); _settings.Save(); ReconnectNodeServiceOnly(); - }; - Grid.SetRow(btn, i / columns); - Grid.SetColumn(btn, i % columns); - grid.Children.Add(btn); - } - menu.AddCustomElement(grid); - } - } + new() { Label = "RPM", UsedPercent = 41 }, + new() { Label = "TPM", UsedPercent = 73 }, + new() { Label = "Daily", UsedPercent = 96 }, + }, + }, + }, + }; } } - - // ── Actions ── - menu.AddSeparator(); - menu.AddMenuItem("Dashboard", "🌐", "dashboard"); - menu.AddMenuItem("Chat", "💬", "openchat"); - menu.AddMenuItem("Canvas", "🎨", "canvas"); - menu.AddMenuItem("Companion", "🦞", "companion"); - menu.AddMenuItem(LocalizationHelper.GetString("Menu_QuickSend"), "📤", "quicksend"); - - // ── Exit ── - menu.AddSeparator(); - menu.AddMenuItem(LocalizationHelper.GetString("Menu_Exit"), "❌", "exit"); } - private static string FormatTokenCount(long n) - { - if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M"; - if (n >= 1_000) return $"{n / 1_000.0:F1}K"; - return n.ToString(); - } - // ── Rich card builder helpers for tray menu ── + private readonly Dictionary _permToggleActions = new(StringComparer.Ordinal); - private static readonly FrozenDictionary CapabilityIcons = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["screen"] = "🖥", - ["camera"] = "📷", - ["browser"] = "🌐", - ["clipboard"] = "📋", - ["tts"] = "🔊", - ["location"] = "📍", - ["canvas"] = "🎨", - ["system"] = "⚙", - ["device"] = "📱", - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - - private static Grid BuildSectionHeader(string title, string summary) - { - var grid = new Grid - { - Padding = new Thickness(12, 8, 12, 4), - HorizontalAlignment = HorizontalAlignment.Stretch - }; - grid.Children.Add(new TextBlock - { - Text = title, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - Opacity = 0.7, - VerticalAlignment = VerticalAlignment.Center - }); - grid.Children.Add(new TextBlock - { - Text = summary, - HorizontalAlignment = HorizontalAlignment.Right, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - FontSize = 11, - VerticalAlignment = VerticalAlignment.Center - }); - return grid; - } + #region Gateway Client - private static UIElement BuildSessionCard(SessionInfo session) + private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) { - var usedTokens = session.InputTokens + session.OutputTokens; - var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; - var pct = usedTokens > 0 ? (int)(Math.Min(1.0, (double)usedTokens / contextTokens) * 100) : 0; - var isActive = string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase); - var isIdle = string.Equals(session.Status, "idle", StringComparison.OrdinalIgnoreCase); - - var grid = new Grid - { - Padding = new Thickness(12, 6, 12, 6), - HorizontalAlignment = HorizontalAlignment.Stretch, - RowSpacing = 2, - ColumnSpacing = 6 - }; - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // status dot - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // model badge - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // chevron - - // Row 0: status dot - var dot = new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - isActive ? Microsoft.UI.Colors.LimeGreen - : isIdle ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.Gray) - }; - Grid.SetRow(dot, 0); - Grid.SetColumn(dot, 0); - grid.Children.Add(dot); - - // Row 0: session name - var nameBlock = new TextBlock - { - Text = session.DisplayName ?? session.Key, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 12, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetRow(nameBlock, 0); - Grid.SetColumn(nameBlock, 1); - grid.Children.Add(nameBlock); + if (_settings == null || _connectionManager == null || _gatewayRegistry == null) return; + // SSH tunnel lifecycle is now handled by the connection manager. + + var gatewayUrl = _settings.GetEffectiveGatewayUrl(); - // Row 0: model badge - if (!string.IsNullOrEmpty(session.Model)) + // Check registry first — it's the source of truth after initial setup + var activeRecord = _gatewayRegistry.GetActive(); + if (activeRecord != null) { - var modelBadge = BuildBadge(session.Model); - Grid.SetRow(modelBadge, 0); - Grid.SetColumn(modelBadge, 2); - grid.Children.Add(modelBadge); + if (!TryConnectGatewayIfCredentialAvailable(activeRecord, "startup")) + { + // Still start MCP-only node if enabled — the active record may be stale + // and MCP-only mode must work without gateway credentials. + TryStartLocalMcpOnlyNode(); + } + return; } - // Row 0: chevron - var chevron = new TextBlock + TryMigrateLegacyGatewaySettings(gatewayUrl, new AppLogger()); + activeRecord = _gatewayRegistry.GetActive(); + if (activeRecord != null) { - Text = "›", - FontSize = 14, - Opacity = 0.5, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetRow(chevron, 0); - Grid.SetColumn(chevron, 3); - grid.Children.Add(chevron); + if (!TryConnectGatewayIfCredentialAvailable(activeRecord, "legacy migration")) + TryStartLocalMcpOnlyNode(); + return; + } - // Row 1: token info + channel badge + status - var row1 = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 6, - VerticalAlignment = VerticalAlignment.Center - }; - var tokenText = usedTokens > 0 - ? $"{FormatTokenCount(usedTokens)}/{FormatTokenCount(contextTokens)} ({pct}%)" - : ""; - if (!string.IsNullOrEmpty(tokenText)) + if (string.IsNullOrWhiteSpace(gatewayUrl)) { - row1.Children.Add(new TextBlock - { - Text = tokenText, - FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); + if (TryStartLocalMcpOnlyNode()) + return; + + Logger.Info("Gateway URL not configured — skipping client initialization"); + return; } - if (!string.IsNullOrEmpty(session.Channel)) + + // Bridge: create/update a GatewayRecord from current settings URL. + // Credentials come from GatewayRegistry and DeviceIdentity, not settings. + var existing = _gatewayRegistry.FindByUrl(gatewayUrl); + if (existing != null) { - var channelAbbrev = session.Channel!.Length <= 2 - ? session.Channel.ToUpperInvariant() - : session.Channel[..2].ToUpperInvariant(); - row1.Children.Add(BuildBadge(channelAbbrev)); + // Record already exists — just ensure it's active and connect + _gatewayRegistry.SetActive(existing.Id); } - var statusText = string.IsNullOrEmpty(session.Status) ? "Unknown" - : char.ToUpperInvariant(session.Status[0]) + session.Status[1..]; - row1.Children.Add(new TextBlock - { - Text = statusText, - FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - Grid.SetRow(row1, 1); - Grid.SetColumn(row1, 1); - Grid.SetColumnSpan(row1, 3); - grid.Children.Add(row1); - - // Row 2: thin progress bar - if (usedTokens > 0) + else { - var bar = new ProgressBar + // No record yet — create one from settings URL if we have a stored device token. + var hasStoredDeviceToken = DeviceIdentity.HasStoredDeviceToken( + Path.Combine(SettingsManager.SettingsDirectoryPath)); + if (!hasStoredDeviceToken) { - Minimum = 0, - Maximum = 100, - Value = pct, - Height = 3, - HorizontalAlignment = HorizontalAlignment.Stretch, - CornerRadius = new CornerRadius(1.5), - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush( - pct > 80 ? Microsoft.UI.Colors.Red - : pct > 50 ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.LimeGreen) - }; - Grid.SetRow(bar, 2); - Grid.SetColumn(bar, 0); - Grid.SetColumnSpan(bar, 4); - grid.Children.Add(bar); - } - - return grid; - } - - private static List BuildSessionFlyoutItems(SessionInfo session) - { - var usedTokens = session.InputTokens + session.OutputTokens; - var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; - var pct = usedTokens > 0 ? (int)(Math.Min(1.0, (double)usedTokens / contextTokens) * 100) : 0; - var statusIcon = string.Equals(session.Status, "active", StringComparison.OrdinalIgnoreCase) ? "🟢" - : string.Equals(session.Status, "done", StringComparison.OrdinalIgnoreCase) ? "✅" : "⚪"; + if (TryStartLocalMcpOnlyNode()) + return; - var items = new List - { - new() { Text = session.DisplayName ?? session.Key, IsHeader = true }, - }; + Logger.Info("No stored device token — skipping startup connect (use Setup Code)"); + return; + } - // Model · Provider - var modelParts = new List(); - if (!string.IsNullOrEmpty(session.Model)) modelParts.Add(session.Model); - if (!string.IsNullOrEmpty(session.Provider)) modelParts.Add(session.Provider); - if (modelParts.Count > 0) items.Add(new() { Text = string.Join(" · ", modelParts) }); + var recordId = Guid.NewGuid().ToString(); + var record = new GatewayRecord + { + Id = recordId, + Url = gatewayUrl, + IsLocal = LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl), + SshTunnel = _settings.UseSshTunnel + ? new SshTunnelConfig( + _settings.SshTunnelUser ?? "", + _settings.SshTunnelHost ?? "", + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + _settings.NodeBrowserProxyEnabled && + SshTunnelCommandLine.CanForwardBrowserProxyPort( + _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort)) + : null, + }; + _gatewayRegistry.AddOrUpdate(record); + _gatewayRegistry.SetActive(recordId); + } - // Channel - if (!string.IsNullOrEmpty(session.Channel)) - items.Add(new() { Text = $"📡 {session.Channel}" }); + var migratedRecord = _gatewayRegistry.GetActive()!; - // Status · age - items.Add(new() { Text = $"{statusIcon} {session.Status} · {session.AgeText}" }); + // Ensure identity directory exists for credential resolution + var identityDir = _gatewayRegistry.GetIdentityDirectory(migratedRecord.Id); + if (!Directory.Exists(identityDir)) + Directory.CreateDirectory(identityDir); - // Token usage - items.Add(new() { Text = "Token Usage", IsHeader = true }); - if (usedTokens > 0) + // Copy identity file from legacy location if needed. + // device-key-ed25519.json holds BOTH the operator DeviceToken and the + // node NodeDeviceToken on a single record (DeviceIdentity.DeviceKeyData), + // so this single copy migrates both roles' identity for paired-pre- + // unification installs (the easy-button setup engine used to write the + // node-side tokens to this same legacy path via NodeService.ConnectAsync). + // The legacy file is preserved (copy, not move) for at least one release + // to allow safe rollback. + var legacyIdentityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); + var newIdentityPath = Path.Combine(identityDir, "device-key-ed25519.json"); + if (File.Exists(legacyIdentityPath) && !File.Exists(newIdentityPath)) { - items.Add(new() { Text = $"Input {FormatTokenCount(session.InputTokens)}" }); - items.Add(new() { Text = $"Output {FormatTokenCount(session.OutputTokens)}" }); - items.Add(new() { Text = $"Total {FormatTokenCount(usedTokens)} / {FormatTokenCount(contextTokens)} ({pct}%)" }); - } - else - { - items.Add(new() { Text = "No token usage yet" }); + try { File.Copy(legacyIdentityPath, newIdentityPath, overwrite: false); } + catch (Exception ex) { Logger.Warn($"Failed to copy identity file: {ex.Message}"); } } - // Context window - if (session.ContextTokens > 0) - items.Add(new() { Text = $"Context {FormatTokenCount(session.ContextTokens)} window" }); + // Delegate to connection manager — it creates the client, fires OperatorClientChanged, + // and our handler re-wires the 27 event subscriptions + if (!TryConnectGatewayIfCredentialAvailable(migratedRecord, "startup bridge")) + TryStartLocalMcpOnlyNode(); + } + + /// + /// Connects only when the active gateway has a usable operator credential: + /// device token, shared gateway token, or bootstrap token. + /// + private bool TryConnectGatewayIfCredentialAvailable(GatewayRecord record, string context) + { + if (_connectionManager == null) + return false; - // Thinking / Verbose - if (!string.IsNullOrEmpty(session.ThinkingLevel) || !string.IsNullOrEmpty(session.VerboseLevel)) + var credential = ResolveStartupOperatorCredential(record); + if (credential == null) { - items.Add(new() { Text = "Settings", IsHeader = true }); - if (!string.IsNullOrEmpty(session.ThinkingLevel)) - items.Add(new() { Text = $"🧠 Thinking: {session.ThinkingLevel}" }); - if (!string.IsNullOrEmpty(session.VerboseLevel)) - items.Add(new() { Text = $"📝 Verbose: {session.VerboseLevel}" }); + Logger.Info($"Active gateway has no usable credential — skipping {context} connect"); + return false; } - // Subject / Room - if (!string.IsNullOrEmpty(session.Subject)) - items.Add(new() { Text = $"Subject: {session.Subject}" }); - if (!string.IsNullOrEmpty(session.Room)) - items.Add(new() { Text = $"Room: {session.Room}" }); - - return items; + var connectionKind = record.LastConnected.HasValue + ? "last successful gateway" + : "credentialed gateway"; + Logger.Info($"Connecting to {connectionKind} during {context}: {record.Url} ({credential.Source})"); + _ = _connectionManager.ConnectAsync(record.Id); + return true; } - private static UIElement BuildDeviceCard(GatewayNodeInfo node) + private OpenClaw.Connection.GatewayCredential? ResolveStartupOperatorCredential(GatewayRecord record) { - var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; + if (_gatewayRegistry == null) + return null; - var grid = new Grid - { - Padding = new Thickness(12, 6, 12, 6), - HorizontalAlignment = HorizontalAlignment.Stretch, - RowSpacing = 2, - ColumnSpacing = 6 - }; - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // dot - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // name - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // platform badge - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // chevron - - // Row 0: status dot - var dot = new Microsoft.UI.Xaml.Shapes.Ellipse - { - Width = 8, Height = 8, - VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - node.IsOnline ? Microsoft.UI.Colors.LimeGreen : Microsoft.UI.Colors.Gray) - }; - Grid.SetRow(dot, 0); - Grid.SetColumn(dot, 0); - grid.Children.Add(dot); - - // Row 0: device name - var nameBlock = new TextBlock - { - Text = nodeName, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - FontSize = 12, - TextTrimming = TextTrimming.CharacterEllipsis, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetRow(nameBlock, 0); - Grid.SetColumn(nameBlock, 1); - grid.Children.Add(nameBlock); + var resolver = new CredentialResolver(DeviceIdentityFileReader.Instance); + var identityDir = _gatewayRegistry.GetIdentityDirectory(record.Id); + var credential = resolver.ResolveOperator(record, identityDir); + if (credential != null) + return credential; - // Row 0: platform badge - if (!string.IsNullOrEmpty(node.Platform)) + // Backfill for legacy installs that still have the identity file at the + // root settings path while the active registry record points at that URL. + var effectiveUrl = _settings?.GetEffectiveGatewayUrl(); + if (!string.IsNullOrWhiteSpace(effectiveUrl) && + string.Equals(record.Url, effectiveUrl, StringComparison.OrdinalIgnoreCase)) { - var badge = BuildBadge(node.Platform); - Grid.SetRow(badge, 0); - Grid.SetColumn(badge, 2); - grid.Children.Add(badge); + return resolver.ResolveOperator(record, SettingsManager.SettingsDirectoryPath); } - // Row 0: chevron - var chevron = new TextBlock - { - Text = "›", - FontSize = 14, - Opacity = 0.5, - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }; - Grid.SetRow(chevron, 0); - Grid.SetColumn(chevron, 3); - grid.Children.Add(chevron); + return null; + } - // Row 1: capability icons + count + online/offline - var row1 = new StackPanel + private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger logger) + { + if (_settings == null || _gatewayRegistry == null || string.IsNullOrWhiteSpace(gatewayUrl)) { - Orientation = Orientation.Horizontal, - Spacing = 4, - VerticalAlignment = VerticalAlignment.Center - }; + return; + } - // Capability emoji icons - var capIcons = new System.Text.StringBuilder(); - if (node.Capabilities.Count > 0) + var legacyIdentityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); + if (!_settings.HasLegacyGatewayCredentials && !File.Exists(legacyIdentityPath)) { - foreach (var cap in node.Capabilities) - { - if (CapabilityIcons.TryGetValue(cap, out var icon)) - capIcons.Append(icon); - } + return; } - var capText = capIcons.Length > 0 - ? $"{capIcons} {node.CapabilityCount} caps" - : node.CapabilityCount > 0 ? $"{node.CapabilityCount} caps" : ""; - var statusLabel = node.IsOnline ? "online" : "offline"; - var row1Text = !string.IsNullOrEmpty(capText) ? $"{capText} · {statusLabel}" : statusLabel; - row1.Children.Add(new TextBlock - { - Text = row1Text, - FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - VerticalAlignment = VerticalAlignment.Center, - IsTextSelectionEnabled = false - }); - Grid.SetRow(row1, 1); - Grid.SetColumn(row1, 1); - Grid.SetColumnSpan(row1, 3); - grid.Children.Add(row1); + var migrated = _gatewayRegistry.MigrateFromSettings( + gatewayUrl, + _settings.LegacyToken, + _settings.LegacyBootstrapToken, + _settings.UseSshTunnel, + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + SettingsManager.SettingsDirectoryPath, + logger); - return grid; + if (migrated) + { + Logger.Info("[GatewayRegistry] Migrated legacy gateway settings into registry"); + } } - private static List BuildDeviceFlyoutItems(GatewayNodeInfo node) + private bool TryStartLocalMcpOnlyNode() { - var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; - var items = new List + if (_settings == null || !_settings.EnableMcpServer || _settings.EnableNodeMode) { - new() { Text = nodeName, IsHeader = true }, - }; - - // Status + platform + mode on one line - var statusIcon = node.IsOnline ? "🟢" : "⚪"; - var statusText = node.IsOnline ? "Online" : "Offline"; - var infoParts = new List { $"{statusIcon} {statusText}" }; - if (!string.IsNullOrEmpty(node.Platform)) infoParts.Add(node.Platform); - if (!string.IsNullOrEmpty(node.Mode)) infoParts.Add(node.Mode); - items.Add(new() { Text = string.Join(" · ", infoParts) }); + return false; + } - // Last seen - if (node.LastSeen.HasValue) + var nodeService = EnsureNodeService(_settings); + if (nodeService == null) { - var age = DateTime.UtcNow - node.LastSeen.Value; - var seenText = age.TotalMinutes < 1 ? "just now" - : age.TotalHours < 1 ? $"{(int)age.TotalMinutes}m ago" - : age.TotalDays < 1 ? $"{(int)age.TotalHours}h ago" - : $"{(int)age.TotalDays}d ago"; - items.Add(new() { Text = $"Last seen {seenText}" }); + Logger.Warn("MCP-only mode requested but node service could not be initialized"); + return false; } - // Capabilities + Commands merged — capability as header, commands as details - if (node.Capabilities.Count > 0 || node.Commands.Count > 0) + try { - items.Add(new() { Text = $"Capabilities ({node.CapabilityCount}) · Commands ({node.CommandCount})", IsHeader = true }); - - var cmdGroups = node.Commands - .GroupBy(c => c.Contains('.') ? c[..c.IndexOf('.')] : c, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Select(c => c.Contains('.') ? c[(c.IndexOf('.') + 1)..] : c).ToList(), StringComparer.OrdinalIgnoreCase); - - // Show each capability with its commands on separate lines - var shownGroups = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var cap in node.Capabilities) - { - var icon = CapabilityIcons.TryGetValue(cap, out var emoji) ? emoji : "▪"; - if (cmdGroups.TryGetValue(cap, out var cmds) && cmds.Count > 0) - { - items.Add(new() { Text = $"{icon} {cap}" }); - items.Add(new() { Text = $" {string.Join(", ", cmds)}" }); - shownGroups.Add(cap); - } - else - { - items.Add(new() { Text = $"{icon} {cap}" }); - shownGroups.Add(cap); - } - } - - // Show any command groups not covered by a capability - foreach (var group in cmdGroups.Where(g => !shownGroups.Contains(g.Key)).OrderBy(g => g.Key)) - { - items.Add(new() { Text = $"▸ {group.Key}" }); - items.Add(new() { Text = $" {string.Join(", ", group.Value)}" }); - } + nodeService.StartLocalOnlyAsync().GetAwaiter().GetResult(); + Logger.Info("Started MCP-only node service without gateway connection"); + return true; + } + catch (Exception ex) + { + Logger.Error($"Failed to start MCP-only node service: {ex}"); + return false; } - - return items; } - private static Border BuildBadge(string text) + /// + /// Handles the connection manager's OperatorClientChanged event. + /// Re-wires all 27 data event handlers from the old client to the new one. + /// + private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventArgs e) { - return new Border + if (_dispatcherQueue is { HasThreadAccess: false } dispatcher) { - Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"], - CornerRadius = new CornerRadius(3), - Padding = new Thickness(5, 1, 5, 1), - VerticalAlignment = VerticalAlignment.Center, - Child = new TextBlock + if (!dispatcher.TryEnqueue(() => OnOperatorClientChanged(sender, e))) { - Text = text, - FontSize = 10, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], - IsTextSelectionEnabled = false + Logger.Warn("[ConnMgr] Failed to dispatch operator client swap to UI thread"); } - }; - } - - #region Gateway Client + return; + } - private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) - { - if (_settings == null) return; - if (!EnsureSshTunnelConfigured()) return; + // Delegate all 27 event subscriptions to GatewayService + _gatewayService?.AttachClient(e.NewClient, e.OldClient); - // Guard against empty gateway URL (e.g., fresh install before onboarding) - var gatewayUrl = _settings.GetEffectiveGatewayUrl(); - if (string.IsNullOrWhiteSpace(gatewayUrl)) + // Configure new client + if (e.NewClient is { } client) { - Logger.Info("Gateway URL not configured — skipping client initialization"); - return; - } + client.SetUserRules(_settings?.UserRules?.Count > 0 ? _settings.UserRules : null); + client.SetPreferStructuredCategories(_settings?.PreferStructuredCategories ?? true); - if (string.IsNullOrWhiteSpace(_settings.Token)) + var concreteClient = client as OpenClawGatewayClient; + if (concreteClient == null) + Logger.Warn("[ConnMgr] NewClient is not OpenClawGatewayClient — chat coordinator disabled"); + _chatCoordinator?.SetOperatorClient(concreteClient); + } + else { - Logger.Info("Gateway token not configured — skipping operator client initialization"); - return; + _chatCoordinator?.SetOperatorClient(null); } - // Unsubscribe from old client if exists - UnsubscribeGatewayEvents(); - _lastGatewaySelf = null; + RaiseChatProviderChanged(); - _gatewayClient = new OpenClawGatewayClient( - gatewayUrl, - _settings.Token, - new AppLogger(), - useBootstrapHandoffAuth); - _gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null); - _gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories); - _gatewayClient.StatusChanged += OnConnectionStatusChanged; - _gatewayClient.AuthenticationFailed += OnAuthenticationFailed; - _gatewayClient.ActivityChanged += OnActivityChanged; - _gatewayClient.NotificationReceived += OnNotificationReceived; - _gatewayClient.ChannelHealthUpdated += OnChannelHealthUpdated; - _gatewayClient.SessionsUpdated += OnSessionsUpdated; - _gatewayClient.UsageUpdated += OnUsageUpdated; - _gatewayClient.UsageStatusUpdated += OnUsageStatusUpdated; - _gatewayClient.UsageCostUpdated += OnUsageCostUpdated; - _gatewayClient.NodesUpdated += OnNodesUpdated; - _gatewayClient.SessionPreviewUpdated += OnSessionPreviewUpdated; - _gatewayClient.SessionCommandCompleted += OnSessionCommandCompleted; - _gatewayClient.GatewaySelfUpdated += OnGatewaySelfUpdated; - _gatewayClient.CronListUpdated += OnCronListUpdated; - _gatewayClient.CronStatusUpdated += OnCronStatusUpdated; - _gatewayClient.ConfigUpdated += OnConfigUpdated; - _gatewayClient.ConfigSchemaUpdated += OnConfigSchemaUpdated; - _gatewayClient.SkillsStatusUpdated += OnSkillsStatusUpdated; - _gatewayClient.AgentEventReceived += OnAgentEventReceived; - _gatewayClient.NodePairListUpdated += OnNodePairListUpdated; - _gatewayClient.DevicePairListUpdated += OnDevicePairListUpdated; - _gatewayClient.ModelsListUpdated += OnModelsListUpdated; - _gatewayClient.PresenceUpdated += OnPresenceUpdated; - _gatewayClient.AgentsListUpdated += OnAgentsListUpdated; - _gatewayClient.AgentFilesListUpdated += OnAgentFilesListUpdated; - _gatewayClient.AgentFileContentUpdated += OnAgentFileContentUpdated; - _ = _gatewayClient.ConnectAsync(); + // Update UI references + if (_appState != null) + _appState.GatewaySelf = null; } - private void UnsubscribeGatewayEvents() + private void RaiseChatProviderChanged() { - if (_gatewayClient != null) - { - _gatewayClient.StatusChanged -= OnConnectionStatusChanged; - _gatewayClient.AuthenticationFailed -= OnAuthenticationFailed; - _gatewayClient.ActivityChanged -= OnActivityChanged; - _gatewayClient.NotificationReceived -= OnNotificationReceived; - _gatewayClient.ChannelHealthUpdated -= OnChannelHealthUpdated; - _gatewayClient.SessionsUpdated -= OnSessionsUpdated; - _gatewayClient.UsageUpdated -= OnUsageUpdated; - _gatewayClient.UsageStatusUpdated -= OnUsageStatusUpdated; - _gatewayClient.UsageCostUpdated -= OnUsageCostUpdated; - _gatewayClient.NodesUpdated -= OnNodesUpdated; - _gatewayClient.SessionPreviewUpdated -= OnSessionPreviewUpdated; - _gatewayClient.SessionCommandCompleted -= OnSessionCommandCompleted; - _gatewayClient.GatewaySelfUpdated -= OnGatewaySelfUpdated; - _gatewayClient.CronListUpdated -= OnCronListUpdated; - _gatewayClient.CronStatusUpdated -= OnCronStatusUpdated; - _gatewayClient.ConfigUpdated -= OnConfigUpdated; - _gatewayClient.ConfigSchemaUpdated -= OnConfigSchemaUpdated; - _gatewayClient.SkillsStatusUpdated -= OnSkillsStatusUpdated; - _gatewayClient.AgentEventReceived -= OnAgentEventReceived; - _gatewayClient.NodePairListUpdated -= OnNodePairListUpdated; - _gatewayClient.DevicePairListUpdated -= OnDevicePairListUpdated; - _gatewayClient.ModelsListUpdated -= OnModelsListUpdated; - _gatewayClient.PresenceUpdated -= OnPresenceUpdated; - _gatewayClient.AgentsListUpdated -= OnAgentsListUpdated; - _gatewayClient.AgentFilesListUpdated -= OnAgentFilesListUpdated; - _gatewayClient.AgentFileContentUpdated -= OnAgentFileContentUpdated; - } + ChatProviderChanged?.Invoke(this, EventArgs.Empty); } - - private void InitializeNodeService() - { - if (_settings == null) return; - if (_dispatcherQueue == null) return; - var enableNode = _settings.EnableNodeMode; - var enableMcp = _settings.EnableMcpServer; - if (!enableNode && !enableMcp) return; + /// + /// Handles the connection manager's StateChanged event. + /// Maps the snapshot to the existing tray icon / UI status system. + /// Authoritative writer of gateway lifecycle status. Local prerequisite + /// failures can still mark the app Error before the manager can connect. + /// + private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot snap) + { + // Map OverallConnectionState to the existing ConnectionStatus enum + // for backward compat with tray icon and hub window + var mapped = snap.OverallState switch + { + OverallConnectionState.Idle => ConnectionStatus.Disconnected, + OverallConnectionState.Connecting => ConnectionStatus.Connecting, + OverallConnectionState.Connected => ConnectionStatus.Connected, + OverallConnectionState.Ready => ConnectionStatus.Connected, + OverallConnectionState.Degraded => ConnectionStatus.Connected, + OverallConnectionState.PairingRequired => ConnectionStatus.Connecting, + OverallConnectionState.Error => ConnectionStatus.Error, + OverallConnectionState.Disconnecting => ConnectionStatus.Disconnected, + _ => ConnectionStatus.Disconnected + }; + OnUiThread(() => + { + if (_appState != null) _appState.Status = mapped; + UpdateTrayIcon(); + SyncConnectionToggle(mapped); + if (mapped is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) + { + // Dismiss the tray menu on state change — it will capture fresh data on next open + _trayMenuWindow?.HideCascade(); + } + }); + } - // Gateway connection requires auth (operator token, bootstrap token, or stored device token); MCP doesn't. - var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, DataPath); + private NodeService? EnsureNodeService(SettingsManager settings) + { + if (_nodeService != null) + return _nodeService; - if (enableNode && !canRunGateway && !enableMcp) - { - Logger.Warn("Node mode enabled but no token or bootstrap token configured — skipping node service. Run Setup Guide to configure."); - return; - } + if (_dispatcherQueue == null) + return null; - // Surface gateway-disabled fallback so the user isn't surprised when - // they enabled both but only MCP comes up. - if (enableNode && !canRunGateway && enableMcp) + if (_gatewayService == null) { - Logger.Warn("Node mode enabled but gateway auth is missing — running MCP-only."); + Logger.Error("GatewayService must be initialized before NodeService event wiring"); + return null; } try @@ -1603,32 +1831,23 @@ private void InitializeNodeService() _dispatcherQueue, DataPath, () => _keepAliveWindow?.Content as FrameworkElement, - _settings, - enableMcpServer: enableMcp); + settings, + enableMcpServer: settings.EnableMcpServer, + identityDataPath: IdentityDataPath); _nodeService.StatusChanged += OnNodeStatusChanged; _nodeService.NotificationRequested += OnNodeNotificationRequested; + _nodeService.ToastRequested += OnNodeToastRequested; _nodeService.PairingStatusChanged += OnPairingStatusChanged; - _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; + _nodeService.ChannelHealthUpdated += _gatewayService.OnChannelHealthUpdated; _nodeService.InvokeCompleted += OnNodeInvokeCompleted; - _nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated; - - if (canRunGateway) - { - Logger.Info($"Initializing Windows Node service (gateway{(enableMcp ? " + MCP" : "")})..."); - _ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token, _settings.BootstrapToken); - } - else - { - Logger.Info("Initializing Windows Node service (MCP-only, no gateway)..."); - _ = _nodeService.StartLocalOnlyAsync(); - } - - // Wire handlers after connect (RegisterCapabilities runs synchronously within Connect/StartLocal) - WireAppCapabilityHandlers(); + _nodeService.GatewaySelfUpdated += _gatewayService.OnGatewaySelfUpdated; + return _nodeService; } catch (Exception ex) { - Logger.Error($"Failed to initialize node service: {ex}"); + Logger.Error($"Failed to initialize node service for local gateway setup: {ex}"); + _nodeService = null; + return null; } } @@ -1651,18 +1870,18 @@ private void WireAppCapabilityHandlers() app.StatusHandler = () => new { - connectionStatus = _currentStatus.ToString(), + connectionStatus = _appState!.Status.ToString(), nodeConnected = _nodeService?.IsConnected ?? false, nodePaired = _nodeService?.IsPaired ?? false, nodePendingApproval = _nodeService?.IsPendingApproval ?? false, - gatewayVersion = _lastGatewaySelf?.ServerVersion, - sessionCount = _lastSessions?.Length ?? 0, - nodeCount = _lastNodes?.Length ?? 0, + gatewayVersion = _appState!.GatewaySelf?.ServerVersion, + sessionCount = _appState!.Sessions?.Length ?? 0, + nodeCount = _appState!.Nodes?.Length ?? 0, }; app.SessionsHandler = async (agentId) => { - var sessions = _lastSessions ?? Array.Empty(); + var sessions = _appState!.Sessions ?? Array.Empty(); if (!string.IsNullOrEmpty(agentId)) sessions = sessions.Where(s => s.Key != null && s.Key.StartsWith($"agent:{agentId}:", StringComparison.OrdinalIgnoreCase)).ToArray(); @@ -1671,8 +1890,8 @@ private void WireAppCapabilityHandlers() app.AgentsHandler = async () => { - if (_lastAgentsList.HasValue && - _lastAgentsList.Value.TryGetProperty("agents", out var agentsArr) && + if (_appState!.AgentsList.HasValue && + _appState!.AgentsList.Value.TryGetProperty("agents", out var agentsArr) && agentsArr.ValueKind == System.Text.Json.JsonValueKind.Array) { return System.Text.Json.JsonSerializer.Deserialize(agentsArr.GetRawText()); @@ -1682,15 +1901,15 @@ private void WireAppCapabilityHandlers() app.NodesHandler = () => { - return _lastNodes?.Select(n => new { n.DisplayName, n.NodeId, n.IsOnline, n.Platform, n.CapabilityCount }).ToArray() + return _appState!.Nodes?.Select(n => new { n.DisplayName, n.NodeId, n.IsOnline, n.Platform, n.CapabilityCount }).ToArray() ?? Array.Empty(); }; app.ConfigGetHandler = async (path) => { - if (_hubWindow?.LastConfig == null) return new { error = "Config not loaded" }; + if (_appState?.Config == null) return new { error = "Config not loaded" }; // Config is already redacted by the gateway's redactConfigSnapshot - var raw = _hubWindow.LastConfig.Value; + var raw = _appState.Config.Value; var config = raw.TryGetProperty("parsed", out var parsed) ? parsed : (raw.TryGetProperty("config", out var cfg) ? cfg : raw); if (!string.IsNullOrEmpty(path)) @@ -1746,9 +1965,9 @@ private void WireAppCapabilityHandlers() { var items = new List { - new { type = "status", status = _currentStatus.ToString() }, - new { type = "sessions", count = _lastSessions?.Length ?? 0 }, - new { type = "nodes", count = _lastNodes?.Length ?? 0 }, + new { type = "status", status = _appState!.Status.ToString() }, + new { type = "sessions", count = _appState!.Sessions?.Length ?? 0 }, + new { type = "nodes", count = _appState!.Nodes?.Length ?? 0 }, }; return items; }; @@ -1767,16 +1986,105 @@ private void WireAppCapabilityHandlers() }; } - private static bool RequiresSetup(SettingsManager settings) + private bool RequiresSetup(SettingsManager settings) { - return StartupSetupState.RequiresSetup(settings, DataPath); + return StartupSetupState.RequiresSetup(settings, IdentityDataPath, _gatewayRegistry); } private bool ShouldInitializeNodeService() { + if (_suppressNodeDuringSetup) return false; return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true; } + /// + /// Re-arms the WSL keepalive process for the local OpenClawGateway distro on every + /// tray launch when the active gateway is local. The keepalive runs detached from + /// the tray (see ) so the WSL2 VM stays up even after + /// the tray exits — addressing the vmIdleTimeout window where Windows tears down the + /// distro (and therefore systemd + the gateway service) ~20s after the last + /// interactive wsl.exe session ends. + /// + /// + /// Best-effort, fire-and-forget. Cold Windows logon is exactly when LxssManager is + /// slow to come up, so a hanging wsl --list probe must never block tray + /// startup. We retry with bounded backoff; if every probe times out, we still try + /// the spawn and let wsl.exe itself decide whether the distro exists — + /// missing a keepalive arm at cold logon is the exact bug this method exists to + /// prevent (see PR review feedback). + /// + private async Task TryEnsureLocalGatewayKeepAliveAsync() + { + try + { + if (_settings is null) return; + + var gatewayUrl = _settings.GetEffectiveGatewayUrl(); + var distroName = await ResolveLocalGatewayDistroNameAsync(); + + if (string.IsNullOrWhiteSpace(gatewayUrl) || !LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl)) + { + // User is no longer using the local gateway (mode switch since the last + // launch). Tear down any keepalive marker that a prior session left + // behind so the WSL VM is allowed to idle out; we never reach + // EnsureStarted in this branch. + if (!string.IsNullOrWhiteSpace(distroName)) + WslDistroKeepAlive.Stop(distroName, new AppLogger()); + return; + } + + if (string.IsNullOrWhiteSpace(distroName)) return; + + var runner = new WslExeCommandRunner(new AppLogger(), defaultTimeout: TimeSpan.FromSeconds(4)); + await WslKeepAliveStartupArmer.ArmAsync( + distroName, + cancellationToken => runner.RunAsync(["--list", "--verbose"], cancellationToken), + () => WslDistroKeepAlive.EnsureStarted(distroName, new AppLogger()), + Logger.Warn); + } + catch (Exception ex) + { + Logger.Warn($"[WslKeepAlive] Startup keepalive failed (non-fatal): {ex.Message}"); + } + } + + /// + /// Resolves the WSL distro name to keep alive. Prefers the value persisted by + /// onboarding in setup-state.json so the keepalive always targets the distro + /// the user actually installed. In DEBUG / test builds, an + /// OPENCLAW_WSL_DISTRO_NAME environment override is honored to match + /// . Falls back + /// to the upstream default OpenClawGateway. + /// + private async Task ResolveLocalGatewayDistroNameAsync() + { + try + { + var store = new LocalGatewaySetupStateStore(); + var state = await store.LoadAsync(); + if (state is not null && !string.IsNullOrWhiteSpace(state.DistroName)) + return state.DistroName; + } + catch (Exception ex) + { + Logger.Warn($"[WslKeepAlive] Failed to read setup-state.json: {ex.Message}"); + } + +#if DEBUG || OPENCLAW_TRAY_TESTS + var envOverride = Environment.GetEnvironmentVariable(LocalGatewaySetupRuntimeConfiguration.DistroNameVariable); + if (!string.IsNullOrWhiteSpace(envOverride)) + return envOverride; +#endif + + return "OpenClawGateway"; + } + + // The pre-unification ShouldInitializeNodeService(GatewayRecord, string) overload + // and LocalNodeServiceOwnsIdentityFor have been removed: GatewayConnectionManager + // is now the single owner of the WindowsNodeClient lifecycle for ALL gateways + // (local + remote). NodeService remains as the capability registrar via the + // NodeConnector.ClientCreated → AttachClient bridge wired in InitializeApp. + private void OnNodeStatusChanged(object? sender, ConnectionStatus status) { Logger.Info($"Node status: {status}"); @@ -1785,28 +2093,58 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) // In node-only mode, surface node connection in main status indicator if (_settings?.EnableNodeMode == true) { - _currentStatus = status; - _hubWindow?.UpdateStatus(_currentStatus); + // Status field is maintained by OnManagerStateChanged — no write needed here. UpdateTrayIcon(); - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); + OnUiThread(UpdateStatusDetailWindow); } - - // Keep hub node state in sync for ConnectionPage - SyncHubNodeState(); // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead - if (status == ConnectionStatus.Connected && _nodeService?.IsPaired == true) + var nodeService = _nodeService; + if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true) { + var deviceId = nodeService.FullDeviceId; + if (_toastService!.HasRecentToast("node-paired", deviceId)) + { + Logger.Info($"[ToastDeduper] Suppressed node-connected toast after node-paired deviceId={deviceId}"); + return; + } + try { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_NodeModeActive")) - .AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail"))); + .AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail")), + "node-connected", + deviceId); } catch { /* ignore */ } } } + private void OnRecordingStateChanged(object? sender, RecordingStateEventArgs args) + { + var source = args.Type == RecordingType.Screen ? "Screen" : "Camera"; + if (args.IsActive) + { + var title = args.Type == RecordingType.Screen + ? LocalizationHelper.GetString("Activity_ScreenRecordingStarted") + : LocalizationHelper.GetString("Activity_CameraRecordingStarted"); + var duration = args.DurationMs > 0 ? $" ({args.DurationMs / 1000.0:0.#}s)" : ""; + AddRecentActivity($"{title}{duration}", category: "node", + icon: "🔴", + details: string.Format(LocalizationHelper.GetString("Activity_RecordingRequestedByAgent"), source)); + } + else + { + var title = args.Type == RecordingType.Screen + ? LocalizationHelper.GetString("Activity_ScreenRecordingComplete") + : LocalizationHelper.GetString("Activity_CameraRecordingComplete"); + AddRecentActivity(title, category: "node", + icon: "✅", + details: string.Format(LocalizationHelper.GetString("Activity_RecordingSentToAgent"), source)); + } + } + private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args) { Logger.Info($"Pairing status: {args.Status}"); @@ -1815,49 +2153,58 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu { if (args.Status == OpenClaw.Shared.PairingStatus.Pending) { + // Bug #2 (manual test 2026-05-05): suppress the "copy pairing command" + // toast while the local-setup engine is mid-Phase-14 node-role PairAsync. + // The loopback gateway parks the role-upgrade as Pending for ~100ms before + // SettingsWindowsTrayNodeProvisioner's pending-approver auto-approves it; + // the user never needs to copy the command in that window. Manual + // ConnectionPage pairings call ShowPairingPendingNotification directly + // (bypassing this event handler), so the suppression scope is exactly + // the autopair window. + if (LocalGatewaySetupEngine.ShouldSuppressPairingPendingNotification(_localSetupEngine, args.Status)) + { + Logger.Info($"Suppressing pairing-pending toast: autopair Phase 14 in progress for {args.DeviceId}"); + return; + } ShowPairingPendingNotification(args.DeviceId); } else if (args.Status == OpenClaw.Shared.PairingStatus.Paired) { - AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); - ShowToast(new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_NodePaired")) - .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail"))); + // Bug 3: idempotency guard — only show "Node paired" toast/activity once + // per device per session. WS reconnects re-fire Paired; suppress duplicates. + var deviceKey = args.DeviceId ?? string.Empty; + if (!_toastService!.HasShownPairedToast(deviceKey)) + { + _toastService!.MarkPairedToastShown(deviceKey); + AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); + _toastService!.ShowToast(new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_NodePaired")) + .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")), + "node-paired", + args.DeviceId); + } + else + { + Logger.Info($"Suppressing duplicate Paired toast for device {deviceKey}"); + } } else if (args.Status == OpenClaw.Shared.PairingStatus.Rejected) { AddRecentActivity("Node pairing rejected", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId, details: args.Message ?? LocalizationHelper.GetString("Toast_PairingRejectedDetail")); - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_PairingRejected")) - .AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail"))); + .AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail")), + "node-pairing-rejected", + args.DeviceId); } } catch { /* ignore */ } - - SyncHubNodeState(); } /// /// Pushes current node service state to hub window so ConnectionPage reflects live pairing/identity. + /// Now a no-op — pages read App properties directly via CurrentApp. /// - private void SyncHubNodeState() - { - if (_hubWindow == null || _hubWindow.IsClosed) return; - if (_nodeService != null) - { - _hubWindow.NodeIsConnected = _nodeService.IsConnected; - _hubWindow.NodeIsPaired = _nodeService.IsPaired; - _hubWindow.NodeIsPendingApproval = _nodeService.IsPendingApproval; - _hubWindow.NodeShortDeviceId = _nodeService.ShortDeviceId; - _hubWindow.NodeFullDeviceId = _nodeService.FullDeviceId; - } - else - { - _hubWindow.NodeIsConnected = false; - _hubWindow.NodeIsPaired = false; - _hubWindow.NodeIsPendingApproval = false; - } - } public static string BuildPairingApprovalCommand(string deviceId) => $"openclaw devices approve {deviceId}"; @@ -1868,13 +2215,15 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm var shortDeviceId = deviceId.Length > 16 ? deviceId[..16] : deviceId; AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: deviceId); - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_PairingPending")) .AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), shortDeviceId)) .AddButton(new ToastButton() .SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand")) .AddArgument("action", "copy_pairing_command") - .AddArgument("command", command))); + .AddArgument("command", command)), + "node-pairing-pending", + deviceId); } private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args) @@ -1884,7 +2233,7 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil // Agent requested a notification via node.invoke system.notify try { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(args.Title) .AddText(args.Body)); } @@ -1894,6 +2243,10 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil } } + private void OnNodeToastRequested(object? sender, Microsoft.Toolkit.Uwp.Notifications.ToastContentBuilder builder) + => OnUiThread(() => + NonFatalAction.Run(() => _toastService!.ShowToast(builder), msg => Logger.Warn($"Failed to show node toast: {msg}"))); + private void OnNodeInvokeCompleted(object? sender, NodeInvokeCompletedEventArgs args) { var status = args.Ok ? "completed" : "failed"; @@ -1909,7 +2262,7 @@ private void OnNodeInvokeCompleted(object? sender, NodeInvokeCompletedEventArgs details: details, nodeId: args.NodeId); - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); + OnUiThread(UpdateStatusDetailWindow); } private static string GetNodeInvokePrivacyClass(string command) @@ -1930,245 +2283,72 @@ private static string GetNodeInvokePrivacyClass(string command) return "metadata"; } - private void OnConnectionStatusChanged(object? sender, ConnectionStatus status) - { - _currentStatus = status; - DiagnosticsJsonlService.Write("connection.status", new - { - status = status.ToString(), - nodeMode = _settings?.EnableNodeMode == true - }); - _hubWindow?.UpdateStatus(_currentStatus); - if (status == ConnectionStatus.Connected) - { - _authFailureMessage = null; - if (_hubWindow != null && !_hubWindow.IsClosed) - _hubWindow.LastAuthError = null; - } - UpdateTrayIcon(); - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); - - if (status == ConnectionStatus.Connected) - { - _ = RunHealthCheckAsync(); - } - } + // ── Re-raised event handlers from GatewayService ────────────────── - private void OnAuthenticationFailed(object? sender, string message) + private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus status) { - _authFailureMessage = message; - Logger.Error($"Authentication failed: {message}"); - DiagnosticsJsonlService.Write("connection.auth_failed", new - { - message, - nodeMode = _settings?.EnableNodeMode == true - }); - AddRecentActivity($"Auth failed: {message}", category: "error"); - UpdateTrayIcon(); - - // Forward to hub/connection page - if (_hubWindow != null && !_hubWindow.IsClosed) + if (status == ConnectionStatus.Connected && _appState != null) { - _hubWindow.LastAuthError = message; - _hubWindow.UpdateStatus(_currentStatus); + _appState.AuthFailureMessage = null; } - } - private void OnActivityChanged(object? sender, AgentActivity? activity) - { - if (activity == null) - { - // Activity ended - if (_displayedSessionKey != null && _sessionActivities.ContainsKey(_displayedSessionKey)) - { - _sessionActivities.Remove(_displayedSessionKey); - } - _currentActivity = null; - } - else + UpdateTrayIcon(); + OnUiThread(() => { - var sessionKey = activity.SessionKey ?? "default"; - _sessionActivities[sessionKey] = activity; - AddRecentActivity( - $"{sessionKey}: {activity.Label}", - category: "session", - dashboardPath: $"sessions/{sessionKey}", - details: activity.Kind.ToString(), - sessionKey: sessionKey); - - // Debounce session switching - var now = DateTime.Now; - if (_displayedSessionKey != sessionKey && - (now - _lastSessionSwitch) > SessionSwitchDebounce) - { - _displayedSessionKey = sessionKey; - _lastSessionSwitch = now; - } - - if (_displayedSessionKey == sessionKey) + UpdateStatusDetailWindow(); + SyncConnectionToggle(status); + if (status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) { - _currentActivity = activity; + // Dismiss the tray menu on state change — it will capture fresh data on next open + _trayMenuWindow?.HideCascade(); } - } - - UpdateTrayIcon(); - } - - private void OnChannelHealthUpdated(object? sender, ChannelHealth[] channels) - { - _lastChannels = channels; - _lastCheckTime = DateTime.Now; - var signature = string.Join("|", channels - .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) - .Select(c => $"{c.Name}:{c.Status}:{c.Error}")); - if (!string.Equals(signature, _lastChannelStatusSignature, StringComparison.Ordinal)) - { - _lastChannelStatusSignature = signature; - var summary = channels.Length == 0 - ? "No channels reported" - : string.Join(", ", channels.Select(c => $"{c.Name}={c.Status}")); - DiagnosticsJsonlService.Write("gateway.health.channels", new - { - channelCount = channels.Length, - healthyCount = channels.Count(c => ChannelHealth.IsHealthyStatus(c.Status)), - errorCount = channels.Count(c => !string.IsNullOrWhiteSpace(c.Error)) - }); - AddRecentActivity("Channel health updated", category: "channel", dashboardPath: "channels", details: summary); - } - - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateChannelHealth(channels); - _hubWindow?.UpdateStatus(_currentStatus); - }); - } - - private void OnSessionsUpdated(object? sender, SessionInfo[] sessions) - { - _lastSessions = sessions; - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); - - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateSessions(sessions); }); - var activeKeys = new HashSet(sessions.Select(s => s.Key), StringComparer.Ordinal); - lock (_sessionPreviewsLock) - { - var stale = _sessionPreviews.Keys.Where(key => !activeKeys.Contains(key)).ToArray(); - foreach (var key in stale) - _sessionPreviews.Remove(key); - } - - if (_gatewayClient != null && - sessions.Length > 0 && - DateTime.UtcNow - _lastPreviewRequestUtc > TimeSpan.FromSeconds(5)) + if (status == ConnectionStatus.Connected) { - _lastPreviewRequestUtc = DateTime.UtcNow; - var keys = sessions.Take(5).Select(s => s.Key).ToArray(); - _ = _gatewayClient.RequestSessionPreviewAsync(keys, limit: 3, maxChars: 140); + _ = RunHealthCheckAsync(); + // For local gateways, the NodeConnector is suppressed because NodeService + // owns the identity. Connect the NodeService directly after operator connects. + _ = TryConnectLocalNodeServiceAsync(); } } - private void OnUsageUpdated(object? sender, GatewayUsageInfo usage) - { - _lastUsage = usage; - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateUsage(usage); - }); - } - - private void OnUsageStatusUpdated(object? sender, GatewayUsageStatusInfo usageStatus) - { - _lastUsageStatus = usageStatus; - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateUsageStatus(usageStatus); - }); - } - - private void OnUsageCostUpdated(object? sender, GatewayCostUsageInfo usageCost) + /// + /// Connects the local NodeService to the active gateway when the operator connection + /// is established. This handles the restart case where the NodeConnector is suppressed + /// for local gateways (LocalNodeServiceOwnsIdentityFor returns true) but the NodeService + /// was never told to connect. + /// + private async Task TryConnectLocalNodeServiceAsync() { - _lastUsageCost = usageCost; - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); - - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateUsageCost(usageCost); - }); + if (_connectionManager == null) + return; - if (DateTime.UtcNow - _lastUsageActivityLogUtc > TimeSpan.FromMinutes(1)) + Logger.Info("[App] Auto-connecting local NodeService via EnsureNodeConnectedAsync"); + try { - _lastUsageActivityLogUtc = DateTime.UtcNow; - AddRecentActivity( - $"{usageCost.Days}d usage ${usageCost.Totals.TotalCost:F2}", - category: "usage", - dashboardPath: "usage", - details: $"{usageCost.Totals.TotalTokens:N0} tokens"); + await _connectionManager.EnsureNodeConnectedAsync(); } - } - - private void OnGatewaySelfUpdated(object? sender, GatewaySelfInfo gatewaySelf) - { - _lastGatewaySelf = _lastGatewaySelf?.Merge(gatewaySelf) ?? gatewaySelf; - DiagnosticsJsonlService.Write("gateway.self", new - { - version = _lastGatewaySelf.ServerVersion, - protocol = _lastGatewaySelf.Protocol, - uptimeMs = _lastGatewaySelf.UptimeMs, - authMode = _lastGatewaySelf.AuthMode, - stateVersionPresence = _lastGatewaySelf.StateVersionPresence, - stateVersionHealth = _lastGatewaySelf.StateVersionHealth, - presenceCount = _lastGatewaySelf.PresenceCount - }); - _dispatcherQueue?.TryEnqueue(() => - { - UpdateStatusDetailWindow(); - _hubWindow?.UpdateGatewaySelf(_lastGatewaySelf); - }); - } - - private void OnNodesUpdated(object? sender, GatewayNodeInfo[] nodes) - { - var previousCount = _lastNodes.Length; - var previousOnline = _lastNodes.Count(n => n.IsOnline); - var online = nodes.Count(n => n.IsOnline); - _lastNodes = nodes; - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); - - _dispatcherQueue?.TryEnqueue(() => - { - _hubWindow?.UpdateNodes(nodes); - }); - - if (nodes.Length != previousCount || online != previousOnline) + catch (Exception ex) { - AddRecentActivity( - $"Nodes {online}/{nodes.Length} online", - category: "node", - dashboardPath: "nodes"); + Logger.Error($"[App] Local NodeService auto-connect failed: {ex.Message}"); } } - private void OnSessionPreviewUpdated(object? sender, SessionsPreviewPayloadInfo payload) + private void OnGatewayAuthenticationFailed(object? sender, string message) { - lock (_sessionPreviewsLock) + UpdateTrayIcon(); + + // Store auth failure in AppState — HubWindow observes it via PropertyChanged + if (_appState != null) { - foreach (var preview in payload.Previews) - { - _sessionPreviews[preview.Key] = preview; - } + _appState.AuthFailureMessage = message; } - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); } - private void OnSessionCommandCompleted(object? sender, SessionCommandResult result) + private void OnGatewaySessionCommandCompleted(object? sender, SessionCommandResult result) { - if (_dispatcherQueue == null) return; - - _dispatcherQueue.TryEnqueue(() => + OnUiThread(() => { try { @@ -2192,7 +2372,7 @@ private void OnSessionCommandCompleted(object? sender, SessionCommandResult resu dashboardPath: !string.IsNullOrWhiteSpace(result.Key) ? $"sessions/{result.Key}" : "sessions", sessionKey: result.Key); - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(title) .AddText(message)); } @@ -2204,88 +2384,40 @@ private void OnSessionCommandCompleted(object? sender, SessionCommandResult resu if (result.Ok) { - _ = _gatewayClient?.RequestSessionsAsync(); + _ = _connectionManager?.OperatorClient?.RequestSessionsAsync(); } } - private void OnCronListUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateCronList(data)); - } - - private void OnCronStatusUpdated(object? sender, System.Text.Json.JsonElement data) + private void OnGatewayNotificationReceived(object? sender, OpenClawNotification notification) { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateCronStatus(data)); - } - - private void OnSkillsStatusUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateSkillsStatus(data)); - } - - private void OnConfigUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateConfig(data)); - } - - private void OnConfigSchemaUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateConfigSchema(data)); - } - - private System.Text.Json.JsonElement? _lastAgentsList; - - private void OnAgentsListUpdated(object? sender, System.Text.Json.JsonElement data) - { - _lastAgentsList = data.Clone(); - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentsList(data)); - } - - private void OnAgentFilesListUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentFilesList(data)); - } - - private void OnAgentFileContentUpdated(object? sender, System.Text.Json.JsonElement data) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentFileContent(data)); - } - - private void OnAgentEventReceived(object? sender, AgentEventInfo evt) - { - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentEvent(evt)); - } - - private void OnNodePairListUpdated(object? sender, PairingListInfo data) - { - _lastNodePairList = data; - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateNodePairList(data)); - } - - private void OnDevicePairListUpdated(object? sender, DevicePairingListInfo data) - { - _lastDevicePairList = data; - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateDevicePairList(data)); - } + // Voice overlay: show agent chat responses, and (independently) speak them + // if the user enabled "Read responses aloud". + if (notification.IsChat && !string.IsNullOrEmpty(notification.Message)) + { + // Suppress TTS/voice overlay when the user has aborted the response. + if (ChatProvider?.IsResponseSuppressed == true) + return; - private void OnModelsListUpdated(object? sender, ModelsListInfo data) - { - _lastModelsList = data; - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateModelsList(data)); - } + // Voice overlay disabled — agent responses no longer routed to overlay window. + // if (_voiceOverlayWindow != null) + // { + // OnUiThread(() => + // { + // try + // { + // _voiceOverlayWindow?.AddAgentResponse(notification.Message); + // } + // catch { } + // }); + // } - private void OnPresenceUpdated(object? sender, PresenceEntry[] data) - { - _lastPresence = data; - _dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdatePresence(data)); - } + // TTS: read response aloud whenever the toggle is on (any chat surface). + if (_settings?.VoiceTtsEnabled == true) + { + _ = (_chatCoordinator?.SpeakResponseAsync(notification.Message) ?? Task.CompletedTask); + } + } - private void OnNotificationReceived(object? sender, OpenClawNotification notification) - { - AddRecentActivity( - $"{notification.Type ?? "info"}: {notification.Title ?? "notification"}", - category: "notification", - details: notification.Message); if (_settings?.ShowNotifications != true) return; if (!ShouldShowNotification(notification)) return; @@ -2304,15 +2436,12 @@ private void OnNotificationReceived(object? sender, OpenClawNotification notific .AddText(notification.Title ?? "OpenClaw") .AddText(notification.Message); - // Add category-specific inline image (emoji rendered as text is fine, - // but we can add app logo override for better visibility) var logoPath = GetNotificationIcon(notification.Type); if (!string.IsNullOrEmpty(logoPath) && System.IO.File.Exists(logoPath)) { builder.AddAppLogoOverride(new Uri(logoPath), ToastGenericAppLogoCrop.Circle); } - // Add "Open Chat" button for chat notifications if (notification.IsChat) { builder.AddArgument("action", "open_chat") @@ -2321,7 +2450,7 @@ private void OnNotificationReceived(object? sender, OpenClawNotification notific .AddArgument("action", "open_chat")); } - ShowToast(builder); + _toastService!.ShowToast(builder); } catch (Exception ex) { @@ -2329,12 +2458,70 @@ private void OnNotificationReceived(object? sender, OpenClawNotification notific } } + // ── AppState → tray-level side effects (tray icon, status detail) ── + // The tray menu is NOT refreshed live while open — data is frozen at + // open time via TrayMenuSnapshot to avoid WinUI layout races that cause + // blank subflyouts. The menu captures a fresh snapshot on every open. + + private void OnAppStateChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (_appState == null) return; + + switch (e.PropertyName) + { + case nameof(AppState.GatewaySelf): + case nameof(AppState.Sessions): + case nameof(AppState.UsageCost): + case nameof(AppState.Nodes): + UpdateStatusDetailWindow(); + break; + case nameof(AppState.CurrentActivity): + UpdateTrayIcon(); + break; + } + } + + + private void SyncConnectionToggle(ConnectionStatus status) + { + if (_connectionToggleRef == null) + return; + + if (!_connectionToggleRef.TryGetTarget(out var toggle)) + return; + + if (toggle.XamlRoot == null) + { + _connectionToggleRef = null; + return; + } + + var shouldBeOn = status == ConnectionStatus.Connected; + var canToggle = status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error; + _suspendConnectionToggleEvent = true; + try + { + if (toggle.IsOn != shouldBeOn) + toggle.IsOn = shouldBeOn; + + toggle.IsEnabled = canToggle; + ToolTipService.SetToolTip(toggle, + shouldBeOn ? "Connected - toggle off to disconnect" + : status == ConnectionStatus.Connecting ? "Connecting..." + : "Disconnected - toggle on to connect"); + } + finally + { + _suspendConnectionToggleEvent = false; + } + } + private static string? GetNotificationIcon(string? type) { // For now, use the app icon for all notifications // In the future, we could create category-specific icons var appDir = AppContext.BaseDirectory; - var iconPath = System.IO.Path.Combine(appDir, "Assets", "claw.ico"); + var iconPath = System.IO.Path.Combine(appDir, "Assets", "openclaw.ico"); return System.IO.File.Exists(iconPath) ? iconPath : null; } @@ -2351,6 +2538,8 @@ private bool ShouldShowNotification(OpenClawNotification notification) { if (_hubWindow != null && !_hubWindow.IsClosed) return false; + if (_chatWindow is { IsClosed: false, Visible: true }) + return false; if (_onboardingWindow != null) return false; // Onboarding window has chat overlay } @@ -2367,15 +2556,16 @@ private bool ShouldShowNotification(OpenClawNotification notification) /// User-initiated health check (from UI button). No background timers. private async Task RunHealthCheckAsync(bool userInitiated = false) { - if (_gatewayClient == null) + var client = _connectionManager?.OperatorClient; + if (client == null) { if (_settings?.EnableNodeMode == true && _nodeService?.IsConnected == true) { - _lastCheckTime = DateTime.Now; - _dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow); + _appState!.LastCheckTime = DateTime.Now; + OnUiThread(UpdateStatusDetailWindow); if (userInitiated) { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_HealthCheck")) .AddText("Node Mode is connected; gateway health is streaming.")); } @@ -2384,7 +2574,7 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) if (userInitiated) { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_HealthCheck")) .AddText(LocalizationHelper.GetString("Toast_HealthCheckNotConnected"))); } @@ -2393,11 +2583,11 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) try { - _lastCheckTime = DateTime.Now; - await _gatewayClient.CheckHealthAsync(); + _appState!.LastCheckTime = DateTime.Now; + await client.CheckHealthAsync(); if (userInitiated) { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_HealthCheck")) .AddText(LocalizationHelper.GetString("Toast_HealthCheckSent"))); } @@ -2407,7 +2597,7 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) Logger.Warn($"Health check failed: {ex.Message}"); if (userInitiated) { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_HealthCheckFailed")) .AddText(ex.Message)); } @@ -2420,21 +2610,24 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) private void UpdateTrayIcon() { - if (_trayIcon == null) return; - - var status = _currentStatus; - if (_currentActivity != null && _currentActivity.Kind != OpenClaw.Shared.ActivityKind.Idle) + if (_dispatcherQueue != null && !_dispatcherQueue.HasThreadAccess) { - status = ConnectionStatus.Connecting; // Use connecting icon for activity + _dispatcherQueue.TryEnqueue(UpdateTrayIcon); + return; } - var iconPath = IconHelper.GetStatusIconPath(status); + if (_trayIcon == null) return; + + // Tray icon is pinned to the app icon so it visually matches the agent + // avatar and chat-window title bar. Status is communicated via the + // tooltip text below rather than swapping the icon image. + var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "openclaw.ico"); var tooltip = BuildTrayTooltip(); try { _trayIcon.SetIcon(iconPath); - _trayIcon.Tooltip = tooltip; + ApplyTrayTooltip(tooltip); } catch (Exception ex) { @@ -2442,82 +2635,64 @@ private void UpdateTrayIcon() } } - private string BuildTrayTooltip() + private void ApplyTrayTooltip(string tooltip) { - var topology = GatewayTopologyClassifier.Classify( - _settings?.GatewayUrl, - _settings?.UseSshTunnel == true, - _settings?.SshTunnelHost, - _settings?.SshTunnelLocalPort ?? 0, - _settings?.SshTunnelRemotePort ?? 0); - var channelReady = _lastChannels.Count(c => ChannelHealth.IsHealthyStatus(c.Status)); - var nodeOnline = _lastNodes.Count(n => n.IsOnline); - var nodeTotal = _lastNodes.Length; - if (nodeTotal == 0 && _nodeService?.GetLocalNodeInfo() is { } localNode) - { - nodeTotal = 1; - nodeOnline = localNode.IsOnline ? 1 : 0; - } - - var warningCount = 0; - if (_currentStatus != ConnectionStatus.Connected) - warningCount++; - if (_authFailureMessage != null) - warningCount++; - if (_lastChannels.Length == 0 && _currentStatus == ConnectionStatus.Connected) - warningCount++; - - var tooltip = new List - { - $"OpenClaw Tray — {_currentStatus}", - $"Topology: {topology.DisplayName}", - $"Channels: {channelReady}/{_lastChannels.Length} ready · Nodes: {nodeOnline}/{nodeTotal} online", - $"Warnings: {warningCount} · Last check: {_lastCheckTime:HH:mm:ss}" - }; + if (_trayIcon == null) + return; - if (_currentActivity != null && !string.IsNullOrEmpty(_currentActivity.DisplayText)) + if (string.Equals(_trayIcon.Tooltip, tooltip, StringComparison.Ordinal)) { - tooltip.Insert(1, _currentActivity.DisplayText); + _trayIcon.Tooltip = string.Empty; } - return string.Join("\n", tooltip); + _trayIcon.Tooltip = tooltip; } + private string BuildTrayTooltip() => + new TrayTooltipBuilder(CaptureTraySnapshot()).Build(); + + private TrayStateSnapshot CaptureTraySnapshot() => new TrayStateSnapshot + { + Status = _appState!.Status, + CurrentActivity = _appState!.CurrentActivity, + Channels = _appState!.Channels, + Nodes = _appState!.Nodes, + LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), + AuthFailureMessage = _appState!.AuthFailureMessage, + LastCheckTime = _appState!.LastCheckTime, + Settings = _settings + }; + #endregion #region Window Management - private void ShowHub(string? navigateTo = null) + internal void ShowHub(string? navigateTo = null, bool activate = true, string? originTag = null) { if (_hubWindow == null || _hubWindow.IsClosed) { _hubWindow = new HubWindow(); - _hubWindow.Settings = _settings; - _hubWindow.GatewayClient = _gatewayClient; - _hubWindow.CurrentStatus = _currentStatus; - _hubWindow.OpenDashboardAction = OpenDashboard; - _hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync(); + _hubWindow.AppModel = _appState; + _hubWindow.ApplyNavPaneState(_settings!); _hubWindow.QuickSendAction = () => ShowQuickSend(); + _hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync(); + _hubWindow.OpenConnectionStatusAction = ShowConnectionStatusWindow; + _hubWindow.OpenVoiceAction = () => ShowHub("voice"); // was: ShowVoiceOverlay() + _hubWindow.ConnectionManager = _connectionManager; + _hubWindow.GatewayRegistry = _gatewayRegistry; _hubWindow.ConnectAction = () => { - InitializeGatewayClient(); - if (ShouldInitializeNodeService()) InitializeNodeService(); + _ = _connectionManager?.ReconnectAsync(); }; _hubWindow.DisconnectAction = () => { - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - var oldNode = _nodeService; - _nodeService = null; - try { oldNode?.Dispose(); } catch { } - _currentStatus = ConnectionStatus.Disconnected; + _ = _connectionManager?.DisconnectAsync(); + // Status is updated by OnManagerStateChanged when disconnect completes. UpdateTrayIcon(); - _hubWindow?.UpdateStatus(_currentStatus); }; _hubWindow.ReconnectAction = () => { - ReconnectGateway(); + _ = _connectionManager?.ReconnectAsync(); }; if (_nodeService != null) { @@ -2527,6 +2702,7 @@ private void ShowHub(string? navigateTo = null) _hubWindow.NodeShortDeviceId = _nodeService.ShortDeviceId; _hubWindow.NodeFullDeviceId = _nodeService.FullDeviceId; } + _hubWindow.VoiceServiceInstance = _nodeService?.VoiceService ?? _standaloneVoiceService; _hubWindow.SettingsSaved += OnSettingsSaved; _hubWindow.Closed += (s, e) => { @@ -2534,47 +2710,39 @@ private void ShowHub(string? navigateTo = null) _hubWindow = null; }; - // Seed ALL cached data BEFORE first navigation so pages see data in Initialize() - SeedHubCachedData(); + _hubWindow.BindToAppState(); - // Navigate to default page now that properties and data are set + // Navigate to default page now that AppModel is set _hubWindow.NavigateToDefault(); } - // Always update live state - _hubWindow.Settings = _settings; - _hubWindow.GatewayClient = _gatewayClient; - _hubWindow.CurrentStatus = _currentStatus; - if (_nodeService != null) - { - _hubWindow.NodeIsConnected = _nodeService.IsConnected; - _hubWindow.NodeIsPaired = _nodeService.IsPaired; - _hubWindow.NodeIsPendingApproval = _nodeService.IsPendingApproval; - _hubWindow.NodeShortDeviceId = _nodeService.ShortDeviceId; - _hubWindow.NodeFullDeviceId = _nodeService.FullDeviceId; - } - - // Seed cached data into hub (also on re-show) - SeedHubCachedData(); if (navigateTo != null) { - _hubWindow.NavigateTo(navigateTo); + _hubWindow.NavigateTo(navigateTo, originTag); + } + if (activate) + { + _hubWindow.Activate(); + } + else + { + // Show without stealing focus — used by right-click on the + // tray icon where the popup needs to remain the foreground + // window (popups light-dismiss if focus moves away). + // If the Hub was minimized, restore it first so it actually + // becomes visible behind the popup; otherwise Show(false) + // is a no-op on a minimized window. + try + { + if (_hubWindow.AppWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter op + && op.State == Microsoft.UI.Windowing.OverlappedPresenterState.Minimized) + { + op.Restore(activateWindow: false); + } + _hubWindow.AppWindow.Show(activateWindow: false); + } + catch { /* swallow */ } } - _hubWindow.Activate(); - } - - private void SeedHubCachedData() - { - if (_hubWindow == null) return; - // Seed all cached data types so pages see data immediately - if (_lastSessions.Length > 0) _hubWindow.UpdateSessions(_lastSessions); - if (_lastNodes.Length > 0) _hubWindow.UpdateNodes(_lastNodes); - if (_lastNodePairList != null) _hubWindow.UpdateNodePairList(_lastNodePairList); - if (_lastDevicePairList != null) _hubWindow.UpdateDevicePairList(_lastDevicePairList); - if (_lastModelsList != null) _hubWindow.UpdateModelsList(_lastModelsList); - if (_lastPresence != null) _hubWindow.UpdatePresence(_lastPresence); - if (_lastGatewaySelf != null) _hubWindow.UpdateGatewaySelf(_lastGatewaySelf); - if (_lastAgentsList.HasValue) _hubWindow.UpdateAgentsList(_lastAgentsList.Value); } private void ShowSettings() @@ -2589,50 +2757,56 @@ private void OnSettingsCommandCenterRequested(object? sender, EventArgs e) private void OnSettingsSaved(object? sender, EventArgs e) { - // Reconnect with new settings — mirror the startup if/else pattern - // to avoid dual connections that cause gateway conflicts. - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - _lastGatewaySelf = null; - var oldNodeService = _nodeService; - _nodeService = null; - try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); } - if (_settings?.UseSshTunnel != true) + var currentSnapshot = _settings?.ToSettingsData()?.ToConnectionSnapshot(); + var impact = SettingsChangeClassifier.Classify(_previousSettingsSnapshot, currentSnapshot); + _previousSettingsSnapshot = currentSnapshot; + Logger.Info($"[SETTINGS] Change impact: {impact}"); + + switch (impact) { - _sshTunnelService?.Stop(); - } + case SettingsChangeImpact.FullReconnectRequired: + case SettingsChangeImpact.OperatorReconnectRequired: + // Full reconnect: tear down everything and rebuild + _appState!.GatewaySelf = null; + if (_settings?.UseSshTunnel != true) + { + _sshTunnelService?.Stop(); + } + // Status is updated by OnManagerStateChanged when reconnect starts. + UpdateTrayIcon(); - // Reset status so the tray doesn't show a stale "Connected" from the previous mode - _currentStatus = ConnectionStatus.Disconnected; - _hubWindow?.UpdateStatus(_currentStatus); - UpdateTrayIcon(); + // Reset chat window — it has a stale URL/token + if (_chatWindow != null) + { + _chatWindow.ForceClose(); + _chatWindow = null; + } - // Reset chat window if gateway URL or token changed (pre-warmed window has stale URL) - if (_chatWindow != null) - { - _chatWindow.ForceClose(); - _chatWindow = null; - } - - // Always reconnect operator client for UI data - InitializeGatewayClient(); - - // Additionally reconnect node service if enabled - if (ShouldInitializeNodeService()) - { - InitializeNodeService(); - } + _ = _connectionManager?.ReconnectAsync(); + break; + + case SettingsChangeImpact.NodeReconnectRequired: + _ = _connectionManager?.ReconnectAsync(); + break; - // Refresh the Hub window if it's open - _hubWindow?.UpdateStatus(_currentStatus); + case SettingsChangeImpact.CapabilityReload: + _ = _connectionManager?.ReconnectAsync(); + break; - // Update global hotkey + case SettingsChangeImpact.UiOnly: + case SettingsChangeImpact.NoOp: + // No connection changes needed + break; + } + + // Non-connection settings always applied regardless of impact if (_settings!.GlobalHotkeyEnabled) { _globalHotkey ??= new GlobalHotkeyService(); _globalHotkey.HotkeyPressed -= OnGlobalHotkeyPressed; _globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed; + _globalHotkey.SettingsHotkeyPressed -= OnSettingsHotkeyPressed; + _globalHotkey.SettingsHotkeyPressed += OnSettingsHotkeyPressed; _globalHotkey.Register(); } else @@ -2640,71 +2814,49 @@ private void OnSettingsSaved(object? sender, EventArgs e) _globalHotkey?.Unregister(); } - // Update auto-start AutoStartManager.SetAutoStart(_settings.AutoStart); - // Keep hub window in sync with new client - if (_hubWindow != null && !_hubWindow.IsClosed) + // Notify ad-hoc listeners (e.g. ChatWindow may be alive but not + // owned by the hub) that settings have changed. Marshal onto the + // UI thread because IAppCommands.NotifySettingsSaved is a public + // entry point that may be invoked from background work; existing + // handlers (DebugPage, ChatWindow) update UI directly and would + // crash if dispatched from a non-UI thread (Hanselman v2 #7). + if (_dispatcherQueue != null && !_dispatcherQueue.HasThreadAccess) { - _hubWindow.Settings = _settings; - _hubWindow.GatewayClient = _gatewayClient; - _hubWindow.CurrentStatus = _currentStatus; + _dispatcherQueue.TryEnqueue(() => SettingsChanged?.Invoke(this, EventArgs.Empty)); } - } - - /// - /// Lightweight reconnect: tears down and rebuilds gateway + node connections - /// without destroying the chat window or re-registering hotkeys/autostart. - /// Use for "Reconnect" actions where only the connection needs recycling. - /// - private void ReconnectGateway() - { - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - _lastGatewaySelf = null; - var oldNodeService = _nodeService; - _nodeService = null; - try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); } - - _currentStatus = ConnectionStatus.Disconnected; - _hubWindow?.UpdateStatus(_currentStatus); - UpdateTrayIcon(); - - InitializeGatewayClient(); - if (ShouldInitializeNodeService()) - InitializeNodeService(); - - if (_hubWindow != null && !_hubWindow.IsClosed) + else { - _hubWindow.Settings = _settings; - _hubWindow.GatewayClient = _gatewayClient; - _hubWindow.CurrentStatus = _currentStatus; + SettingsChanged?.Invoke(this, EventArgs.Empty); } } - /// - /// Reconnects only the node service (preserves gateway client + chat window). - /// Use for capability toggle changes that don't require a full reconnect. - /// - private void ReconnectNodeServiceOnly() + private void ShowWebChat() { - var oldNodeService = _nodeService; - _nodeService = null; - try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); } + if (_settings == null) return; + if (!TryResolveChatCredentials(out _, out _, out _, out var isBootstrapToken)) + { + ShowConnectionSettingsForPairingIssue( + "Chat", + "Gateway URL or credential is not configured"); + return; + } - if (ShouldInitializeNodeService()) - InitializeNodeService(); - } + if (isBootstrapToken) + { + ShowConnectionSettingsForPairingIssue( + "Chat", + "Gateway pairing is not complete"); + return; + } - private void ShowWebChat() - { ShowHub("chat"); } private void ShowQuickSend(string? prefillMessage = null) { - if (_gatewayClient == null) + if (_connectionManager?.OperatorClient == null) { Logger.Warn("QuickSend blocked: gateway client not initialized"); return; @@ -2731,7 +2883,9 @@ private void ShowQuickSend(string? prefillMessage = null) } Logger.Info("Showing QuickSend dialog"); - var dialog = new QuickSendDialog(_gatewayClient, prefillMessage); + // Bug #3: pass a Func that resolves the live OperatorClient on + // every Send so post-pair / restart / reinit swaps are observed. + var dialog = new QuickSendDialog(() => _connectionManager?.OperatorClient as OpenClawGatewayClient, prefillMessage); dialog.Closed += (s, e) => { if (ReferenceEquals(_quickSendDialog, dialog)) @@ -2750,510 +2904,124 @@ private void ShowQuickSend(string? prefillMessage = null) private void ShowStatusDetail() { - ShowHub("general"); + ShowHub("connection"); } - private void RestartSshTunnel() + private void ShowConnectionStatusWindow() { - if (_settings?.UseSshTunnel != true) + if (_connectionStatusWindow != null && !_connectionStatusWindow.IsClosed) { - ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("Managed SSH tunnel mode is not enabled.")); + _connectionStatusWindow.Activate(); return; } - - try - { - Logger.Info("Restarting managed SSH tunnel from Command Center"); - DiagnosticsJsonlService.Write("tunnel.restart_requested", new - { - localEndpoint = _settings.SshTunnelLocalPort > 0 ? $"127.0.0.1:{_settings.SshTunnelLocalPort}" : null, - remotePort = _settings.SshTunnelRemotePort - }); - - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - _lastGatewaySelf = null; - - var oldNodeService = _nodeService; - _nodeService = null; - try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); } - - _sshTunnelService?.Stop(); - _currentStatus = ConnectionStatus.Disconnected; - UpdateTrayIcon(); - - if (!EnsureSshTunnelConfigured()) - { - UpdateStatusDetailWindow(); - ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel restart failed") - .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); - return; - } - - if (_settings.EnableNodeMode) - InitializeNodeService(); - else - { - InitializeGatewayClient(); - if (_settings.EnableMcpServer) - InitializeNodeService(); - } - - UpdateStatusDetailWindow(); - ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel") - .AddText("Restarted; reconnecting to gateway.")); - } - catch (Exception ex) - { - Logger.Error($"SSH tunnel restart request failed: {ex.Message}"); - DiagnosticsJsonlService.Write("tunnel.restart_request_failed", new { ex.Message }); - ShowToast(new ToastContentBuilder() - .AddText("SSH tunnel restart failed") - .AddText(ex.Message)); - } - } - - private async Task RefreshCommandCenterAsync() - { - await RunHealthCheckAsync(userInitiated: true); - if (_gatewayClient != null) - { - await _gatewayClient.RequestSessionsAsync(); - await _gatewayClient.RequestUsageAsync(); - await _gatewayClient.RequestNodesAsync(); - } - UpdateStatusDetailWindow(); - } - - private void UpdateStatusDetailWindow() - { - _hubWindow?.UpdateStatus(_currentStatus); - } - - private GatewayCommandCenterState BuildCommandCenterState() - { - var nodes = _lastNodes.Select(NodeCapabilityHealthInfo.FromNode).ToList(); - if (nodes.Count == 0 && _nodeService?.GetLocalNodeInfo() is { } localNode) - { - nodes.Add(NodeCapabilityHealthInfo.FromNode(localNode)); - } - - var topology = GatewayTopologyClassifier.Classify( - _settings?.GatewayUrl, - _settings?.UseSshTunnel == true, - _settings?.SshTunnelHost, - _settings?.SshTunnelLocalPort ?? 0, - _settings?.SshTunnelRemotePort ?? 0); - var tunnel = BuildTunnelInfo(); - var portDiagnostics = PortDiagnosticsService.BuildDiagnostics(topology, tunnel); - ApplyDetectedSshForwardTopology(topology, portDiagnostics); - var runtime = BuildGatewayRuntimeInfo(portDiagnostics); - var warnings = nodes.SelectMany(n => n.Warnings).ToList(); - warnings.AddRange(CommandCenterDiagnostics.BuildTopologyWarnings(topology, tunnel)); - warnings.AddRange(BuildPortDiagnosticWarnings(portDiagnostics, topology, tunnel)); - warnings.AddRange(BuildBrowserProxyAuthWarnings(nodes)); - - if (!string.IsNullOrWhiteSpace(_authFailureMessage)) - { - warnings.Insert(0, new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Critical, - Category = "auth", - Title = "Gateway authentication failed", - Detail = _authFailureMessage - }); - } - - if (_nodeService?.IsPendingApproval == true && !string.IsNullOrWhiteSpace(_nodeService.FullDeviceId)) - { - var approvalCommand = $"openclaw devices approve {_nodeService.FullDeviceId}"; - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Warning, - Category = "pairing", - Title = "Node is waiting for approval", - Detail = $"Approve device {_nodeService.ShortDeviceId} from the gateway CLI, then re-open the command center after reconnect.", - RepairAction = "Copy approval command", - CopyText = approvalCommand - }); - } - - if (_currentStatus == ConnectionStatus.Error) - { - warnings.Insert(0, new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Critical, - Category = "gateway", - Title = "Gateway connection error", - Detail = "The tray is not currently connected to the gateway." - }); - } - else if (_currentStatus != ConnectionStatus.Connected) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Warning, - Category = "gateway", - Title = "Gateway is not connected", - Detail = $"Current connection state is {_currentStatus}." - }); - } - - if (_currentStatus == ConnectionStatus.Connected && - DateTime.Now - _lastCheckTime > TimeSpan.FromMinutes(2)) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Warning, - Category = "gateway", - Title = "Gateway health is stale", - Detail = $"Last health check was {_lastCheckTime:t}. Run a health check or verify the localhost tunnel." - }); - } - - if (_lastChannels.Length == 0 && _currentStatus == ConnectionStatus.Connected && _gatewayClient != null) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "channel", - Title = "No channels reported", - Detail = "The gateway health payload did not report any channels." - }); - } - else if (_lastChannels.Length == 0 && _currentStatus == ConnectionStatus.Connected && _settings?.EnableNodeMode == true) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "gateway", - Title = "Waiting for gateway health", - Detail = "Node mode is connected. Channel/session inventories are filled from gateway health events when available." - }); - } - else if (_lastChannels.Length > 0 && _lastChannels.All(c => !ChannelHealth.IsHealthyStatus(c.Status))) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Warning, - Category = "channel", - Title = "No channels are currently running", - Detail = "Channels are configured but none are reporting a running/ready state." - }); - } - - if (_currentStatus == ConnectionStatus.Connected && nodes.Count == 0 && _gatewayClient != null) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "node", - Title = "No nodes reported", - Detail = "node.list did not report any connected nodes. Pair a Windows node or verify the operator token has node inventory access." - }); - } - - if (_lastUsageCost?.Totals.MissingCostEntries > 0) - { - warnings.Add(new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "usage", - Title = "Some usage costs are missing", - Detail = $"{_lastUsageCost.Totals.MissingCostEntries} usage entr{(_lastUsageCost.Totals.MissingCostEntries == 1 ? "y is" : "ies are")} missing cost data." - }); - } - - return new GatewayCommandCenterState - { - ConnectionStatus = _currentStatus, - LastRefresh = _lastCheckTime.ToUniversalTime(), - Topology = topology, - Runtime = runtime, - Update = _lastUpdateInfo, - Tunnel = tunnel, - GatewaySelf = _lastGatewaySelf, - PortDiagnostics = portDiagnostics, - Permissions = PermissionDiagnostics.BuildDefaultWindowsMatrix(), - Channels = _lastChannels.Select(ChannelCommandCenterInfo.FromHealth).ToList(), - Sessions = _lastSessions.ToList(), - Usage = _lastUsage, - UsageStatus = _lastUsageStatus, - UsageCost = _lastUsageCost, - Nodes = nodes, - Warnings = CommandCenterDiagnostics.SortAndDedupeWarnings(warnings), - RecentActivity = ActivityStreamService.GetItems(12) - .Select(item => new CommandCenterActivityInfo - { - Timestamp = item.Timestamp, - Category = item.Category, - Title = item.Title, - Details = item.Details, - DashboardPath = item.DashboardPath, - SessionKey = item.SessionKey, - NodeId = item.NodeId - }) - .ToList() - }; - } - - private IEnumerable BuildBrowserProxyAuthWarnings(IReadOnlyList nodes) - { - if (_settings?.NodeBrowserProxyEnabled == false || - !string.IsNullOrWhiteSpace(_settings?.Token) || - !nodes.Any(node => node.BrowserDeclaredCommands.Contains("browser.proxy", StringComparer.OrdinalIgnoreCase))) - { - yield break; - } - - yield return new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "browser", - Title = "Browser proxy auth may need a gateway token", - Detail = "This Windows node is advertising browser.proxy without a saved gateway shared token. QR/bootstrap pairing can connect the node, but an authenticated browser-control host may still require the same gateway token in Settings.", - RepairAction = "Copy browser proxy auth guidance", - CopyText = "If browser.proxy returns an auth error, enter the gateway shared token in Settings > Gateway Token, or configure the browser-control host to use auth compatible with the Windows node. Do not paste QR bootstrap tokens into the normal gateway token field." - }; - } - - private static IEnumerable BuildPortDiagnosticWarnings( - IReadOnlyList ports, - GatewayTopologyInfo topology, - TunnelCommandCenterInfo? tunnel) - { - foreach (var port in ports) - { - if (tunnel?.Status == TunnelStatus.Up && - port.Purpose.Equals("SSH tunnel local forward", StringComparison.OrdinalIgnoreCase) && - !port.IsListening) - { - yield return new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Warning, - Category = "port", - Title = "SSH tunnel port is not listening", - Detail = port.Detail - }; - } - - if (topology.DetectedKind == GatewayKind.WindowsNative && - port.Purpose.Equals("Gateway endpoint", StringComparison.OrdinalIgnoreCase) && - !port.IsListening) - { - yield return new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "port", - Title = "No local gateway listener detected", - Detail = port.Detail - }; - } - - if (port.Purpose.Equals("Browser proxy host", StringComparison.OrdinalIgnoreCase) && - !port.IsListening) - { - if (topology.UsesSshTunnel) - { - yield return new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "browser", - Title = "Browser proxy SSH forward is not listening", - Detail = $"browser.proxy over SSH needs a companion local forward for port {port.Port}. Add the browser-control forward to the same tunnel, or enable the managed SSH tunnel so Windows starts both forwards.", - RepairAction = "Copy browser proxy SSH forward", - CopyText = BuildBrowserProxySshForwardHint(port.Port, tunnel) - }; - continue; - } - - yield return new GatewayDiagnosticWarning - { - Severity = GatewayDiagnosticSeverity.Info, - Category = "browser", - Title = "Browser proxy host not detected", - Detail = "browser.proxy needs a compatible browser-control host listening on the gateway port + 2.", - RepairAction = "Copy browser setup guidance", - CopyText = CommandCenterTextHelper.BuildBrowserSetupGuidance(port.Port, topology, tunnel) - }; - } - } - } - - private static string BuildBrowserProxySshForwardHint(int browserProxyPort, TunnelCommandCenterInfo? tunnel) - { - if (browserProxyPort is < 1 or > 65535) - return "ssh -N -L :127.0.0.1: @"; - - var localBrowserPort = ResolveLocalBrowserProxyPort(browserProxyPort, tunnel); - var target = BuildSshTarget(tunnel); - var remoteBrowserPort = ResolveRemoteBrowserProxyPort(localBrowserPort, tunnel); - return remoteBrowserPort is >= 1 and <= 65535 - ? $"ssh -N -L {localBrowserPort}:127.0.0.1:{remoteBrowserPort} {target}" - : $"ssh -N -L {localBrowserPort}:127.0.0.1: {target}"; - } - - private static string BuildSshTarget(TunnelCommandCenterInfo? tunnel) - { - var host = tunnel?.Host?.Trim(); - var user = tunnel?.User?.Trim(); - if (!string.IsNullOrWhiteSpace(host) && !string.IsNullOrWhiteSpace(user)) - return $"{user}@{host}"; - if (!string.IsNullOrWhiteSpace(host)) - return $"@{host}"; - return "@"; - } - - private static int ResolveLocalBrowserProxyPort(int fallbackBrowserProxyPort, TunnelCommandCenterInfo? tunnel) - { - if (TryGetEndpointPort(tunnel?.BrowserProxyLocalEndpoint, out var browserLocalPort)) - return browserLocalPort; - - if (TryGetEndpointPort(tunnel?.LocalEndpoint, out var localGatewayPort) && - localGatewayPort <= 65533) - { - return localGatewayPort + 2; - } - - return fallbackBrowserProxyPort; - } - - private static int? ResolveRemoteBrowserProxyPort(int localBrowserProxyPort, TunnelCommandCenterInfo? tunnel) - { - if (TryGetEndpointPort(tunnel?.BrowserProxyRemoteEndpoint, out var browserRemotePort)) - return browserRemotePort; - - if (!TryGetEndpointPort(tunnel?.RemoteEndpoint, out var remoteGatewayPort) || - remoteGatewayPort > 65533) - { - return null; - } - - if (TryGetEndpointPort(tunnel?.LocalEndpoint, out var localGatewayPort) && - localBrowserProxyPort != localGatewayPort + 2) - { - return null; - } - - return remoteGatewayPort + 2; - } - - private static bool TryGetEndpointPort(string? endpoint, out int port) - { - port = 0; - if (string.IsNullOrWhiteSpace(endpoint)) - return false; - - var separator = endpoint.LastIndexOf(':'); - return separator >= 0 && - int.TryParse(endpoint[(separator + 1)..], out port) && - port is >= 1 and <= 65535; + _connectionStatusWindow = new ConnectionStatusWindow( + _connectionManager!.Diagnostics, + _gatewayRegistry, + _connectionManager); + _connectionStatusWindow.Activate(); } - private static void ApplyDetectedSshForwardTopology( - GatewayTopologyInfo topology, - IReadOnlyList ports) + private void RestartSshTunnel() { - if (topology.UsesSshTunnel || - topology.DetectedKind != GatewayKind.WindowsNative || - !topology.IsLoopback) + if (_settings?.UseSshTunnel != true) { + _toastService!.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Managed SSH tunnel mode is not enabled.")); return; } - var gatewayPort = ports.FirstOrDefault(port => - port.Purpose.Equals("Gateway endpoint", StringComparison.OrdinalIgnoreCase)); - if (gatewayPort is null || - !gatewayPort.IsListening || - !string.Equals(gatewayPort.OwningProcessName, "ssh", StringComparison.OrdinalIgnoreCase)) + try { - return; - } + Logger.Info("Restarting managed SSH tunnel from Command Center"); + DiagnosticsJsonlService.Write("tunnel.restart_requested", new + { + localEndpoint = _settings.SshTunnelLocalPort > 0 ? $"127.0.0.1:{_settings.SshTunnelLocalPort}" : null, + remotePort = _settings.SshTunnelRemotePort + }); - topology.DetectedKind = GatewayKind.MacOverSsh; - topology.DisplayName = "SSH tunnel (detected)"; - topology.Transport = "ssh tunnel"; - topology.UsesSshTunnel = true; - topology.Detail = $"Local gateway port {gatewayPort.Port} is owned by ssh, so Command Center treats it as a manually managed SSH local forward."; - } + _sshTunnelService?.Stop(); + // Status is updated by OnManagerStateChanged when reconnect completes. + UpdateTrayIcon(); - private static GatewayRuntimeInfo BuildGatewayRuntimeInfo(IReadOnlyList ports) - { - var gatewayPort = ports.FirstOrDefault(port => - port.Purpose.Equals("Gateway endpoint", StringComparison.OrdinalIgnoreCase)); - if (gatewayPort is null || !gatewayPort.IsListening) - return new GatewayRuntimeInfo(); + if (!EnsureSshTunnelConfigured()) + { + UpdateStatusDetailWindow(); + _toastService!.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel restart failed") + .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); + return; + } - return new GatewayRuntimeInfo + _ = _connectionManager?.ReconnectAsync(); + + UpdateStatusDetailWindow(); + _toastService!.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Restarted; reconnecting to gateway.")); + } + catch (Exception ex) { - ProcessName = gatewayPort.OwningProcessName ?? "", - ProcessId = gatewayPort.OwningProcessId, - Port = gatewayPort.Port, - IsSshForward = string.Equals(gatewayPort.OwningProcessName, "ssh", StringComparison.OrdinalIgnoreCase) - }; + Logger.Error($"SSH tunnel restart request failed: {ex.Message}"); + DiagnosticsJsonlService.Write("tunnel.restart_request_failed", new { ex.Message }); + _toastService!.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel restart failed") + .AddText(ex.Message)); + } } - private TunnelCommandCenterInfo? BuildTunnelInfo() + private async Task RefreshCommandCenterAsync() { - if (_settings?.UseSshTunnel != true) + await RunHealthCheckAsync(userInitiated: true); + var client = _connectionManager?.OperatorClient; + if (client != null) { - return null; + await client.RequestSessionsAsync(); + await client.RequestUsageAsync(); + await client.RequestNodesAsync(); } - - var localPort = _sshTunnelService is { CurrentLocalPort: > 0 } - ? _sshTunnelService.CurrentLocalPort - : _settings.SshTunnelLocalPort; - var remotePort = _sshTunnelService is { CurrentRemotePort: > 0 } - ? _sshTunnelService.CurrentRemotePort - : _settings.SshTunnelRemotePort; - var host = string.IsNullOrWhiteSpace(_sshTunnelService?.CurrentHost) - ? _settings.SshTunnelHost - : _sshTunnelService!.CurrentHost!; - var user = string.IsNullOrWhiteSpace(_sshTunnelService?.CurrentUser) - ? _settings.SshTunnelUser - : _sshTunnelService!.CurrentUser!; - var status = _sshTunnelService?.Status is TunnelStatus.Up or TunnelStatus.Starting or TunnelStatus.Restarting or TunnelStatus.Failed - ? _sshTunnelService.Status - : string.IsNullOrWhiteSpace(_sshTunnelService?.LastError) - ? TunnelStatus.Stopped - : TunnelStatus.Failed; - - return new TunnelCommandCenterInfo - { - Status = status, - LocalEndpoint = $"127.0.0.1:{localPort}", - RemoteEndpoint = string.IsNullOrWhiteSpace(host) - ? $"127.0.0.1:{remotePort}" - : $"{host}:127.0.0.1:{remotePort}", - BrowserProxyLocalEndpoint = _sshTunnelService?.CurrentBrowserProxyLocalPort > 0 - ? $"127.0.0.1:{_sshTunnelService.CurrentBrowserProxyLocalPort}" - : "", - BrowserProxyRemoteEndpoint = _sshTunnelService?.CurrentBrowserProxyRemotePort > 0 - ? string.IsNullOrWhiteSpace(host) - ? $"127.0.0.1:{_sshTunnelService.CurrentBrowserProxyRemotePort}" - : $"{host}:127.0.0.1:{_sshTunnelService.CurrentBrowserProxyRemotePort}" - : "", - Host = host, - User = user, - LastError = _sshTunnelService?.LastError, - StartedAt = _sshTunnelService?.StartedAtUtc - }; + UpdateStatusDetailWindow(); } + private void UpdateStatusDetailWindow() + { + // No-op — hub window observes AppState.PropertyChanged directly. + // Tray status detail window reads from AppState too. + } + + internal GatewayCommandCenterState BuildCommandCenterState() => + new CommandCenterStateBuilder(CaptureSnapshot()).Build(); + + private AppStateSnapshot CaptureSnapshot() => new AppStateSnapshot + { + Status = _appState!.Status, + LastCheckTime = _appState!.LastCheckTime, + Channels = _appState!.Channels, + Sessions = _appState!.Sessions, + Nodes = _appState!.Nodes, + Usage = _appState!.Usage, + UsageStatus = _appState!.UsageStatus, + UsageCost = _appState!.UsageCost, + GatewaySelf = _appState!.GatewaySelf, + AuthFailureMessage = _appState!.AuthFailureMessage, + LastUpdateInfo = _appState!.UpdateInfo, + Settings = _settings, + NodeService = _nodeService, + SshTunnelSnapshot = _sshTunnelService?.CreateSnapshot(), + HasGatewayClient = _connectionManager?.OperatorClient != null + }; + private void ShowNotificationHistory() { - ShowActivityStream("notification"); + // ActivityPage removed; legacy callers now land on the Channels page. + ShowHub("channels"); } private void ShowActivityStream(string? filter = null) { - ShowHub("activity"); - _hubWindow?.SetActivityFilter(filter); + // ActivityPage removed; legacy callers now land on the Channels page. + _ = filter; + ShowHub("channels"); } private OnboardingWindow? _onboardingWindow; @@ -3267,45 +3035,71 @@ private async Task ShowOnboardingAsync() try { _onboardingWindow.Activate(); return; } catch { _onboardingWindow = null; } } - _onboardingWindow = new OnboardingWindow(_settings); + // Disconnect existing gateway connection for a clean setup flow. + // ActiveId is preserved so it can be restored if setup is cancelled. + var restoreGatewayId = _gatewayRegistry?.ActiveGatewayId; + var disconnectedForOnboarding = false; + if (_connectionManager != null && + _connectionManager.CurrentSnapshot.OverallState is not OverallConnectionState.Idle) + { + Logger.Info("Disconnecting existing gateway connection for clean setup"); + await _connectionManager.DisconnectAsync(); + disconnectedForOnboarding = restoreGatewayId != null; + } + + var onboardingCompleted = false; + _onboardingWindow = new OnboardingWindow(_settings, IdentityDataPath); _onboardingWindow.OnboardingCompleted += (s, e) => { + onboardingCompleted = true; Logger.Info("Onboarding completed"); _onboardingWindow = null; // If the persistent client was already initialized during onboarding, keep it - if (_gatewayClient?.IsConnectedToGateway == true) + if (_connectionManager?.OperatorClient is OpenClawGatewayClient { IsConnectedToGateway: true }) { Logger.Info("Gateway client already connected from onboarding — keeping"); return; } - // Otherwise reinitialize with saved settings - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; - var oldNodeService = _nodeService; - _nodeService = null; - try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); } - - _currentStatus = ConnectionStatus.Disconnected; - _hubWindow?.UpdateStatus(_currentStatus); - UpdateTrayIcon(); + // If a reconnect is already in flight (e.g. the user clicked Finish while + // the gateway was mid-restart from a V2 GatewayWelcome wizard config save — + // the gateway emits a `shutdown` event with reason="gateway restarting" when + // provider/model config changes), let the existing auto-reconnect timer + // finish rather than canceling it and starting a fresh one. Canceling adds + // a visible ~5s churn (cancel + new connect attempt against a still-warming + // gateway + retry) on top of the gateway's own ~1.5s restart window. + if (_connectionManager?.CurrentSnapshot.OperatorState == RoleConnectionState.Connecting) + { + Logger.Info("Gateway client reconnect already in flight — keeping"); + return; + } - // Always reconnect operator client for UI data - InitializeGatewayClient(); - if (ShouldInitializeNodeService()) - InitializeNodeService(); + // Reconnect only if there's an active gateway with credentials — + // don't blindly reconnect a pre-setup gateway the user may be replacing. + var activeRecord = _gatewayRegistry?.GetActive(); + if (activeRecord != null && TryConnectGatewayIfCredentialAvailable(activeRecord, "post-onboarding")) + { + Logger.Info("Reconnecting to active gateway after onboarding"); + } + else + { + Logger.Info("No previously connected gateway after onboarding — skipping reconnect"); + TryStartLocalMcpOnlyNode(); + } - // Keep hub window in sync with new client - if (_hubWindow != null && !_hubWindow.IsClosed) + // Keep hub window in sync with new client — no shadow state to push, + // hub observes AppState directly. + }; + _onboardingWindow.Closed += (s, e) => + { + _onboardingWindow = null; + if (!onboardingCompleted && disconnectedForOnboarding && restoreGatewayId != null) { - _hubWindow.Settings = _settings; - _hubWindow.GatewayClient = _gatewayClient; - _hubWindow.CurrentStatus = _currentStatus; + Logger.Info("Onboarding closed before completion — restoring previous gateway connection"); + _ = _connectionManager?.ConnectAsync(restoreGatewayId); } }; - _onboardingWindow.Closed += (s, e) => _onboardingWindow = null; _onboardingWindow.Activate(); } @@ -3318,7 +3112,7 @@ private void ShowSurfaceImprovementsTipIfNeeded() try { - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_ActivityStreamTip")) .AddText(LocalizationHelper.GetString("Toast_ActivityStreamTipDetail")) .AddButton(new ToastButton() @@ -3333,18 +3127,38 @@ private void ShowSurfaceImprovementsTipIfNeeded() #endregion - private void ShowToast(ToastContentBuilder builder) + private bool TryResolveChatCredentials( + out string gatewayUrl, + out string token, + out string credentialSource, + out bool isBootstrapToken) { - var sound = _settings?.NotificationSound; - if (string.Equals(sound, "None", StringComparison.OrdinalIgnoreCase)) - { - builder.AddAudio(new ToastAudio { Silent = true }); - } - else if (string.Equals(sound, "Subtle", StringComparison.OrdinalIgnoreCase)) + gatewayUrl = string.Empty; + token = string.Empty; + credentialSource = "none"; + isBootstrapToken = false; + + if (_settings == null) + return false; + + if (!InteractiveGatewayCredentialResolver.TryResolve( + _gatewayRegistry, + SettingsManager.SettingsDirectoryPath, + DeviceIdentityFileReader.Instance, + _settings.GetEffectiveGatewayUrl(), + _settings.LegacyToken, + _settings.LegacyBootstrapToken, + out var credential) || + credential == null) { - builder.AddAudio(new Uri("ms-winsoundevent:Notification.IM"), silent: false); + return false; } - builder.Show(); + + gatewayUrl = credential.GatewayUrl; + token = credential.Token; + credentialSource = credential.Source; + isBootstrapToken = credential.IsBootstrapToken; + return true; } #region Actions @@ -3353,8 +3167,16 @@ private void OpenDashboard(string? path = null) { if (_settings == null) return; if (!EnsureSshTunnelConfigured()) return; - - var baseUrl = _settings.GetEffectiveGatewayUrl() + + if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return; + } + + var baseUrl = gatewayUrl .Replace("ws://", "http://") .Replace("wss://", "https://") .TrimEnd('/'); @@ -3363,10 +3185,12 @@ private void OpenDashboard(string? path = null) ? baseUrl : $"{baseUrl}/{path.TrimStart('/')}"; - if (!string.IsNullOrEmpty(_settings.Token)) + if (!isBootstrapToken && + credentialSource == CredentialResolver.SourceSharedGatewayToken && + !string.IsNullOrEmpty(token)) { var separator = url.Contains('?') ? "&" : "?"; - url = $"{url}{separator}token={Uri.EscapeDataString(_settings.Token)}"; + url = $"{url}{separator}token={Uri.EscapeDataString(token)}"; } try @@ -3379,11 +3203,31 @@ private void OpenDashboard(string? path = null) } } + // ── IAppCommands implementation ───────────────────────────────────── + + void IAppCommands.OpenDashboard(string? path) => OpenDashboard(path); + void IAppCommands.Navigate(string pageTag) => ShowHub(pageTag); + void IAppCommands.Navigate(string pageTag, string? originTag) => ShowHub(pageTag, originTag: originTag); + void IAppCommands.Reconnect() => _ = _connectionManager?.ReconnectAsync(); + void IAppCommands.Disconnect() + { + _ = _connectionManager?.DisconnectAsync(); + UpdateTrayIcon(); + } + void IAppCommands.ShowVoiceOverlay() => ShowHub("voice"); + void IAppCommands.ShowChat() => ShowChatWindow(); + void IAppCommands.ShowQuickSend() => ShowQuickSend(); + void IAppCommands.CheckForUpdates() => _ = CheckForUpdatesUserInitiatedAsync(); + void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync(); + void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); + void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); + private async void ToggleChannel(string channelName) { - if (_gatewayClient == null) return; + var client = _connectionManager?.OperatorClient; + if (client == null) return; - var channel = _lastChannels.FirstOrDefault(c => c.Name == channelName); + var channel = _appState!.Channels.FirstOrDefault(c => c.Name == channelName); if (channel == null) return; try @@ -3391,12 +3235,12 @@ private async void ToggleChannel(string channelName) var isRunning = ChannelHealth.IsHealthyStatus(channel.Status); if (isRunning) { - await _gatewayClient.StopChannelAsync(channelName); + await client.StopChannelAsync(channelName); AddRecentActivity($"Stopped channel: {channelName}", category: "channel", dashboardPath: "settings"); } else { - await _gatewayClient.StartChannelAsync(channelName); + await client.StartChannelAsync(channelName); AddRecentActivity($"Started channel: {channelName}", category: "channel", dashboardPath: "settings"); } @@ -3465,156 +3309,57 @@ private static void OpenFolder(string? folderPath, string label) } } - private void CopySupportContext() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildSupportContext(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied support context from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy support context from deep link: {ex.Message}"); - } - } - - private void CopyDebugBundle() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildDebugBundle(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied debug bundle from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy debug bundle from deep link: {ex.Message}"); - } - } - - private void CopyBrowserSetupGuidance() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildBrowserSetupGuidance(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied browser setup guidance from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy browser setup guidance from deep link: {ex.Message}"); - } - } - - private void CopyPortDiagnostics() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildPortDiagnosticsSummary(BuildCommandCenterState().PortDiagnostics)); - Clipboard.SetContent(package); - Logger.Info("Copied port diagnostics from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy port diagnostics from deep link: {ex.Message}"); - } - } - - private void CopyCapabilityDiagnostics() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildCapabilityDiagnosticsSummary(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied capability diagnostics from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy capability diagnostics from deep link: {ex.Message}"); - } - } - - private void CopyNodeInventory() + private void OnGlobalHotkeyPressed(object? sender, EventArgs e) { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildNodeInventorySummary(BuildCommandCenterState().Nodes)); - Clipboard.SetContent(package); - Logger.Info("Copied node inventory from deep link"); - } - catch (Exception ex) + if (_dispatcherQueue == null) { - Logger.Warn($"Failed to copy node inventory from deep link: {ex.Message}"); + Logger.Warn("Hotkey pressed but DispatcherQueue is null"); + return; } - } - private void CopyChannelSummary() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildChannelSummaryText(BuildCommandCenterState().Channels)); - Clipboard.SetContent(package); - Logger.Info("Copied channel summary from deep link"); - } - catch (Exception ex) + var enqueued = _dispatcherQueue.TryEnqueue(() => ShowQuickSend()); + if (!enqueued) { - Logger.Warn($"Failed to copy channel summary from deep link: {ex.Message}"); + Logger.Warn("Hotkey pressed but failed to enqueue QuickSend on UI thread"); } } - private void CopyActivitySummary() + private void OnVoiceHotkeyPressed(object? sender, EventArgs e) { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildActivitySummary(BuildCommandCenterState().RecentActivity)); - Clipboard.SetContent(package); - Logger.Info("Copied activity summary from deep link"); - } - catch (Exception ex) + if (_dispatcherQueue == null) return; + _dispatcherQueue.TryEnqueue(() => { - Logger.Warn($"Failed to copy activity summary from deep link: {ex.Message}"); - } - } + // Always set the flag first — ChatPage checks it during navigation + var hubExisted = _hubWindow != null; + ShowHub("chat"); + if (_hubWindow == null) return; - private void CopyExtensibilitySummary() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildExtensibilitySummary(BuildCommandCenterState().Channels)); - Clipboard.SetContent(package); - Logger.Info("Copied extensibility summary from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy extensibility summary from deep link: {ex.Message}"); - } + if (_hubWindow.CurrentPage is Pages.ChatPage chatPage) + { + // Chat page is already visible — trigger voice directly + chatPage.TriggerAutoStartVoice(); + } + else + { + // Chat page is being created — set the flag for ChatPage.Initialize to pick up. + // Also schedule a delayed trigger in case the flag isn't consumed during navigation. + _hubWindow.PendingAutoStartVoice = true; + _dispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + if (_hubWindow?.PendingAutoStartVoice == true && + _hubWindow.CurrentPage is Pages.ChatPage cp) + { + _hubWindow.PendingAutoStartVoice = false; + cp.TriggerAutoStartVoice(); + } + }); + } + }); } - private void OnGlobalHotkeyPressed(object? sender, EventArgs e) + private void OnSettingsHotkeyPressed(object? sender, EventArgs e) { - // Hotkey events are raised from a dedicated Win32 message-loop thread. - // Creating/activating WinUI windows must happen on the app's UI thread. - if (_dispatcherQueue == null) - { - Logger.Warn("Hotkey pressed but DispatcherQueue is null"); - return; - } - - var enqueued = _dispatcherQueue.TryEnqueue(() => ShowQuickSend()); - if (!enqueued) - { - Logger.Warn("Hotkey pressed but failed to enqueue QuickSend on UI thread"); - } + OnUiThread(() => ShowHub("companion")); } #endregion @@ -3633,7 +3378,7 @@ private async Task CheckForUpdatesAsync() { #if DEBUG Logger.Info("Skipping update check in debug build"); - _lastUpdateInfo = new UpdateCommandCenterInfo + _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Skipped", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", @@ -3643,7 +3388,7 @@ private async Task CheckForUpdatesAsync() return true; #else Logger.Info("Checking for updates..."); - _lastUpdateInfo = new UpdateCommandCenterInfo + _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Checking", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", @@ -3654,7 +3399,7 @@ private async Task CheckForUpdatesAsync() if (!updateFound) { Logger.Info("No updates available"); - _lastUpdateInfo = new UpdateCommandCenterInfo + _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Current", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", @@ -3667,7 +3412,7 @@ private async Task CheckForUpdatesAsync() var release = AppUpdater.LatestRelease!; var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; Logger.Info($"Update available: {release.TagName}"); - _lastUpdateInfo = new UpdateCommandCenterInfo + _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Available", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", @@ -3680,7 +3425,7 @@ private async Task CheckForUpdatesAsync() string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase)) { Logger.Info($"Skipping update prompt for remembered version {release.TagName}"); - _lastUpdateInfo.Detail = "skipped by user"; + _appState!.UpdateInfo.Detail = "skipped by user"; return true; } @@ -3689,7 +3434,7 @@ private async Task CheckForUpdatesAsync() if (result == UpdateDialogResult.Download) { - _lastUpdateInfo.Detail = "download requested"; + _appState!.UpdateInfo.Detail = "download requested"; if (_settings != null) { _settings.SkippedUpdateTag = string.Empty; @@ -3703,7 +3448,7 @@ private async Task CheckForUpdatesAsync() { _settings.SkippedUpdateTag = release.TagName ?? string.Empty; _settings.Save(); - _lastUpdateInfo.Detail = "skipped by user"; + _appState!.UpdateInfo.Detail = "skipped by user"; } return true; // RemindLater or Skip - continue @@ -3712,7 +3457,7 @@ private async Task CheckForUpdatesAsync() catch (Exception ex) { Logger.Warn($"Update check failed: {ex.Message}"); - _lastUpdateInfo = new UpdateCommandCenterInfo + _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Failed", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", @@ -3779,14 +3524,20 @@ private void StartDeepLinkServer() { try { - using var pipe = new NamedPipeServerStream(PipeName, PipeDirection.In); + using var pipe = new NamedPipeServerStream( + DeepLinkPipeName, + PipeDirection.In, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly, + inBufferSize: DeepLinkSecurityPolicy.MaxIpcMessageBytes, + outBufferSize: 0); await pipe.WaitForConnectionAsync(token); - using var reader = new System.IO.StreamReader(pipe); - var uri = await reader.ReadLineAsync(token); + var uri = await ReadDeepLinkIpcPayloadAsync(pipe, token); if (!string.IsNullOrEmpty(uri)) { - Logger.Info($"Received deep link via IPC: {uri}"); - _dispatcherQueue?.TryEnqueue(() => HandleDeepLink(uri)); + Logger.Info($"Received deep link via IPC: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); + OnUiThread(() => _ = HandleDeepLinkAsync(uri)); } } catch (OperationCanceledException) @@ -3794,6 +3545,20 @@ private void StartDeepLinkServer() Logger.Info("Deep link server stopping (canceled)"); break; // Normal shutdown } + catch (InvalidDataException ex) + { + if (!token.IsCancellationRequested) + { + Logger.Warn($"Rejected deep link IPC payload: {ex.Message}"); + } + } + catch (TimeoutException ex) + { + if (!token.IsCancellationRequested) + { + Logger.Warn($"Rejected deep link IPC payload: {ex.Message}"); + } + } catch (Exception ex) { if (!token.IsCancellationRequested) @@ -3806,6 +3571,77 @@ private void StartDeepLinkServer() }, token); } + private static async Task ReadDeepLinkIpcPayloadAsync(Stream stream, CancellationToken appToken) + { + using var readCts = CancellationTokenSource.CreateLinkedTokenSource(appToken); + readCts.CancelAfter(DeepLinkSecurityPolicy.IpcReadTimeout); + + var scratch = new byte[1024]; + var payload = new byte[DeepLinkSecurityPolicy.MaxIpcMessageBytes + 1]; + var totalBytes = 0; + + try + { + while (true) + { + var remaining = payload.Length - totalBytes; + if (remaining <= 0) + throw new InvalidDataException("payload exceeds maximum size"); + + var read = await stream.ReadAsync( + scratch.AsMemory(0, Math.Min(scratch.Length, remaining)), + readCts.Token); + if (read == 0) + break; + + scratch.AsSpan(0, read).CopyTo(payload.AsSpan(totalBytes)); + totalBytes += read; + if (totalBytes > DeepLinkSecurityPolicy.MaxIpcMessageBytes) + throw new InvalidDataException("payload exceeds maximum size"); + } + } + catch (OperationCanceledException) when (!appToken.IsCancellationRequested) + { + throw new TimeoutException("timed out while reading payload"); + } + + if (totalBytes == 0) + return null; + + try + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(payload, 0, totalBytes) + .TrimEnd('\r', '\n'); + } + catch (DecoderFallbackException ex) + { + throw new InvalidDataException("payload is not valid UTF-8", ex); + } + } + + private async Task HandleDeepLinkAsync(string uri) + { + var result = DeepLinkParser.ParseDeepLink(uri); + if (result == null) + { + Logger.Warn($"Rejected invalid deep link: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); + return; + } + + if (DeepLinkSecurityPolicy.RequiresConfirmation(result)) + { + var confirmed = await ConfirmDeepLinkActionAsync(result); + if (!confirmed) + { + Logger.Warn($"Rejected unconfirmed deep link action: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); + return; + } + } + + HandleDeepLink(uri); + } + private void HandleDeepLink(string uri) { DeepLinkHandler.Handle(uri, new DeepLinkActions @@ -3818,15 +3654,16 @@ private void HandleDeepLink(string uri) OpenLogFolder = OpenLogFolder, OpenConfigFolder = OpenConfigFolder, OpenDiagnosticsFolder = OpenDiagnosticsFolder, - CopySupportContext = CopySupportContext, - CopyDebugBundle = CopyDebugBundle, - CopyBrowserSetupGuidance = CopyBrowserSetupGuidance, - CopyPortDiagnostics = CopyPortDiagnostics, - CopyCapabilityDiagnostics = CopyCapabilityDiagnostics, - CopyNodeInventory = CopyNodeInventory, - CopyChannelSummary = CopyChannelSummary, - CopyActivitySummary = CopyActivitySummary, - CopyExtensibilitySummary = CopyExtensibilitySummary, + OpenConnectionStatus = ShowConnectionStatusWindow, + CopySupportContext = _diagnosticsClipboard!.CopySupportContext, + CopyDebugBundle = _diagnosticsClipboard!.CopyDebugBundle, + CopyBrowserSetupGuidance = _diagnosticsClipboard!.CopyBrowserSetupGuidance, + CopyPortDiagnostics = _diagnosticsClipboard!.CopyPortDiagnostics, + CopyCapabilityDiagnostics = _diagnosticsClipboard!.CopyCapabilityDiagnostics, + CopyNodeInventory = _diagnosticsClipboard!.CopyNodeInventory, + CopyChannelSummary = _diagnosticsClipboard!.CopyChannelSummary, + CopyActivitySummary = _diagnosticsClipboard!.CopyActivitySummary, + CopyExtensibilitySummary = _diagnosticsClipboard!.CopyExtensibilitySummary, RestartSshTunnel = RestartSshTunnel, OpenChat = ShowWebChat, OpenCommandCenter = ShowStatusDetail, @@ -3836,25 +3673,72 @@ private void HandleDeepLink(string uri) OpenDashboard = OpenDashboard, OpenQuickSend = ShowQuickSend, OpenHub = (page) => ShowHub(page), + OpenVoice = () => ShowHub("voice"), // was: ShowVoiceOverlay() + StopVoice = () => _ = StopVoiceAsync(), SendMessage = async (msg) => { - if (_gatewayClient != null) + var client = _connectionManager?.OperatorClient; + if (client != null) { - await _gatewayClient.SendChatMessageAsync(msg); + await client.SendChatMessageAsync(msg); } } }); } + private async Task StopVoiceAsync() + { + var voiceService = _nodeService?.VoiceService; + if (voiceService != null) + await voiceService.StopAsync(); + } + + public Task SpeakChatTextAsync(string text) => + _chatCoordinator?.SpeakChatTextAsync(text) ?? Task.CompletedTask; + + /// Raised when speaker mute state changes from any source (composer, settings, etc.). + public event Action? SpeakerMuteChanged; + + public void SetChatSpeakerMuted(bool muted) + { + if (_chatCoordinator is { } c) c.IsMuted = muted; + // Persist to settings + if (_settings != null) + { + _settings.VoiceTtsEnabled = !muted; + _settings.Save(); + } + // Broadcast to all subscribers + SpeakerMuteChanged?.Invoke(muted); + } + private static void SendDeepLinkToRunningInstance(string uri) { try { - using var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); + if (!DeepLinkSecurityPolicy.IsIpcPayloadWithinLimit(uri)) + { + Logger.Warn($"Rejected oversized deep link before IPC forwarding: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); + return; + } + + if (DeepLinkParser.ParseDeepLink(uri) == null) + { + Logger.Warn($"Rejected invalid deep link before IPC forwarding: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); + return; + } + + var payload = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetBytes(uri); + using var pipe = new NamedPipeClientStream( + ".", + DeepLinkPipeName, + PipeDirection.Out, + PipeOptions.CurrentUserOnly); pipe.Connect(1000); - using var writer = new System.IO.StreamWriter(pipe); - writer.WriteLine(uri); - writer.Flush(); + pipe.Write(payload, 0, payload.Length); + pipe.Flush(); + pipe.WaitForPipeDrain(); } catch (Exception ex) { @@ -3872,7 +3756,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) if (arguments.TryGetValue("action", out var action)) { - _dispatcherQueue?.TryEnqueue(() => + OnUiThread(() => { switch (action) { @@ -3890,11 +3774,12 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) ShowWebChat(); break; case "open_activity": - ShowActivityStream(); + // ActivityPage removed — redirect to Channels. + ShowHub("channels"); break; case "copy_pairing_command" when arguments.TryGetValue("command", out var command): CopyTextToClipboard(command); - ShowToast(new ToastContentBuilder() + _toastService!.ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_PairingCommandCopied")) .AddText(command)); break; @@ -3905,9 +3790,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) public static void CopyTextToClipboard(string text) { - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(text); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + ClipboardHelper.CopyText(text); } #endregion @@ -3942,9 +3825,13 @@ private void ExitApplication() // Dispose runtime services SafeShutdownStep("gateway client", () => { - UnsubscribeGatewayEvents(); - _gatewayClient?.Dispose(); - _gatewayClient = null; + _connectionManager?.Dispose(); + }); + + SafeShutdownStep("chat coordinator", () => + { + _chatCoordinator?.Dispose(); + _chatCoordinator = null; }); SafeShutdownStep("node service", () => @@ -3953,6 +3840,12 @@ private void ExitApplication() _nodeService = null; }); + SafeShutdownStep("standalone voice service", () => + { + _standaloneVoiceService?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _standaloneVoiceService = null; + }); + SafeShutdownStep("ssh tunnel service", () => { _sshTunnelService?.Dispose(); @@ -4034,8 +3927,7 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || _settings.SshTunnelLocalPort is < 1 or > 65535) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); - _currentStatus = ConnectionStatus.Error; - _hubWindow?.UpdateStatus(_currentStatus); + _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } @@ -4043,7 +3935,15 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || try { _sshTunnelService ??= new SshTunnelService(new AppLogger()); - _sshTunnelService.EnsureStarted(_settings); + var includeBrowserProxy = + _settings.NodeBrowserProxyEnabled && + SshTunnelCommandLine.CanForwardBrowserProxyPort(_settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort); + _sshTunnelService.EnsureStarted( + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + includeBrowserProxy); DiagnosticsJsonlService.Write("tunnel.ensure_started", new { status = _sshTunnelService.Status.ToString(), @@ -4055,8 +3955,7 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); - _currentStatus = ConnectionStatus.Error; - _hubWindow?.UpdateStatus(_currentStatus); + _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } @@ -4087,7 +3986,15 @@ private async void OnSshTunnelExited(object? sender, int exitCode) { try { - _sshTunnelService.EnsureStarted(_settings); + var restartBrowserProxy = + _settings.NodeBrowserProxyEnabled && + SshTunnelCommandLine.CanForwardBrowserProxyPort(_settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort); + _sshTunnelService.EnsureStarted( + _settings.SshTunnelUser, + _settings.SshTunnelHost, + _settings.SshTunnelRemotePort, + _settings.SshTunnelLocalPort, + restartBrowserProxy); Logger.Info("SSH tunnel restarted successfully"); DiagnosticsJsonlService.Write("tunnel.restart_succeeded", new { @@ -4103,16 +4010,5 @@ private async void OnSshTunnelExited(object? sender, int exitCode) } } } - - private Microsoft.UI.Dispatching.DispatcherQueue? AppDispatcherQueue => - Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); } -internal class AppLogger : IOpenClawLogger -{ - public void Info(string message) => Logger.Info(message); - public void Debug(string message) => Logger.Debug(message); - public void Warn(string message) => Logger.Warn(message); - public void Error(string message, Exception? ex = null) => - Logger.Error(ex != null ? $"{message}: {ex.Message}" : message); -} diff --git a/src/OpenClaw.Tray.WinUI/AppLogger.cs b/src/OpenClaw.Tray.WinUI/AppLogger.cs new file mode 100644 index 000000000..f1a668282 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/AppLogger.cs @@ -0,0 +1,13 @@ +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray; + +internal sealed class AppLogger : IOpenClawLogger +{ + public void Info(string message) => Logger.Info(message); + public void Debug(string message) => Logger.Debug(message); + public void Warn(string message) => Logger.Warn(message); + public void Error(string message, Exception? ex = null) => + Logger.Error(ex != null ? $"{message}: {ex.Message}" : message); +} diff --git a/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png b/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png index 944738bc1..07bfcfabf 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png and b/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/CheckmarkBadge12.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/CheckmarkBadge12.png new file mode 100644 index 000000000..f02e3e3f2 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/CheckmarkBadge12.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/ErrorBadge12.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/ErrorBadge12.png new file mode 100644 index 000000000..0d744ea5c Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Badge/ErrorBadge12.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeClose.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeClose.png new file mode 100644 index 000000000..4882f7b74 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeClose.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeMinimize.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeMinimize.png new file mode 100644 index 000000000..11eebb5b2 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/ChromeMinimize.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/TitleBarIcon.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/TitleBarIcon.png new file mode 100644 index 000000000..fbbf288f4 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Chrome/TitleBarIcon.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/InfoBar/ImportantBadge12.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/InfoBar/ImportantBadge12.png new file mode 100644 index 000000000..794fca6b8 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/InfoBar/ImportantBadge12.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png new file mode 100644 index 000000000..2b9a2dafb Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PartyPopper.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PartyPopper.png new file mode 100644 index 000000000..e3eb2a22a Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PartyPopper.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PermCamera.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermCamera.png new file mode 100644 index 000000000..b827cfc22 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermCamera.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PermLocation.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermLocation.png new file mode 100644 index 000000000..64674f313 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermLocation.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PermMicrophone.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermMicrophone.png new file mode 100644 index 000000000..baa87b928 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermMicrophone.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PermNotifications.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermNotifications.png new file mode 100644 index 000000000..2fd89ccaa Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermNotifications.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/PermScreenCapture.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermScreenCapture.png new file mode 100644 index 000000000..47438078f Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/PermScreenCapture.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Activity.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Activity.svg new file mode 100644 index 000000000..dba1be2c4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Activity.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Advanced.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Advanced.svg new file mode 100644 index 000000000..f71406da0 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Advanced.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/AgentEvents.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/AgentEvents.svg new file mode 100644 index 000000000..fd2558fe4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/AgentEvents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Agents.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Agents.svg new file mode 100644 index 000000000..a45e35340 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Agents.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Bindings.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Bindings.svg new file mode 100644 index 000000000..c4fc30d18 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Bindings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Channels.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Channels.svg new file mode 100644 index 000000000..2f1debe04 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Channels.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Chat.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Chat.svg new file mode 100644 index 000000000..12fa2e094 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Config.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Config.svg new file mode 100644 index 000000000..887ee3da8 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Config.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Connection.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Connection.svg new file mode 100644 index 000000000..63b5407ea --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Connection.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Cron.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Cron.svg new file mode 100644 index 000000000..5ef5f3e70 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Cron.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Debug.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Debug.svg new file mode 100644 index 000000000..702a2002b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Debug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Info.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Info.svg new file mode 100644 index 000000000..a50616527 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Instances.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Instances.svg new file mode 100644 index 000000000..feabb8721 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Instances.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Permissions.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Permissions.svg new file mode 100644 index 000000000..03d26d3c6 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Permissions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sandbox.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sandbox.svg new file mode 100644 index 000000000..8a018bc71 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sandbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sessions.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sessions.svg new file mode 100644 index 000000000..a954db4d4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Sessions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Settings.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Settings.svg new file mode 100644 index 000000000..67ed255ac --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Skills.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Skills.svg new file mode 100644 index 000000000..a5348f05e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Skills.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Usage.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Usage.svg new file mode 100644 index 000000000..9c3add33f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Usage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Voice.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Voice.svg new file mode 100644 index 000000000..af5e3539e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/Voice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png b/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png index 7102fde77..a2e043217 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png and b/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png index 6b3fc0b40..e467c2dbc 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png index 944738bc1..07bfcfabf 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png index 4642ada58..319469b78 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png index ee14cc7c5..f0e4ae8a1 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png index cb2b4d979..c492bc25d 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png b/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png index 517caddce..13556a3b8 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png and b/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico b/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico index 1b7300ec5..30dc915b2 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico and b/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico differ diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs new file mode 100644 index 000000000..f3c3d7865 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatEntryMetadata.cs @@ -0,0 +1,45 @@ +namespace OpenClawTray.Chat; + +/// +/// Per-entry metadata maintained by +/// in parallel to the vendored . +/// Tracks values that the upstream ChatTimelineItem record doesn't +/// carry — specifically the wall-clock timestamp of when the entry was +/// created, the model active at that moment, and gateway-reported usage +/// counters — so the timeline renderer can show a richer footer like +/// Field · 7:54 PM · ↑1475 ↓12 R45.4k 23% ctx · gpt-5.5. +/// +/// +/// Local-time timestamp of when the entry was created. null when the +/// source event didn't carry a timestamp (e.g. live UI-only status entries). +/// +/// +/// Snapshot of the model name active when the entry was created (typically +/// taken from ). null +/// when the model is unknown. +/// +/// +/// Cumulative input (prompt) tokens reported by the gateway for this turn, +/// shown in the footer with an up arrow (). null when not +/// reported (most live ``chat`` deltas don't carry usage info — only the +/// final summary does). +/// +/// +/// Cumulative output tokens reported by the gateway for this turn, shown in +/// the footer with a down arrow (). +/// +/// +/// Total tokens spent on the response (prompt + completion) — surfaces as +/// R<n> in the footer (e.g. R45.4k). +/// +/// +/// Percentage of the model's context window consumed by the conversation +/// when this entry was generated (0–100). Shown as 23% ctx. +/// +public sealed record ChatEntryMetadata( + DateTimeOffset? Timestamp, + string? Model, + int? InputTokens = null, + int? OutputTokens = null, + int? ResponseTokens = null, + int? ContextPercent = null); diff --git a/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs b/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs new file mode 100644 index 000000000..a33bdb1f4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/ChatMarkdownSanitizer.cs @@ -0,0 +1,542 @@ +using System.Text; + +namespace OpenClawTray.Chat; + +/// +/// Conservative pre-rendering sanitizer for chat-bubble Markdown. +/// Removes constructs that would otherwise trigger an outbound URL +/// fetch or a click-to-navigate hyperlink: +/// +/// Inline images ![alt](src) render as [Image: alt] +/// text (no Uri instantiation, no BitmapImage fetch). +/// Inline links [text](url) render as plain +/// text (url) with the URL visible but inert. +/// Reference link definitions [ref]: url render as +/// plain ref: url so any usage [text][ref] +/// degrades to literal text. +/// +/// Code spans (`code`) and fenced code blocks (```...``` +/// or ~~~...~~~) are preserved verbatim. Idempotent and safe on +/// null / empty input. +/// +/// +/// SECURITY (chat-rubber-duck HIGH 1 / MEDIUM 3): the gateway, a +/// compromised tool, or a prompt-injected model can place arbitrary +/// Markdown in assistant text. Default rendering would (a) auto-fetch +/// http(s):// images into BitmapImage / SvgImageSource +/// (SSRF, tracking pixels, beacon attacks) and (b) attach +/// click-to-navigate hyperlinks that look like trusted assistant prose. +/// Rendering URL-bearing constructs as inert text breaks both vectors at +/// the source before any renderer can activate links or remote media. +/// +internal static class ChatMarkdownSanitizer +{ + public readonly record struct TextSegment(string Text, bool IsStrong); + + /// + /// Flatten a parsed Markdown link's display text + destination URI + /// into a single inert plain-text string. Used by the + /// OpenClawChatTimeline rendering path so + /// that links the parser DOES emit (bare URLs, autolinks + /// <https://…>) collapse to non-clickable text instead + /// of -style runs. + /// Pure function — kept here so it can be unit-tested without a + /// dependency on the WinUI project. + /// + /// + /// Output rules: + /// + /// Empty display + URI → URI text (autolink case). + /// Display equals URI → display only (avoid duplicate + /// "https://x (https://x)" rendering). + /// Otherwise → "{display} ({uri})" so the navigation + /// target stays visible and inspectable. + /// + /// + public static string FlattenLinkToInertText(string? displayText, string? uriText) + { + var d = displayText ?? string.Empty; + var u = uriText ?? string.Empty; + if (string.IsNullOrEmpty(d)) return u; + if (string.Equals(d, u, StringComparison.Ordinal)) return d; + return string.IsNullOrEmpty(u) ? d : $"{d} ({u})"; + } + + /// + /// Convert a raw Markdown HTML block into inert display text. The caller + /// must render the returned string with a plain text control, not an HTML + /// or WebView renderer. + /// + public static string FlattenRawHtmlBlockToInertText(string? rawHtml) => rawHtml ?? string.Empty; + + /// + /// Sanitize a chat-bubble markdown string. + /// + public static string Sanitize(string? text) + { + if (string.IsNullOrEmpty(text)) return text ?? string.Empty; + if (text.IndexOf('[') < 0 && text.IndexOf('!') < 0) return text; + + var sb = new StringBuilder(text.Length); + int i = 0; + int n = text.Length; + bool atLineStart = true; + while (i < n) + { + char c = text[i]; + + // Indented code block: line begins with a tab or 4+ spaces. + // CommonMark treats such lines as code content; pass them + // through verbatim so link-like syntax inside them is never + // mutated by the link / image / ref-def branches below. + if (atLineStart && IsIndentedCodeStart(text, i, n)) + { + int lineEnd = text.IndexOf('\n', i); + int segEnd = lineEnd < 0 ? n : lineEnd + 1; + sb.Append(text, i, segEnd - i); + i = segEnd; + atLineStart = true; + continue; + } + + // Fenced code block: ``` or ~~~ at line start, optionally + // preceded by up to 3 spaces (per CommonMark §4.5). Pass + // through to matching closing fence (or EOF). The closing + // fence must use the same character and may also have up to + // 3 leading spaces; we accept any leading whitespace on + // close to stay conservative. + if (atLineStart && TryReadFenceOpen(text, i, n, out var fenceChar, out int fenceLen, out int afterFenceLine)) + { + sb.Append(text, i, afterFenceLine - i); + i = afterFenceLine; + while (i < n) + { + int nextLine = text.IndexOf('\n', i); + int segEnd = nextLine < 0 ? n : nextLine + 1; + sb.Append(text, i, segEnd - i); + var line = text.AsSpan(i, segEnd - i); + i = segEnd; + var trimmed = line.TrimStart(); + if (trimmed.Length >= fenceLen) + { + bool allFence = true; + for (int k = 0; k < fenceLen; k++) + { + if (trimmed[k] != fenceChar) { allFence = false; break; } + } + if (allFence) break; + } + } + atLineStart = true; + continue; + } + + // Inline code: backtick run; pass through up to matching run of + // the same length (or EOF). + if (c == '`') + { + int runLen = 0; + while (i + runLen < n && text[i + runLen] == '`') runLen++; + int searchFrom = i + runLen; + int closeAt = -1; + while (searchFrom < n) + { + int found = text.IndexOf('`', searchFrom); + if (found < 0) break; + int foundLen = 0; + while (found + foundLen < n && text[found + foundLen] == '`') foundLen++; + if (foundLen == runLen) { closeAt = found; break; } + searchFrom = found + foundLen; + } + if (closeAt < 0) + { + sb.Append(text, i, n - i); + i = n; + atLineStart = false; + continue; + } + int end = closeAt + runLen; + sb.Append(text, i, end - i); + i = end; + atLineStart = false; + continue; + } + + // Reference link definition at line start: ``[ref]: url ...``. + // CommonMark also allows up to 3 leading spaces before the + // definition; 4+ spaces are handled above as indented code. + if (atLineStart && TryParseReferenceDefinition(text, i, n, out var refLabel, out var refUrl, out var refEnd)) + { + sb.Append(refLabel); + sb.Append(": "); + sb.Append(refUrl); + i = refEnd; + continue; + } + + // Inline image: ![alt](src) → [Image: alt] + if (c == '!' && i + 1 < n && text[i + 1] == '[') + { + if (TryParseLinkOrImage(text, i + 1, out _, out var alt, out _, out var afterParen, out var exceededScanLimit)) + { + sb.Append("[Image"); + if (!string.IsNullOrEmpty(alt)) + { + // Recursively sanitize alt text — the gateway can + // place link / image syntax inside an alt and + // we must not re-emit it verbatim. + sb.Append(": "); + sb.Append(Sanitize(alt)); + } + sb.Append(']'); + i = afterParen; + atLineStart = false; + continue; + } + + if (exceededScanLimit) + { + sb.Append(@"\!\["); + i += 2; + atLineStart = false; + continue; + } + } + + // Inline link: [text](url) → text (url) + if (c == '[') + { + if (TryParseLinkOrImage(text, i, out _, out var linkText, out var url, out var afterParen, out var exceededScanLimit)) + { + // Recursively sanitize the link text so a nested + // image / link inside the brackets (e.g. + // ``[![alt](http://img)](http://other)``) is also + // flattened. Without this the inner ``![alt](src)`` + // syntax would be preserved verbatim and re-parsed + // by a markdown renderer as a real image fetch. + sb.Append(Sanitize(linkText)); + if (!string.IsNullOrEmpty(url)) + { + sb.Append(" ("); + sb.Append(url); + sb.Append(')'); + } + i = afterParen; + atLineStart = false; + continue; + } + + if (exceededScanLimit) + { + sb.Append(@"\["); + i++; + atLineStart = false; + continue; + } + } + + sb.Append(c); + atLineStart = c == '\n'; + i++; + } + + return sb.ToString(); + } + + public static IReadOnlyList SanitizeAndSplitStrongEmphasis(string? text) => + SplitStrongEmphasis(Sanitize(text)); + + private static IReadOnlyList SplitStrongEmphasis(string text) + { + if (string.IsNullOrEmpty(text)) + return Array.Empty(); + + var segments = new List(); + var normal = new StringBuilder(text.Length); + int i = 0; + int n = text.Length; + bool atLineStart = true; + + void FlushNormal() + { + if (normal.Length == 0) + return; + + segments.Add(new TextSegment(normal.ToString(), IsStrong: false)); + normal.Clear(); + } + + while (i < n) + { + if (atLineStart && TryReadFenceOpen(text, i, n, out var fenceChar, out int fenceLen, out int afterFenceLine)) + { + AppendFencedBlock(text, normal, ref i, n, fenceChar, fenceLen, afterFenceLine); + atLineStart = true; + continue; + } + + if (text[i] == '`') + { + AppendInlineCode(text, normal, ref i, n); + atLineStart = false; + continue; + } + + if (i + 1 < n && text[i] == '*' && text[i + 1] == '*' && !IsEscaped(text, i)) + { + var close = FindStrongClose(text, i + 2); + if (close > i + 2) + { + FlushNormal(); + segments.Add(new TextSegment(text.Substring(i + 2, close - i - 2), IsStrong: true)); + i = close + 2; + atLineStart = false; + continue; + } + } + + normal.Append(text[i]); + atLineStart = text[i] == '\n'; + i++; + } + + FlushNormal(); + return segments; + } + + private static void AppendFencedBlock(string text, StringBuilder output, ref int i, int n, char fenceChar, int fenceLen, int afterFenceLine) + { + output.Append(text, i, afterFenceLine - i); + i = afterFenceLine; + + while (i < n) + { + int nextLine = text.IndexOf('\n', i); + int segEnd = nextLine < 0 ? n : nextLine + 1; + output.Append(text, i, segEnd - i); + var line = text.AsSpan(i, segEnd - i); + i = segEnd; + + var trimmed = line.TrimStart(); + if (trimmed.Length < fenceLen) + continue; + + if (trimmed[0] != fenceChar) + continue; + + bool allFence = true; + for (int k = 0; k < fenceLen; k++) + { + if (trimmed[k] != fenceChar) { allFence = false; break; } + } + + if (allFence) + break; + } + } + + private static void AppendInlineCode(string text, StringBuilder output, ref int i, int n) + { + int runLen = 0; + while (i + runLen < n && text[i + runLen] == '`') runLen++; + + int searchFrom = i + runLen; + while (searchFrom < n) + { + int found = text.IndexOf('`', searchFrom); + if (found < 0) + break; + + int foundLen = 0; + while (found + foundLen < n && text[found + foundLen] == '`') foundLen++; + if (foundLen == runLen) + { + var end = found + foundLen; + output.Append(text, i, end - i); + i = end; + return; + } + + searchFrom = found + foundLen; + } + + output.Append(text, i, n - i); + i = n; + } + + private static int FindStrongClose(string text, int start) + { + var i = start; + while (i + 1 < text.Length) + { + if (text[i] == '`') + { + var previous = i; + AppendInlineCode(text, new StringBuilder(), ref i, text.Length); + if (i == previous) + i++; + continue; + } + + if (text[i] == '*' && text[i + 1] == '*' && !IsEscaped(text, i)) + return i; + + i++; + } + + return -1; + } + + private static bool IsEscaped(string text, int index) + { + var slashCount = 0; + for (var i = index - 1; i >= 0 && text[i] == '\\'; i--) + slashCount++; + + return slashCount % 2 == 1; + } + + private static bool TryParseReferenceDefinition( + string text, int lineStart, int n, + out string label, out string url, out int definitionEnd) + { + label = string.Empty; + url = string.Empty; + definitionEnd = lineStart; + + int p = lineStart; + int leadingSpaces = 0; + while (p < n && text[p] == ' ' && leadingSpaces < 3) + { + p++; + leadingSpaces++; + } + + if (p >= n || text[p] != '[') return false; + + int lineEnd = text.IndexOf('\n', p); + int segEnd = lineEnd < 0 ? n : lineEnd; + int close = text.IndexOf(']', p + 1); + if (close <= p || close >= segEnd || close + 1 >= n || text[close + 1] != ':') + return false; + + label = text.Substring(p + 1, close - p - 1); + url = text.Substring(close + 2, segEnd - (close + 2)).TrimStart(); + definitionEnd = segEnd; + return true; + } + + // Detect a CommonMark indented code block: a line that begins with + // a tab OR 4+ spaces. Blank lines (within an existing code block + // context) are not handled here — but the conservative + // single-line-at-a-time copy is fine because a non-indented line + // simply re-enters the normal scan path. + private static bool IsIndentedCodeStart(string text, int i, int n) + { + if (i >= n) return false; + if (text[i] == '\t') return true; + // 4 spaces. + if (i + 3 < n && + text[i] == ' ' && text[i + 1] == ' ' && text[i + 2] == ' ' && text[i + 3] == ' ') + { + return true; + } + return false; + } + + // Detect a CommonMark fenced code block opening at position ``i``. + // Allows up to 3 leading spaces of indentation (per spec §4.5) and + // a fence run of 3+ backticks or 3+ tildes. Returns the fence char, + // the run length, and the byte offset of the start of the next line + // (or end-of-input if no newline). + private static bool TryReadFenceOpen( + string text, int i, int n, + out char fenceChar, out int fenceLen, out int afterFenceLine) + { + fenceChar = '\0'; + fenceLen = 0; + afterFenceLine = i; + + int p = i; + int leadingSpaces = 0; + while (p < n && text[p] == ' ' && leadingSpaces < 3) + { + p++; + leadingSpaces++; + } + if (p >= n) return false; + char ch = text[p]; + if (ch != '`' && ch != '~') return false; + int runStart = p; + while (p < n && text[p] == ch) p++; + int run = p - runStart; + if (run < 3) return false; + + fenceChar = ch; + fenceLen = run; + int lineEnd = text.IndexOf('\n', p); + afterFenceLine = lineEnd < 0 ? n : lineEnd + 1; + return true; + } + + // Parse ``[text](url)`` starting at the ``[`` index. Returns false + // if the construct doesn't fully match within reasonable bounds. + private static bool TryParseLinkOrImage( + string text, int bracketStart, + out int closeBracket, out string innerText, out string url, out int afterParen, + out bool exceededScanLimit) + { + closeBracket = -1; + innerText = string.Empty; + url = string.Empty; + afterParen = -1; + exceededScanLimit = false; + + int n = text.Length; + if (bracketStart >= n || text[bracketStart] != '[') return false; + + int depth = 1; + int j = bracketStart + 1; + int scanLimit = Math.Min(n, bracketStart + 1024); + while (j < scanLimit) + { + char ch = text[j]; + if (ch == '\\' && j + 1 < scanLimit) { j += 2; continue; } + if (ch == '\n' && j > bracketStart + 1 && text[j - 1] == '\n') return false; + if (ch == '[') depth++; + else if (ch == ']') { depth--; if (depth == 0) { closeBracket = j; break; } } + j++; + } + if (closeBracket < 0) + { + exceededScanLimit = scanLimit < n; + return false; + } + if (closeBracket + 1 >= n || text[closeBracket + 1] != '(') return false; + + int parenStart = closeBracket + 2; + int parenEnd = -1; + int parenScanLimit = Math.Min(n, parenStart + 2048); + for (int k = parenStart; k < parenScanLimit; k++) + { + char ch = text[k]; + if (ch == ')') { parenEnd = k; break; } + if (ch == '\n') return false; + } + if (parenEnd < 0) + { + exceededScanLimit = parenScanLimit < n; + return false; + } + + innerText = text.Substring(bracketStart + 1, closeBracket - bracketStart - 1); + var rawUrl = text.Substring(parenStart, parenEnd - parenStart).Trim(); + // Strip optional title: ``url "title"`` or ``url 'title'``. + int sp = rawUrl.IndexOf(' '); + url = sp > 0 ? rawUrl[..sp] : rawUrl; + // Drop angle brackets if present: ````. + if (url.Length >= 2 && url[0] == '<' && url[^1] == '>') url = url[1..^1]; + afterParen = parenEnd + 1; + return true; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/DebugChatSurfaceOverrides.cs b/src/OpenClaw.Tray.WinUI/Chat/DebugChatSurfaceOverrides.cs new file mode 100644 index 000000000..29d8f9892 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/DebugChatSurfaceOverrides.cs @@ -0,0 +1,63 @@ +using System; + +namespace OpenClawTray.Chat; + +/// +/// Per-chat-surface override of the user's Settings.UseLegacyWebChat toggle. +/// Picked from the Debug page so engineers can compare the legacy WebView and +/// the native chat side-by-side without flipping the global setting. +/// +public enum ChatSurfaceOverride +{ + /// Use the value of Settings.UseLegacyWebChat. + NoOverride, + /// Force the legacy WebView (gateway HTML chat). + ForceLegacy, + /// Force the native chat (Companion Chat UI). + ForceNative, +} + +/// +/// Process-wide debug overrides for which chat surface (legacy WebView or +/// native chat) renders inside each chat container. Not persisted — these +/// reset every app launch and are intended only for engineering A/B tests. +/// +/// Subscribers (, +/// ) listen on +/// and call their ApplyChatSurface hook to swap +/// in/out the active surface immediately. +/// +public static class DebugChatSurfaceOverrides +{ + private static ChatSurfaceOverride _hubChat = ChatSurfaceOverride.NoOverride; + private static ChatSurfaceOverride _trayChat = ChatSurfaceOverride.NoOverride; + + /// Override for the Hub Chat tab (in-window NavigationView page). + public static ChatSurfaceOverride HubChat + { + get => _hubChat; + set { if (_hubChat != value) { _hubChat = value; Changed?.Invoke(null, EventArgs.Empty); } } + } + + /// Override for the floating Tray Chat popup (near-tray window). + public static ChatSurfaceOverride TrayChat + { + get => _trayChat; + set { if (_trayChat != value) { _trayChat = value; Changed?.Invoke(null, EventArgs.Empty); } } + } + + /// Fires when either override is changed by the Debug page. + public static event EventHandler? Changed; + + /// + /// Resolve the effective "use legacy WebView" flag for a chat surface: + /// the override wins when set to a forced value; otherwise the user's + /// Settings.UseLegacyWebChat applies. + /// + public static bool ResolveUseLegacy(ChatSurfaceOverride ovr, bool settingValue) => ovr switch + { + ChatSurfaceOverride.ForceLegacy => true, + ChatSurfaceOverride.ForceNative => false, + _ => settingValue, + }; +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs new file mode 100644 index 000000000..16807a9d9 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs @@ -0,0 +1,267 @@ +using Microsoft.UI.Xaml.Media; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Windows.UI; + +namespace OpenClawTray.Chat.Explorations; + +/// +/// 사용자가 패널에서 만든 ChatExplorationState 스냅샷을 이름 붙여 저장/불러오기. +/// %APPDATA%\OpenClawTray\chat-exploration-presets.json 에 영속화. +/// 빌트인 (Calm/Compact/Plain) 은 코드 (ChatVariationPresets), 커스텀은 JSON. +/// +public sealed record ChatExplorationPreset +{ + public string Name { get; init; } = ""; + + /// + /// When true, this preset is auto-applied on app startup. Only one preset + /// can be the default at a time; the panel enforces this by clearing the + /// flag on all other presets when one is marked. + /// + public bool IsDefault { get; init; } + + // Surface + public string BackdropMode { get; init; } = "Mica"; + public bool UsesHostBackdrop { get; init; } + public string PreviewTheme { get; init; } = "System"; + public string Variation { get; init; } = "Calm"; + + // Bubble / Layout + public double BubbleCornerRadius { get; init; } = 16; + public double Gutter { get; init; } = 64; + public double MessageGap { get; init; } = 12; + public string PaddingDensity { get; init; } = "Comfortable"; + public bool ShowTimestamps { get; init; } = true; + public bool ShowAssistantBubbles { get; init; } = true; + public bool ShowToolCalls { get; init; } = true; + public double BubbleMaxWidth { get; init; } = 560; + public double BubbleSideMargin { get; init; } = 8; + + // Footer + public bool ShowSenderName { get; init; } = true; + public bool ShowModelName { get; init; } = true; + public bool ShowTokens { get; init; } + public bool ShowContextPercent { get; init; } + + // Avatar + public bool ShowAvatars { get; init; } = true; + public string AvatarMode { get; init; } = "Both"; + + // Composer + public string ComposerLayout { get; init; } = "ThreeRow"; + public double ComposerCornerRadius { get; init; } = 8; + public double ComposerIconSize { get; init; } = 14; + public double SendButtonSize { get; init; } = 32; + + // Icons + public string SendIconGlyph { get; init; } = "\uE724"; + public bool SendIconShow { get; init; } = true; + public string AttachIconGlyph { get; init; } = "\uE723"; + public bool AttachIconShow { get; init; } = true; + public string VoiceIconGlyph { get; init; } = "\uE720"; + public bool VoiceIconShow { get; init; } = true; + public string MoreIconGlyph { get; init; } = "\uE712"; + public bool MoreIconShow { get; init; } = true; + public string StopIconGlyph { get; init; } = "\uE71A"; + public bool StopIconShow { get; init; } = true; + + // Brushes (#RRGGBB or null) + public string? AccentHex { get; init; } + public string? UserBubbleHex { get; init; } + public string? AssistantBubbleHex { get; init; } + public string? SendButtonHex { get; init; } +} + +public static class ChatExplorationPresetStore +{ + private static readonly JsonSerializerOptions JsonOpts = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static string FilePath + { + get + { + // OPENCLAW_TRAY_DATA_DIR override (used by tests). + var dir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR"); + if (string.IsNullOrEmpty(dir)) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + dir = Path.Combine(appData, "OpenClawTray"); + } + Directory.CreateDirectory(dir); + return Path.Combine(dir, "chat-exploration-presets.json"); + } + } + + public static List LoadAll() + { + try + { + var path = FilePath; + if (!File.Exists(path)) return new List(); + var json = File.ReadAllText(path); + var arr = JsonSerializer.Deserialize>(json, JsonOpts); + return arr ?? new List(); + } + catch { return new List(); } + } + + public static void SaveAll(IEnumerable presets) + { + try + { + var json = JsonSerializer.Serialize(presets.ToList(), JsonOpts); + File.WriteAllText(FilePath, json); + } + catch { /* best-effort */ } + } + + /// + /// Marks the preset with as the new default and + /// clears the flag on every other preset, then persists. Pass null/empty + /// to clear the default entirely. + /// + public static List SetDefault(string? name) + { + var all = LoadAll(); + var updated = all.Select(p => p with { IsDefault = !string.IsNullOrEmpty(name) && p.Name == name }).ToList(); + SaveAll(updated); + return updated; + } + + /// + /// Loads the persisted preset marked + /// and applies it to . Best-effort — any + /// IO/parse failure is swallowed and the in-memory defaults remain. + /// + public static void ApplyDefaultIfPresent() + { + try + { + var def = LoadAll().FirstOrDefault(p => p.IsDefault); + if (def is not null) Apply(def); + } + catch { /* best-effort */ } + } + + public static ChatExplorationPreset Capture(string name) => new() + { + Name = name, + BackdropMode = ChatExplorationState.BackdropMode.ToString(), + UsesHostBackdrop = ChatExplorationState.UsesHostBackdrop, + PreviewTheme = ChatExplorationState.PreviewTheme.ToString(), + Variation = ChatExplorationState.Variation.ToString(), + + BubbleCornerRadius = ChatExplorationState.BubbleCornerRadius, + Gutter = ChatExplorationState.Gutter, + MessageGap = ChatExplorationState.MessageGap, + PaddingDensity = ChatExplorationState.PaddingDensity.ToString(), + ShowTimestamps = ChatExplorationState.ShowTimestamps, + ShowAssistantBubbles = ChatExplorationState.ShowAssistantBubbles, + ShowToolCalls = ChatExplorationState.ShowToolCalls, + BubbleMaxWidth = ChatExplorationState.BubbleMaxWidth, + BubbleSideMargin = ChatExplorationState.BubbleSideMargin, + + ShowSenderName = ChatExplorationState.ShowSenderName, + ShowModelName = ChatExplorationState.ShowModelName, + ShowTokens = ChatExplorationState.ShowTokens, + ShowContextPercent = ChatExplorationState.ShowContextPercent, + + ShowAvatars = ChatExplorationState.ShowAvatars, + AvatarMode = ChatExplorationState.AvatarMode.ToString(), + + ComposerLayout = ChatExplorationState.ComposerLayout.ToString(), + ComposerCornerRadius = ChatExplorationState.ComposerCornerRadius, + ComposerIconSize = ChatExplorationState.ComposerIconSize, + SendButtonSize = ChatExplorationState.SendButtonSize, + + SendIconGlyph = ChatExplorationState.SendIconGlyph, + SendIconShow = ChatExplorationState.SendIconShow, + AttachIconGlyph = ChatExplorationState.AttachIconGlyph, + AttachIconShow = ChatExplorationState.AttachIconShow, + VoiceIconGlyph = ChatExplorationState.VoiceIconGlyph, + VoiceIconShow = ChatExplorationState.VoiceIconShow, + MoreIconGlyph = ChatExplorationState.MoreIconGlyph, + MoreIconShow = ChatExplorationState.MoreIconShow, + StopIconGlyph = ChatExplorationState.StopIconGlyph, + StopIconShow = ChatExplorationState.StopIconShow, + + AccentHex = BrushToHex(ChatExplorationState.AccentBrushOverride), + UserBubbleHex = BrushToHex(ChatExplorationState.UserBubbleBrushOverride), + AssistantBubbleHex = BrushToHex(ChatExplorationState.AssistantBubbleBrushOverride), + SendButtonHex = BrushToHex(ChatExplorationState.SendButtonBrushOverride), + }; + + public static void Apply(ChatExplorationPreset p) + { + if (Enum.TryParse(p.BackdropMode, out var bm)) ChatExplorationState.BackdropMode = bm; + ChatExplorationState.UsesHostBackdrop = p.UsesHostBackdrop; + if (Enum.TryParse(p.PreviewTheme, out var pt)) ChatExplorationState.PreviewTheme = pt; + if (Enum.TryParse(p.Variation, out var v)) ChatExplorationState.Variation = v; + + ChatExplorationState.BubbleCornerRadius = p.BubbleCornerRadius; + ChatExplorationState.Gutter = p.Gutter; + ChatExplorationState.MessageGap = p.MessageGap; + if (Enum.TryParse(p.PaddingDensity, out var pd)) ChatExplorationState.PaddingDensity = pd; + ChatExplorationState.ShowTimestamps = p.ShowTimestamps; + ChatExplorationState.ShowAssistantBubbles = p.ShowAssistantBubbles; + ChatExplorationState.ShowToolCalls = p.ShowToolCalls; + ChatExplorationState.BubbleMaxWidth = p.BubbleMaxWidth; + ChatExplorationState.BubbleSideMargin = p.BubbleSideMargin; + + ChatExplorationState.ShowSenderName = p.ShowSenderName; + ChatExplorationState.ShowModelName = p.ShowModelName; + ChatExplorationState.ShowTokens = p.ShowTokens; + ChatExplorationState.ShowContextPercent = p.ShowContextPercent; + + ChatExplorationState.ShowAvatars = p.ShowAvatars; + if (Enum.TryParse(p.AvatarMode, out var am)) ChatExplorationState.AvatarMode = am; + + if (Enum.TryParse(p.ComposerLayout, out var cl)) ChatExplorationState.ComposerLayout = cl; + ChatExplorationState.ComposerCornerRadius = p.ComposerCornerRadius; + ChatExplorationState.ComposerIconSize = p.ComposerIconSize; + ChatExplorationState.SendButtonSize = p.SendButtonSize; + + ChatExplorationState.SendIconGlyph = p.SendIconGlyph; + ChatExplorationState.SendIconShow = p.SendIconShow; + ChatExplorationState.AttachIconGlyph = p.AttachIconGlyph; + ChatExplorationState.AttachIconShow = p.AttachIconShow; + ChatExplorationState.VoiceIconGlyph = p.VoiceIconGlyph; + ChatExplorationState.VoiceIconShow = p.VoiceIconShow; + ChatExplorationState.MoreIconGlyph = p.MoreIconGlyph; + ChatExplorationState.MoreIconShow = p.MoreIconShow; + ChatExplorationState.StopIconGlyph = p.StopIconGlyph; + ChatExplorationState.StopIconShow = p.StopIconShow; + + ChatExplorationState.AccentBrushOverride = HexToBrush(p.AccentHex); + ChatExplorationState.UserBubbleBrushOverride = HexToBrush(p.UserBubbleHex); + ChatExplorationState.AssistantBubbleBrushOverride = HexToBrush(p.AssistantBubbleHex); + ChatExplorationState.SendButtonBrushOverride = HexToBrush(p.SendButtonHex); + } + + public static string? BrushToHex(Brush? b) => + b is SolidColorBrush scb ? $"#{scb.Color.R:X2}{scb.Color.G:X2}{scb.Color.B:X2}" : null; + + public static Brush? HexToBrush(string? hex) + { + if (string.IsNullOrWhiteSpace(hex)) return null; + var s = hex.Trim().TrimStart('#'); + if (s.Length != 6) return null; + try + { + byte r = Convert.ToByte(s.Substring(0, 2), 16); + byte g = Convert.ToByte(s.Substring(2, 2), 16); + byte bl = Convert.ToByte(s.Substring(4, 2), 16); + return new SolidColorBrush(Color.FromArgb(0xFF, r, g, bl)); + } + catch { return null; } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs new file mode 100644 index 000000000..3abd1fe24 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs @@ -0,0 +1,419 @@ +using System; +using Microsoft.UI.Xaml.Media; + +namespace OpenClawTray.Chat.Explorations; + +// Enums mirror Kenny's v2 ChatExplorationPreview spec +// (openclaw-windows-node-v2/src/OpenClaw.Tray.WinUI/Controls/ChatExplorations/ChatExplorationPreview.xaml.cs). +// Source of truth for value names — keep in sync if the v2 spec evolves. + +public enum ChatVariation +{ + /// Mica look-alike, large rounded bubbles, generous spacing. + Calm, + /// Acrylic look-alike, small bubbles, tight spacing. + Compact, + /// Solid surface, no bubble fill, thin accent left stroke + larger typography. + Plain, +} + +public enum ChatBackdropMode +{ + Mica, + MicaAlt, + Acrylic, + Solid, +} + +public enum ChatPaddingDensity +{ + Cozy, + Comfortable, + Compact, +} + +public enum ChatPreviewTheme +{ + System, + Light, + Dark, +} + +public enum ChatAvatarMode +{ + Both, + AgentOnly, + None, +} + +/// +/// Forces the chat surface into a specific lifecycle state for preview purposes. +/// Live = no override (use real provider data). The other values render +/// a synthesized version of each visual state so designers can compare them +/// without waiting for real backend conditions. +/// +public enum ChatPreviewState +{ + /// No override — render whatever the data provider says. + Live, + /// Force the "Connecting to gateway…" ProgressRing screen. + Loading, + /// + /// Force the unified zero-state (welcome screen with app icon + prompt + /// suggestions). Covers both "no thread selected" and "thread with zero + /// messages" — they intentionally render identically because the + /// distinction is a backend implementation detail, not a user-facing one. + /// + Empty, + /// Timeline + inline "thinking" indicator (turn started, no assistant delta yet). + Thinking, + /// Composer shows the tool-permission banner with Allow/Deny. + PendingPermission, +} + +public enum ChatComposerLayout +{ + /// Three rows: dropdowns / textbox / actions. Mirrors production OpenClawComposer. + ThreeRow, + /// Two rows: textbox on top, then [borderless session·model pill] [actions] [Send]. + /// The pill opens a single MenuFlyout grouping Session / Model / Reasoning sections. + InlinePill, + /// Single row: textbox + Send. Everything else hides under a More menu. + Minimal, +} + +/// +/// Visual treatment for a contiguous run of ToolCall entries (a +/// "tool burst") that all belong to the same assistant turn. The burst is +/// rendered as a single unified card in ; +/// this enum controls how that card is framed at the *task* level. +/// +public enum ToolBurstStyle +{ + /// No task framing. Just rows + a single trailing "Tool · time" footer. + Plain, + /// Card-top header row "⚡ Task · N steps [overall status]". + /// Mirrors Cursor's "Tool calls (N steps)" treatment. + TaskHeader, + /// Single collapsed summary row "▸ ⚡ Task · N steps (a, b, c) [Done]". + /// Click to expand the per-step list. Highest information density. + CompactSummary, + /// Plain rows but the trailing footer is reframed as + /// "Task · N steps · time" so the task semantics are still surfaced. + FooterReframe, + /// Per-step list with a status icon per row: ✓ for done, + /// spinning ProgressRing for in-progress, ✕ for errored. + /// Mirrors the AgentRunCard "Running steps / Completed steps" pattern + /// from native-chat-v2. + TaskList, +} + +/// +/// Process-wide live state for the chat exploration toggles. Mirrors the +/// dependency-property surface of v2's ChatExplorationPreview XAML +/// control, but as plain static properties so the native chat tree +/// (, , +/// ) can read them at render time and any +/// component can subscribe to to invalidate. +/// +/// Not persisted — resets every app launch (debug/exploration tool only). +/// Follows the same static + Changed event pattern as +/// . +/// +public static class ChatExplorationState +{ + // Defaults mirror v2 PropertyMetadata. + + private static ChatPreviewState _previewState = ChatPreviewState.Live; + private static ChatVariation _variation = ChatVariation.Calm; + private static ChatBackdropMode _backdropMode = ChatBackdropMode.Mica; + private static ChatPreviewTheme _previewTheme = ChatPreviewTheme.System; + private static bool _usesHostBackdrop; + + private static double _bubbleCornerRadius = 16d; + private static double _gutter = 64d; + private static double _messageGap = 12d; + private static ChatPaddingDensity _paddingDensity = ChatPaddingDensity.Comfortable; + private static bool _showTimestamps = true; + + private static bool _showAvatars = true; + private static ChatAvatarMode _avatarMode = ChatAvatarMode.Both; + + private static ChatComposerLayout _composerLayout = ChatComposerLayout.ThreeRow; + private static double _composerCornerRadius = 8d; + private static double _composerIconSize = 14d; + private static double _sendButtonSize = 32d; + + private static Brush? _accentBrushOverride; + private static Brush? _userBubbleBrushOverride; + private static Brush? _assistantBubbleBrushOverride; + private static Brush? _sendButtonBrushOverride; + + // ── v2 additions: bubble/tool visibility, size, footer details, icon customization ── + private static bool _showAssistantBubbles = true; + private static bool _showToolCalls = true; + private static double _bubbleMaxWidth = 560d; + private static double _bubbleSideMargin = 8d; + + private static bool _showSenderName = true; + private static bool _showModelName = true; + private static bool _showTokens; + private static bool _showContextPercent; + + // Default icon glyphs match production OpenClawComposer.cs. + private static string _sendIconGlyph = "\uE724"; + private static bool _sendIconShow = true; + private static string _attachIconGlyph = "\uE723"; + private static bool _attachIconShow = true; + private static string _voiceIconGlyph = "\uE720"; + private static bool _voiceIconShow = true; + private static string _moreIconGlyph = "\uE712"; + private static bool _moreIconShow = true; + private static string _stopIconGlyph = "\uE71A"; + private static bool _stopIconShow = true; + + // ---- Preview state override (G) ---- + + /// + /// When set to anything other than , + /// bypasses the real provider data and + /// renders a synthesized version of the matching lifecycle state. + /// + public static ChatPreviewState PreviewState + { + get => _previewState; + set { if (_previewState != value) { _previewState = value; RaiseChanged(); } } + } + + // ---- Surface (A) ---- + + public static ChatVariation Variation + { + get => _variation; + set { if (_variation != value) { _variation = value; RaiseChanged(); } } + } + + public static ChatBackdropMode BackdropMode + { + get => _backdropMode; + set { if (_backdropMode != value) { _backdropMode = value; RaiseChanged(); } } + } + + public static ChatPreviewTheme PreviewTheme + { + get => _previewTheme; + set { if (_previewTheme != value) { _previewTheme = value; RaiseChanged(); } } + } + + /// + /// When true, the chat is hosted inside a window/page that already paints a + /// SystemBackdrop, so backdrop changes are no-ops at the chat-surface level + /// (the host owns the backdrop). Mirrors v2 UsesHostBackdrop. + /// + public static bool UsesHostBackdrop + { + get => _usesHostBackdrop; + set { if (_usesHostBackdrop != value) { _usesHostBackdrop = value; RaiseChanged(); } } + } + + // ---- Bubble / Layout (C) ---- + + public static double BubbleCornerRadius + { + get => _bubbleCornerRadius; + set { if (_bubbleCornerRadius != value) { _bubbleCornerRadius = value; RaiseChanged(); } } + } + + public static double Gutter + { + get => _gutter; + set { if (_gutter != value) { _gutter = value; RaiseChanged(); } } + } + + public static double MessageGap + { + get => _messageGap; + set { if (_messageGap != value) { _messageGap = value; RaiseChanged(); } } + } + + public static ChatPaddingDensity PaddingDensity + { + get => _paddingDensity; + set { if (_paddingDensity != value) { _paddingDensity = value; RaiseChanged(); } } + } + + public static bool ShowTimestamps + { + get => _showTimestamps; + set { if (_showTimestamps != value) { _showTimestamps = value; RaiseChanged(); } } + } + + // ---- Avatar (D) ---- + + public static bool ShowAvatars + { + get => _showAvatars; + set { if (_showAvatars != value) { _showAvatars = value; RaiseChanged(); } } + } + + public static ChatAvatarMode AvatarMode + { + get => _avatarMode; + set { if (_avatarMode != value) { _avatarMode = value; RaiseChanged(); } } + } + + // ---- Composer (E) ---- + + public static ChatComposerLayout ComposerLayout + { + get => _composerLayout; + set { if (_composerLayout != value) { _composerLayout = value; RaiseChanged(); } } + } + + public static double ComposerCornerRadius + { + get => _composerCornerRadius; + set { if (_composerCornerRadius != value) { _composerCornerRadius = value; RaiseChanged(); } } + } + + public static double ComposerIconSize + { + get => _composerIconSize; + set { if (_composerIconSize != value) { _composerIconSize = value; RaiseChanged(); } } + } + + public static double SendButtonSize + { + get => _sendButtonSize; + set { if (_sendButtonSize != value) { _sendButtonSize = value; RaiseChanged(); } } + } + + // ---- Brush overrides (F). null = use theme/accent default. ---- + + public static Brush? AccentBrushOverride + { + get => _accentBrushOverride; + set { if (!ReferenceEquals(_accentBrushOverride, value)) { _accentBrushOverride = value; RaiseChanged(); } } + } + + public static Brush? UserBubbleBrushOverride + { + get => _userBubbleBrushOverride; + set { if (!ReferenceEquals(_userBubbleBrushOverride, value)) { _userBubbleBrushOverride = value; RaiseChanged(); } } + } + + public static Brush? AssistantBubbleBrushOverride + { + get => _assistantBubbleBrushOverride; + set { if (!ReferenceEquals(_assistantBubbleBrushOverride, value)) { _assistantBubbleBrushOverride = value; RaiseChanged(); } } + } + + public static Brush? SendButtonBrushOverride + { + get => _sendButtonBrushOverride; + set { if (!ReferenceEquals(_sendButtonBrushOverride, value)) { _sendButtonBrushOverride = value; RaiseChanged(); } } + } + + /// + /// Fires whenever any toggle or override changes. Subscribers should + /// invalidate any cached visuals derived from these values. + /// + public static event EventHandler? Changed; + + private static void RaiseChanged() => Changed?.Invoke(null, EventArgs.Empty); + + // ────────────────────────────────────────────────────────────────── + // v2 additions + // ────────────────────────────────────────────────────────────────── + + // ---- Bubble / tool visibility + sizing (extends C) ---- + + public static bool ShowAssistantBubbles + { + get => _showAssistantBubbles; + set { if (_showAssistantBubbles != value) { _showAssistantBubbles = value; RaiseChanged(); } } + } + + public static bool ShowToolCalls + { + get => _showToolCalls; + set { if (_showToolCalls != value) { _showToolCalls = value; RaiseChanged(); } } + } + + public static double BubbleMaxWidth + { + get => _bubbleMaxWidth; + set { if (_bubbleMaxWidth != value) { _bubbleMaxWidth = value; RaiseChanged(); } } + } + + /// Distance between the bubble and the avatar (or the wall when avatar hidden). + public static double BubbleSideMargin + { + get => _bubbleSideMargin; + set { if (_bubbleSideMargin != value) { _bubbleSideMargin = value; RaiseChanged(); } } + } + + // ---- Footer detail toggles (extends ShowTimestamps) ---- + + public static bool ShowSenderName + { + get => _showSenderName; + set { if (_showSenderName != value) { _showSenderName = value; RaiseChanged(); } } + } + + public static bool ShowModelName + { + get => _showModelName; + set { if (_showModelName != value) { _showModelName = value; RaiseChanged(); } } + } + + public static bool ShowTokens + { + get => _showTokens; + set { if (_showTokens != value) { _showTokens = value; RaiseChanged(); } } + } + + public static bool ShowContextPercent + { + get => _showContextPercent; + set { if (_showContextPercent != value) { _showContextPercent = value; RaiseChanged(); } } + } + + // ---- Composer icon customization (extends E) ---- + + public static string SendIconGlyph { get => _sendIconGlyph; set { if (_sendIconGlyph != value) { _sendIconGlyph = value ?? ""; RaiseChanged(); } } } + public static bool SendIconShow { get => _sendIconShow; set { if (_sendIconShow != value) { _sendIconShow = value; RaiseChanged(); } } } + public static string AttachIconGlyph { get => _attachIconGlyph; set { if (_attachIconGlyph != value) { _attachIconGlyph = value ?? ""; RaiseChanged(); } } } + public static bool AttachIconShow { get => _attachIconShow; set { if (_attachIconShow != value) { _attachIconShow = value; RaiseChanged(); } } } + public static string VoiceIconGlyph { get => _voiceIconGlyph; set { if (_voiceIconGlyph != value) { _voiceIconGlyph = value ?? ""; RaiseChanged(); } } } + public static bool VoiceIconShow { get => _voiceIconShow; set { if (_voiceIconShow != value) { _voiceIconShow = value; RaiseChanged(); } } } + public static string MoreIconGlyph { get => _moreIconGlyph; set { if (_moreIconGlyph != value) { _moreIconGlyph = value ?? ""; RaiseChanged(); } } } + public static bool MoreIconShow { get => _moreIconShow; set { if (_moreIconShow != value) { _moreIconShow = value; RaiseChanged(); } } } + public static string StopIconGlyph { get => _stopIconGlyph; set { if (_stopIconGlyph != value) { _stopIconGlyph = value ?? ""; RaiseChanged(); } } } + public static bool StopIconShow { get => _stopIconShow; set { if (_stopIconShow != value) { _stopIconShow = value; RaiseChanged(); } } } + + // ---- Tool burst (H) ---- + + private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain; + private static bool _showStepNumbers; + + /// + /// Visual framing for a run of consecutive ToolCall entries (multi-step + /// assistant turn). See for the variants. + /// + public static ToolBurstStyle ToolBurstStyle + { + get => _toolBurstStyle; + set { if (_toolBurstStyle != value) { _toolBurstStyle = value; RaiseChanged(); } } + } + + /// + /// When true, prefix each step row with its 1-based index ("1.", "2.", …) + /// so the sequence is explicit. Has no effect on single-step bursts. + /// + public static bool ShowStepNumbers + { + get => _showStepNumbers; + set { if (_showStepNumbers != value) { _showStepNumbers = value; RaiseChanged(); } } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs new file mode 100644 index 000000000..d9a555b8c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs @@ -0,0 +1,370 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Windows; +using System; +using System.Collections.Generic; +using System.Linq; +using Windows.UI; +using static OpenClawTray.FunctionalUI.Factories; +using static OpenClawTray.FunctionalUI.Core.Theme; + +namespace OpenClawTray.Chat.Explorations; + +/// +/// FunctionalUI 좌측 패널: ChatExplorationState 의 모든 토글을 슬라이더/체크박스/콤보박스/ +/// ColorPicker 로 묶어서 보여준다. 변경하면 ChatExplorationState 가 즉시 업데이트되고 +/// 옆 라이브 챗이 다시 그려진다 (각 챗 컴포넌트가 Changed 를 구독 중). +/// +public class ChatExplorationsPanel : Component +{ + public override Element Render() + { + var rev = UseState(0, threadSafe: true); + UseEffect((Func)(() => + { + EventHandler h = (_, _) => rev.Set(rev.Value + 1); + ChatExplorationState.Changed += h; + return () => ChatExplorationState.Changed -= h; + })); + + var presetNameState = UseState("", threadSafe: true); + var presetsState = UseState>(ChatExplorationPresetStore.LoadAll(), threadSafe: true); + var selectedPresetIdx = UseState(0, threadSafe: true); + + // ── A. Surface / Backdrop ──────────────────────────────────── + var backdropSection = Section("A. Surface", + EnumCombo("Backdrop", ChatExplorationState.BackdropMode, + v => ChatExplorationState.BackdropMode = v, + ChatBackdropMode.Mica, ChatBackdropMode.MicaAlt, ChatBackdropMode.Acrylic, ChatBackdropMode.Solid), + Toggle("Uses host backdrop", ChatExplorationState.UsesHostBackdrop, + v => ChatExplorationState.UsesHostBackdrop = v), + EnumCombo("Preview theme", ChatExplorationState.PreviewTheme, + v => ChatExplorationState.PreviewTheme = v, + ChatPreviewTheme.System, ChatPreviewTheme.Light, ChatPreviewTheme.Dark) + ); + + // ── B. Variation 프리셋 + 커스텀 프리셋 ───────────────────── + var presets = presetsState.Value; + var presetNames = presets.Count == 0 + ? new[] { "(no custom presets)" } + : presets.Select(p => p.IsDefault ? $"★ {p.Name}" : p.Name).ToArray(); + var safeIdx = presets.Count == 0 ? 0 : Math.Clamp(selectedPresetIdx.Value, 0, presets.Count - 1); + var selectedPreset = presets.Count == 0 ? null : presets[safeIdx]; + + var variationSection = Section("B. Variation preset", + EnumCombo("Variation", ChatExplorationState.Variation, + v => { ChatExplorationState.Variation = v; ChatVariationPresets.Apply(v); }, + ChatVariation.Calm, ChatVariation.Compact, ChatVariation.Plain), + TextBlock("Custom presets").Set(t => { t.FontSize = 12; t.Opacity = 0.85; }), + (FlexRow( + TextField(presetNameState.Value, v => presetNameState.Set(v)) + .Set(tb => { tb.PlaceholderText = "name"; tb.MinWidth = 130; tb.Height = 28; }), + Button("💾 Save", () => + { + var name = (presetNameState.Value ?? "").Trim(); + if (string.IsNullOrEmpty(name)) return; + var existingDefault = presetsState.Value.Any(p => p.Name == name && p.IsDefault); + var list = presetsState.Value.Where(p => p.Name != name).ToList(); + list.Add(ChatExplorationPresetStore.Capture(name) with { IsDefault = existingDefault }); + ChatExplorationPresetStore.SaveAll(list); + presetsState.Set(list); + selectedPresetIdx.Set(list.FindIndex(p => p.Name == name)); + presetNameState.Set(""); + }).Set(b => b.Height = 28) + ) with { ColumnGap = 6 }), + (FlexRow( + ComboBox(presetNames, safeIdx, i => selectedPresetIdx.Set(i)) + .Set(cb => { cb.MinWidth = 130; cb.Height = 28; }), + Button("📂 Load", () => + { + if (presets.Count == 0) return; + ChatExplorationPresetStore.Apply(presets[safeIdx]); + }).Set(b => b.Height = 28), + Button(selectedPreset?.IsDefault == true ? "★ Unset default" : "☆ Set default", () => + { + if (selectedPreset is null) return; + var newName = selectedPreset.IsDefault ? null : selectedPreset.Name; + var updated = ChatExplorationPresetStore.SetDefault(newName); + presetsState.Set(updated); + }).Set(b => b.Height = 28), + Button("🗑", () => + { + if (presets.Count == 0) return; + var list = presets.Where((_, i) => i != safeIdx).ToList(); + ChatExplorationPresetStore.SaveAll(list); + presetsState.Set(list); + selectedPresetIdx.Set(0); + }).Set(b => b.Height = 28) + ) with { ColumnGap = 6 }) + ); + + // ── C. Bubble / Layout ─────────────────────────────────────── + var bubbleSection = Section("C. Bubble / Layout", + SliderRow("Bubble corner radius", ChatExplorationState.BubbleCornerRadius, 0, 30, + v => ChatExplorationState.BubbleCornerRadius = v), + SliderRow("Gutter", ChatExplorationState.Gutter, 0, 160, + v => ChatExplorationState.Gutter = v), + SliderRow("Message gap", ChatExplorationState.MessageGap, 0, 40, + v => ChatExplorationState.MessageGap = v), + SliderRow("Bubble max width", ChatExplorationState.BubbleMaxWidth, 200, 900, + v => ChatExplorationState.BubbleMaxWidth = v), + SliderRow("Bubble side margin", ChatExplorationState.BubbleSideMargin, 0, 40, + v => ChatExplorationState.BubbleSideMargin = v), + EnumCombo("Padding density", ChatExplorationState.PaddingDensity, + v => ChatExplorationState.PaddingDensity = v, + ChatPaddingDensity.Cozy, ChatPaddingDensity.Comfortable, ChatPaddingDensity.Compact) + ); + + // ── C.1. Bubble visibility & footer ────────────────────────── + var visibilitySection = Section("C.1. Visibility & footer", + Toggle("Show assistant bubbles", ChatExplorationState.ShowAssistantBubbles, + v => ChatExplorationState.ShowAssistantBubbles = v), + Toggle("Show tool calls", ChatExplorationState.ShowToolCalls, + v => ChatExplorationState.ShowToolCalls = v), + Toggle("Show timestamps", ChatExplorationState.ShowTimestamps, + v => ChatExplorationState.ShowTimestamps = v), + Toggle(" Show sender name", ChatExplorationState.ShowSenderName, + v => ChatExplorationState.ShowSenderName = v), + Toggle(" Show model name", ChatExplorationState.ShowModelName, + v => ChatExplorationState.ShowModelName = v), + Toggle(" Show tokens", ChatExplorationState.ShowTokens, + v => ChatExplorationState.ShowTokens = v), + Toggle(" Show context %", ChatExplorationState.ShowContextPercent, + v => ChatExplorationState.ShowContextPercent = v) + ); + + // ── D. Avatar ──────────────────────────────────────────────── + var avatarSection = Section("D. Avatar", + Toggle("Show avatars", ChatExplorationState.ShowAvatars, + v => ChatExplorationState.ShowAvatars = v), + EnumCombo("Avatar mode", ChatExplorationState.AvatarMode, + v => ChatExplorationState.AvatarMode = v, + ChatAvatarMode.Both, ChatAvatarMode.AgentOnly, ChatAvatarMode.None) + ); + + // ── E. Composer ────────────────────────────────────────────── + var composerSection = Section("E. Composer", + EnumCombo("Composer layout", ChatExplorationState.ComposerLayout, + v => ChatExplorationState.ComposerLayout = v, + ChatComposerLayout.ThreeRow, ChatComposerLayout.InlinePill, ChatComposerLayout.Minimal), + SliderRow("Composer corner radius", ChatExplorationState.ComposerCornerRadius, 0, 24, + v => ChatExplorationState.ComposerCornerRadius = v), + SliderRow("Composer icon size", ChatExplorationState.ComposerIconSize, 8, 32, + v => ChatExplorationState.ComposerIconSize = v), + SliderRow("Send button size", ChatExplorationState.SendButtonSize, 16, 64, + v => ChatExplorationState.SendButtonSize = v) + ); + + // ── E.1. Composer icons ────────────────────────────────────── + var iconsSection = Section("E.1. Composer icons (Segoe MDL2)", + IconRow("Send", + () => ChatExplorationState.SendIconShow, v => ChatExplorationState.SendIconShow = v, + () => ChatExplorationState.SendIconGlyph, v => ChatExplorationState.SendIconGlyph = v), + IconRow("Stop", + () => ChatExplorationState.StopIconShow, v => ChatExplorationState.StopIconShow = v, + () => ChatExplorationState.StopIconGlyph, v => ChatExplorationState.StopIconGlyph = v), + IconRow("Attach", + () => ChatExplorationState.AttachIconShow, v => ChatExplorationState.AttachIconShow = v, + () => ChatExplorationState.AttachIconGlyph, v => ChatExplorationState.AttachIconGlyph = v), + IconRow("Voice", + () => ChatExplorationState.VoiceIconShow, v => ChatExplorationState.VoiceIconShow = v, + () => ChatExplorationState.VoiceIconGlyph, v => ChatExplorationState.VoiceIconGlyph = v), + IconRow("More", + () => ChatExplorationState.MoreIconShow, v => ChatExplorationState.MoreIconShow = v, + () => ChatExplorationState.MoreIconGlyph, v => ChatExplorationState.MoreIconGlyph = v) + ); + + // ── F. Brush overrides (ColorPicker) ───────────────────────── + var brushSection = Section("F. Brush overrides (color picker)", + ColorRow("Accent", + ChatExplorationState.AccentBrushOverride, + b => ChatExplorationState.AccentBrushOverride = b), + ColorRow("User bubble", + ChatExplorationState.UserBubbleBrushOverride, + b => ChatExplorationState.UserBubbleBrushOverride = b), + ColorRow("Assistant bubble", + ChatExplorationState.AssistantBubbleBrushOverride, + b => ChatExplorationState.AssistantBubbleBrushOverride = b), + ColorRow("Send button", + ChatExplorationState.SendButtonBrushOverride, + b => ChatExplorationState.SendButtonBrushOverride = b) + ); + + // ── G. Preview state ───────────────────────────────────────── + // Lets designers preview each chat lifecycle state (loading, + // empty/zero, empty thread, thinking indicator, pending + // permission) without having to reproduce the real backend + // conditions. Live = no override. + var previewStateSection = Section("G. Preview state", + EnumCombo("Force chat state", ChatExplorationState.PreviewState, + v => ChatExplorationState.PreviewState = v, + ChatPreviewState.Live, + ChatPreviewState.Loading, + ChatPreviewState.Empty, + ChatPreviewState.Thinking, + ChatPreviewState.PendingPermission) + ); + + // ── H. Tool burst (multi-step task framing) ────────────────── + // Variants explored here mirror competitor patterns: + // Plain — current Cursor-lite (no task framing) + // TaskHeader — Cursor's "Tool calls (N steps)" + per-row list + // CompactSummary — single collapsed "Task · 3 steps" row, expands + // FooterReframe — plain rows, just reframe the footer text + var toolBurstSection = Section("H. Tool burst style", + EnumCombo("Burst style", ChatExplorationState.ToolBurstStyle, + v => ChatExplorationState.ToolBurstStyle = v, + ToolBurstStyle.Plain, + ToolBurstStyle.TaskHeader, + ToolBurstStyle.CompactSummary, + ToolBurstStyle.FooterReframe, + ToolBurstStyle.TaskList), + Toggle("Show step numbers (1./2./3.)", ChatExplorationState.ShowStepNumbers, + v => ChatExplorationState.ShowStepNumbers = v) + ); + + var resetBtn = Button("Reset all", () => + { + ChatVariationPresets.Apply(ChatVariation.Calm); + ChatExplorationState.AccentBrushOverride = null; + ChatExplorationState.UserBubbleBrushOverride = null; + ChatExplorationState.AssistantBubbleBrushOverride = null; + ChatExplorationState.SendButtonBrushOverride = null; + ChatExplorationState.PreviewState = ChatPreviewState.Live; + }); + + var pinBtn = Button( + ChatWindowPinState.IsPinned ? "📌 Unpin tray chat popup" : "📌 Pin tray chat popup", + () => + { + try + { + if (ChatWindowPinState.IsPinned) + { + ChatWindowPinState.IsPinned = false; + rev.Set(rev.Value + 1); // refresh button label + return; + } + ChatWindowPinState.IsPinned = true; + (App.Current as App)?.ShowChatWindow(); + rev.Set(rev.Value + 1); + } + catch { /* ignore */ } + }); + + var content = VStack(12, + TextBlock("Chat explorations").Set(t => { t.FontSize = 18; t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; }), + backdropSection, variationSection, + bubbleSection, visibilitySection, + avatarSection, + composerSection, iconsSection, + brushSection, + previewStateSection, + toolBurstSection, + (FlexRow(resetBtn, pinBtn) with { ColumnGap = 8 }) + ).Padding(16, 16, 16, 16); + + return ScrollView(content); + } + + // ── Helpers ────────────────────────────────────────────────────── + + static Element Section(string title, params Element[] rows) + { + var children = new Element[rows.Length + 1]; + children[0] = TextBlock(title).Set(t => { t.FontSize = 13; t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; t.Opacity = 0.85; }); + Array.Copy(rows, 0, children, 1, rows.Length); + return Border(VStack(8, children)) + .Padding(12, 10, 12, 10) + .Set(b => + { + b.CornerRadius = new CornerRadius(6); + b.BorderThickness = new Thickness(1); + b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["ControlStrokeColorDefaultBrush"]; + }); + } + + static Element SliderRow(string label, double value, double min, double max, Action set) + => VStack(2, + TextBlock($"{label}: {value:0.#}").Set(t => t.FontSize = 12), + Slider(value, min, max, set) + ); + + static Element Toggle(string label, bool isChecked, Action set) + => CheckBox(isChecked, set, label); + + static Element EnumCombo(string label, T current, Action set, params T[] options) where T : struct, Enum + { + var names = new string[options.Length]; + for (int i = 0; i < options.Length; i++) names[i] = options[i].ToString(); + var idx = Array.IndexOf(options, current); + if (idx < 0) idx = 0; + return VStack(2, + TextBlock(label).Set(t => t.FontSize = 12), + ComboBox(names, idx, i => { if (i >= 0 && i < options.Length) set(options[i]); }) + .Set(cb => { cb.Height = 28; cb.MinWidth = 200; }) + ); + } + + static Element IconRow(string label, Func getShow, Action setShow, + Func getGlyph, Action setGlyph) + { + var glyph = getGlyph(); + var preview = string.IsNullOrEmpty(glyph) ? "·" : glyph; + return (FlexRow( + CheckBox(getShow(), v => setShow(v), label).Set(c => c.MinWidth = 70), + TextField(glyph, v => setGlyph(v)) + .Set(tb => { tb.PlaceholderText = "\\uE724"; tb.MinWidth = 90; tb.Height = 28; tb.FontFamily = new FontFamily("Consolas, monospace"); }), + Border( + TextBlock(preview).Set(t => + { + t.FontFamily = new FontFamily("Segoe MDL2 Assets, Segoe Fluent Icons"); + t.FontSize = 16; + }) + ).Padding(6, 2, 6, 2).VAlign(VerticalAlignment.Center) + ) with { ColumnGap = 6 }); + } + + static Element ColorRow(string label, Brush? current, Action set) + { + var hex = ChatExplorationPresetStore.BrushToHex(current) ?? "(default)"; + var swatchColor = current is SolidColorBrush scb ? scb.Color : Color.FromArgb(0xFF, 0xCC, 0xCC, 0xCC); + + var swatch = Border(Empty()) + .Set(b => + { + b.Width = 24; b.Height = 24; + b.CornerRadius = new CornerRadius(4); + b.Background = current ?? new SolidColorBrush(Color.FromArgb(0x40, 0x80, 0x80, 0x80)); + b.BorderThickness = new Thickness(1); + b.BorderBrush = (Brush)Microsoft.UI.Xaml.Application.Current.Resources["ControlStrokeColorDefaultBrush"]; + }).VAlign(VerticalAlignment.Center); + + var pickerButton = Button( + (FlexRow( + swatch, + Caption(hex).VAlign(VerticalAlignment.Center) + ) with { ColumnGap = 8 }), + () => { /* opens via attached flyout */ }) + .Set(b => { b.Height = 32; b.MinWidth = 160; b.HorizontalContentAlignment = HorizontalAlignment.Left; }) + .WithFlyout(ContentFlyout( + VStack(8, + ColorPicker(swatchColor, c => + set(new SolidColorBrush(Color.FromArgb(0xFF, c.R, c.G, c.B)))) + ).Padding(8, 8, 8, 8), + FlyoutPlacementMode.Right)); + + var clearBtn = Button("✕", () => set(null)) + .Set(b => { b.Height = 32; b.MinWidth = 32; }); + + return VStack(2, + TextBlock(label).Set(t => t.FontSize = 12), + (FlexRow(pickerButton, clearBtn) with { ColumnGap = 6 }) + ); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs new file mode 100644 index 000000000..fb7622e32 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVariationPresets.cs @@ -0,0 +1,86 @@ +namespace OpenClawTray.Chat.Explorations; + +/// +/// Snapshot bundle of every visual setting tuned by a single +/// preset. writes the bundle back to +/// in one shot so subscribers re-render only once. Mirrors the OnVisualChanged +/// preset block in v2 ChatExplorationPreview.xaml.cs. +/// +public sealed record ChatVariationPreset( + double BubbleCornerRadius, + double Gutter, + double MessageGap, + ChatPaddingDensity PaddingDensity, + double ComposerCornerRadius, + double ComposerIconSize, + double SendButtonSize, + bool ShowAvatars, + bool ShowTimestamps); + +public static class ChatVariationPresets +{ + /// Mica look-alike, large rounded bubbles, generous spacing. + public static readonly ChatVariationPreset Calm = new( + BubbleCornerRadius: 16, + Gutter: 64, + MessageGap: 12, + PaddingDensity: ChatPaddingDensity.Comfortable, + ComposerCornerRadius: 8, + ComposerIconSize: 14, + SendButtonSize: 32, + ShowAvatars: true, + ShowTimestamps: true); + + /// Acrylic look-alike, small bubbles, tight spacing. + public static readonly ChatVariationPreset Compact = new( + BubbleCornerRadius: 10, + Gutter: 24, + MessageGap: 6, + PaddingDensity: ChatPaddingDensity.Compact, + ComposerCornerRadius: 6, + ComposerIconSize: 12, + SendButtonSize: 28, + ShowAvatars: true, + ShowTimestamps: false); + + /// Solid surface, no bubble fill, thin accent left stroke + larger typography. + public static readonly ChatVariationPreset Plain = new( + BubbleCornerRadius: 4, + Gutter: 40, + MessageGap: 18, + PaddingDensity: ChatPaddingDensity.Cozy, + ComposerCornerRadius: 4, + ComposerIconSize: 14, + SendButtonSize: 32, + ShowAvatars: false, + ShowTimestamps: true); + + public static ChatVariationPreset For(ChatVariation variation) => variation switch + { + ChatVariation.Compact => Compact, + ChatVariation.Plain => Plain, + _ => Calm, + }; + + /// + /// Apply 's preset bundle to + /// . Each setter raises + /// individually — subscribers may receive several Changed events in sequence. + /// (We accept the small re-render cost in exchange for keeping ChatExplorationState + /// free of batching APIs.) + /// + public static void Apply(ChatVariation variation) + { + var p = For(variation); + ChatExplorationState.Variation = variation; + ChatExplorationState.BubbleCornerRadius = p.BubbleCornerRadius; + ChatExplorationState.Gutter = p.Gutter; + ChatExplorationState.MessageGap = p.MessageGap; + ChatExplorationState.PaddingDensity = p.PaddingDensity; + ChatExplorationState.ComposerCornerRadius = p.ComposerCornerRadius; + ChatExplorationState.ComposerIconSize = p.ComposerIconSize; + ChatExplorationState.SendButtonSize = p.SendButtonSize; + ChatExplorationState.ShowAvatars = p.ShowAvatars; + ChatExplorationState.ShowTimestamps = p.ShowTimestamps; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVisualResolver.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVisualResolver.cs new file mode 100644 index 000000000..d0778b5e8 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatVisualResolver.cs @@ -0,0 +1,111 @@ +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; + +namespace OpenClawTray.Chat.Explorations; + +/// +/// Pure functions that translate the live +/// values into concrete WinUI primitives (Thickness, double, Brush). Components +/// in call these from their Render so the +/// translation logic lives in one place and stays consistent across the chat +/// surface. +/// +/// Centralizing here also lets the presets fan-out +/// to multiple sliders in one place — pick a variation, get the bundled bubble +/// radius / gutter / message gap / padding density / composer radius, etc. +/// (The variation only fires when the user hits a preset button; once they +/// nudge a slider, the slider value wins because it's stored separately.) +/// +public static class ChatVisualResolver +{ + // ── Bubble / Layout (C) ─────────────────────────────────────────── + + public static CornerRadius BubbleCornerRadius() + => new(ChatExplorationState.BubbleCornerRadius); + + public static Thickness BubbleListPadding() + { + // Gutter is symmetric horizontal padding around the timeline column. + var g = ChatExplorationState.Gutter; + return new Thickness(g, 0, g, 0); + } + + public static double MessageGap() => ChatExplorationState.MessageGap; + + public static Thickness BubbleInnerPadding() => ChatExplorationState.PaddingDensity switch + { + ChatPaddingDensity.Cozy => new Thickness(16, 12, 16, 12), + ChatPaddingDensity.Compact => new Thickness(10, 6, 10, 6), + _ /* Comfortable */ => new Thickness(14, 8, 14, 8), + }; + + public static bool ShowTimestamps() => ChatExplorationState.ShowTimestamps; + + // ── Avatar (D) ──────────────────────────────────────────────────── + + public static bool ShowUserAvatar() => ChatExplorationState.ShowAvatars + && ChatExplorationState.AvatarMode == ChatAvatarMode.Both; + + public static bool ShowAssistantAvatar() => ChatExplorationState.ShowAvatars + && ChatExplorationState.AvatarMode != ChatAvatarMode.None; + + // ── Composer (E) ────────────────────────────────────────────────── + + public static CornerRadius ComposerCornerRadius() + => new(ChatExplorationState.ComposerCornerRadius); + + public static double ComposerIconSize() => ChatExplorationState.ComposerIconSize; + + public static double SendButtonSize() => ChatExplorationState.SendButtonSize; + + // ── Brush overrides (F). Resolve to override-or-fallback. ───────── + + /// Returns the override brush if set, else . + public static Brush AccentBrush(Brush fallback) + => ChatExplorationState.AccentBrushOverride ?? fallback; + + public static Brush UserBubbleBrush(Brush fallback) + => ChatExplorationState.UserBubbleBrushOverride ?? fallback; + + public static Brush AssistantBubbleBrush(Brush fallback) + => ChatExplorationState.AssistantBubbleBrushOverride ?? fallback; + + public static Brush SendButtonBrush(Brush fallback) + => ChatExplorationState.SendButtonBrushOverride ?? fallback; + + // ── v2 additions ───────────────────────────────────────────────── + + public static bool ShowAssistantBubbles() => ChatExplorationState.ShowAssistantBubbles; + public static bool ShowToolCalls() => ChatExplorationState.ShowToolCalls; + public static double BubbleMaxWidth() => ChatExplorationState.BubbleMaxWidth; + public static double BubbleSideMargin() => ChatExplorationState.BubbleSideMargin; + + /// + /// Build a chat footer text from the live toggles. , + /// , , + /// are shown only when their corresponding toggle is on. + /// is shown when is on. + /// Returns an empty string when nothing should be shown — callers can + /// short-circuit rendering the whole footer in that case. + /// + public static string BuildFooterText(string? sender, string? time, string? model, string? tokens, string? contextPct) + { + var parts = new System.Collections.Generic.List(5); + if (ChatExplorationState.ShowSenderName && !string.IsNullOrEmpty(sender)) parts.Add(sender!); + if (ChatExplorationState.ShowTimestamps && !string.IsNullOrEmpty(time)) parts.Add(time!); + if (ChatExplorationState.ShowModelName && !string.IsNullOrEmpty(model)) parts.Add(model!); + if (ChatExplorationState.ShowTokens && !string.IsNullOrEmpty(tokens)) parts.Add(tokens!); + if (ChatExplorationState.ShowContextPercent && !string.IsNullOrEmpty(contextPct)) parts.Add(contextPct!); + return string.Join(" · ", parts); + } + + // ── Theme (A) ──────────────────────────────────────────────────── + + public static ElementTheme ResolvePreviewTheme() => ChatExplorationState.PreviewTheme switch + { + ChatPreviewTheme.Light => ElementTheme.Light, + ChatPreviewTheme.Dark => ElementTheme.Dark, + _ => ElementTheme.Default, + }; +} diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs new file mode 100644 index 000000000..13b9b00f9 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/FakeChatDataProvider.cs @@ -0,0 +1,123 @@ +using OpenClaw.Chat; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Chat.Explorations; + +/// +/// 가짜 IChatDataProvider — ChatExplorationsWindow 의 라이브 프리뷰용. 실제 백엔드 없이 +/// user / assistant / tool 데모 메시지를 미리 채워서 mount 하면 바로 버블, 아바타, +/// 툴 카드, 어시스턴트 카드, footer 가 그려진다. +/// SendMessageAsync 는 user 메시지를 추가하고 짧은 fake assistant reply 를 붙인다. +/// +public sealed class FakeChatDataProvider : IChatDataProvider +{ + private const string ThreadId = "demo-thread"; + private static readonly string[] Models = ["gpt-5.5", "gpt-5.4", "claude-opus-4.7"]; + + private ChatTimelineState _timeline; + private int _nextId = 100; + + public string DisplayName => "Demo (preview)"; + + public event EventHandler? Changed; + public event EventHandler? NotificationRequested; + + public FakeChatDataProvider() + { + var entries = new List + { + new("d1", ChatTimelineItemKind.User, "Hi! Show me how the chat looks with all the toggles applied."), + new("d2", ChatTimelineItemKind.Assistant, "Hi there! This is an assistant bubble. **Markdown** is supported, and long lines wrap automatically inside the bubble so you can see how the layout breathes."), + // d3 / d3b / d3c form a 3-entry tool burst to exercise burst grouping + // (single trailing "Tool ·