From 9e6f1caa76ad31c1a46916d188bb4c74928a8a67 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 16:54:00 +0100 Subject: [PATCH 1/5] fix(ci): migrate release signing to Azure Artifact Signing Migrate release signing to Azure Artifact Signing with OIDC and the openclaw signing profile. --- .github/workflows/ci.yml | 107 +++++++----------- docs/RELEASING.md | 39 +++++-- scripts/Test-ReleaseExecutableSignatures.ps1 | 2 +- src/OpenClaw.Tray.WinUI/Package.appxmanifest | 8 +- .../ReleaseSigningWorkflowTests.cs | 22 ++-- 5 files changed, 90 insertions(+), 88 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89d75817a..8278e37f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,51 +408,6 @@ jobs: throw "ProductVersion '$actual' did not match GitVersion SemVer '$expected'." } - - 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 - with: - creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - - - name: Stage OpenClaw Executables for Signing - if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - shell: pwsh - run: | - New-Item -ItemType Directory -Path signing-input -Force | Out-Null - New-Item -ItemType HardLink -Path signing-input\OpenClaw.Tray.WinUI.exe -Target publish\OpenClaw.Tray.WinUI.exe | Out-Null - New-Item -ItemType HardLink -Path signing-input\OpenClaw.SetupEngine.exe -Target publish\SetupEngine\OpenClaw.SetupEngine.exe | Out-Null - New-Item -ItemType HardLink -Path signing-input\OpenClaw.SetupEngine.UI.exe -Target publish\SetupEngine\OpenClaw.SetupEngine.UI.exe | Out-Null - - - name: Sign OpenClaw Executables - if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - uses: azure/trusted-signing-action@v2 - 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: signing-input - files-folder-filter: exe - files-folder-depth: 1 - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - - name: Verify Release Executable Signing Policy - if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64' - shell: pwsh - run: .\scripts\Test-ReleaseExecutableSignatures.ps1 -PayloadPath publish -RequireSignedOpenClaw - - name: Upload Tray Artifact uses: actions/upload-artifact@v7 with: @@ -558,9 +513,11 @@ jobs: needs: [repo-hygiene, test, e2etests, build] if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && !cancelled() runs-on: windows-latest + environment: release-signing permissions: actions: read contents: write + id-token: write steps: - uses: actions/checkout@v6 @@ -588,7 +545,17 @@ jobs: - 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 }}"}' + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Stage x64 OpenClaw Executables for Signing + shell: pwsh + run: | + New-Item -ItemType Directory -Path signing-input-x64 -Force | Out-Null + New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-x64\OpenClaw.Tray.WinUI.exe | Out-Null + New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.SetupEngine.exe -Target artifacts\tray-win-x64\SetupEngine\OpenClaw.SetupEngine.exe | Out-Null + New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.SetupEngine.UI.exe -Target artifacts\tray-win-x64\SetupEngine\OpenClaw.SetupEngine.UI.exe | Out-Null - name: Stage ARM64 OpenClaw Executables for Signing shell: pwsh @@ -598,15 +565,25 @@ jobs: New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.SetupEngine.exe -Target artifacts\tray-win-arm64\SetupEngine\OpenClaw.SetupEngine.exe | Out-Null New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.SetupEngine.UI.exe -Target artifacts\tray-win-arm64\SetupEngine\OpenClaw.SetupEngine.UI.exe | Out-Null + - name: Sign x64 OpenClaw Executables + uses: azure/artifact-signing-action@v2 + with: + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw + files-folder: signing-input-x64 + files-folder-filter: exe + files-folder-depth: 1 + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + - name: Sign ARM64 OpenClaw Executables - uses: azure/trusted-signing-action@v2 + uses: azure/artifact-signing-action@v2 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 + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw files-folder: signing-input-arm64 files-folder-filter: exe files-folder-depth: 1 @@ -614,6 +591,10 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Verify x64 Release Executable Signing Policy + shell: pwsh + run: .\scripts\Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-x64 -RequireSignedOpenClaw + - name: Verify ARM64 Release Executable Signing Policy shell: pwsh run: .\scripts\Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireSignedOpenClaw @@ -644,20 +625,12 @@ jobs: # Build installer & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=arm64 /Dpublish=publish-arm64 installer.iss - - name: Azure Login for 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 Installer - uses: azure/trusted-signing-action@v2 + - name: Sign Installers + uses: azure/artifact-signing-action@v2 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 + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw files-folder: Output files-folder-filter: exe file-digest: SHA256 @@ -687,7 +660,7 @@ jobs: ### Features - 🦞 System tray integration with gateway status - πŸ”„ Auto-updates from GitHub Releases - - βœ… Code-signed with Azure Trusted Signing + - βœ… Code-signed with Azure Artifact Signing ### Requirements - Windows 10 version 1903 or later diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 22eb3c124..c715dba16 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -100,12 +100,32 @@ CI enforces this with `scripts\Test-ReleaseExecutableSignatures.ps1`. The verifier fails closed on unknown `.exe` files so future payload changes are reviewed deliberately. +The current Azure Artifact Signing resource is: + +- Account: `openclaw` +- Certificate profile: `openclaw` +- Endpoint: `https://eus.codesigning.azure.net/` +- Public trust certificate subject: + `CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US` + +GitHub Actions authenticates with Azure through OIDC, not a stored client +secret. The release job runs in the `release-signing` environment and requires: + +- `AZURE_CLIENT_ID` +- `AZURE_TENANT_ID` +- `AZURE_SUBSCRIPTION_ID` + +Do not add `AZURE_CLIENT_SECRET` back to the release workflow. The Entra app +registration should have a federated credential for: +`repo:openclaw/openclaw-windows-node:environment:release-signing`. + ## How CI signs payload executables The release workflow does not recursively sign every `.exe`. Instead it creates -a temporary signing input directory with hardlinks to only the OpenClaw-owned -executables, then runs Azure Trusted Signing on that allowlist. Because these -are NTFS hardlinks, signing the staged file signs the real payload file. +temporary signing input directories with hardlinks to only the OpenClaw-owned +executables from the x64 and ARM64 payloads, then runs Azure Artifact Signing on +those allowlists. Because these are NTFS hardlinks, signing the staged file +signs the real payload file. After signing, CI verifies the actual payload directory, not the staging folder. If hardlink signing does not affect the payload, the verifier fails before @@ -127,12 +147,13 @@ MSIX jobs may appear as skipped while MSIX is paused. The release job should: 1. Download x64/ARM64 tray payload artifacts. -2. Sign only the OpenClaw-owned EXEs. -3. Verify executable signing policy. -4. Create portable ZIPs. -5. Build Inno installers. -6. Sign installers. -7. Create a GitHub prerelease with installer and ZIP assets only. +2. Authenticate to Azure with OIDC in the `release-signing` environment. +3. Sign only the OpenClaw-owned EXEs in both payloads. +4. Verify executable signing policy. +5. Create portable ZIPs. +6. Build Inno installers. +7. Sign installers. +8. Create a GitHub prerelease with installer and ZIP assets only. ## Post-release verification diff --git a/scripts/Test-ReleaseExecutableSignatures.ps1 b/scripts/Test-ReleaseExecutableSignatures.ps1 index 6e13d0867..a2da6594c 100644 --- a/scripts/Test-ReleaseExecutableSignatures.ps1 +++ b/scripts/Test-ReleaseExecutableSignatures.ps1 @@ -24,7 +24,7 @@ param( [switch]$RequireSignedOpenClaw, - [string]$OpenClawSignerPattern = "hanselman|OpenClaw|Scott Hanselman" + [string]$OpenClawSignerPattern = "OpenClaw Foundation" ) Set-StrictMode -Version Latest diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index 7016ca5c7..af0301e44 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -6,20 +6,20 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap rescap"> - OpenClaw Companion - Scott Hanselman + OpenClaw Foundation Assets\StoreLogo.png diff --git a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs index 0d37f971b..b953bb25c 100644 --- a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs +++ b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs @@ -7,12 +7,20 @@ public void ReleaseWorkflow_SignsOnlyOpenClawOwnedPayloadExecutables() { var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - Assert.Contains("Stage OpenClaw Executables for Signing", workflow); - Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input\OpenClaw.Tray.WinUI.exe -Target publish\OpenClaw.Tray.WinUI.exe", workflow); - Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input\OpenClaw.SetupEngine.exe -Target publish\SetupEngine\OpenClaw.SetupEngine.exe", workflow); - Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input\OpenClaw.SetupEngine.UI.exe -Target publish\SetupEngine\OpenClaw.SetupEngine.UI.exe", workflow); - Assert.Contains("Sign OpenClaw Executables", workflow); - Assert.Contains("files-folder: signing-input", workflow); + Assert.DoesNotContain("azure/trusted-signing-action", workflow); + Assert.DoesNotContain("AZURE_CLIENT_SECRET", workflow); + Assert.Contains("environment: release-signing", workflow); + Assert.Contains("id-token: write", workflow); + Assert.Contains("uses: azure/artifact-signing-action@v2", workflow); + Assert.Contains("endpoint: https://eus.codesigning.azure.net/", workflow); + Assert.Contains("signing-account-name: openclaw", workflow); + Assert.Contains("certificate-profile-name: openclaw", workflow); + Assert.Contains("Stage x64 OpenClaw Executables for Signing", workflow); + Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-x64\OpenClaw.Tray.WinUI.exe", workflow); + Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.SetupEngine.exe -Target artifacts\tray-win-x64\SetupEngine\OpenClaw.SetupEngine.exe", workflow); + Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.SetupEngine.UI.exe -Target artifacts\tray-win-x64\SetupEngine\OpenClaw.SetupEngine.UI.exe", workflow); + Assert.Contains("Sign x64 OpenClaw Executables", workflow); + Assert.Contains("files-folder: signing-input-x64", workflow); Assert.Contains("Stage ARM64 OpenClaw Executables for Signing", workflow); Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-arm64\OpenClaw.Tray.WinUI.exe", workflow); Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.SetupEngine.exe -Target artifacts\tray-win-arm64\SetupEngine\OpenClaw.SetupEngine.exe", workflow); @@ -29,7 +37,7 @@ public void ReleaseWorkflow_VerifiesExecutableSigningPolicy() var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); var verifier = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "scripts", "Test-ReleaseExecutableSignatures.ps1")); - Assert.Contains("Test-ReleaseExecutableSignatures.ps1 -PayloadPath publish -RequireSignedOpenClaw", workflow); + Assert.Contains("Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-x64 -RequireSignedOpenClaw", workflow); Assert.Contains("Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireSignedOpenClaw", workflow); Assert.Contains(@"^OpenClaw\.Tray\.WinUI\.exe$", verifier); Assert.Contains(@"^SetupEngine\\OpenClaw\.SetupEngine\.exe$", verifier); From f6a2d5d18f5c36f082d0b3de32d88820297a39f6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 19:28:06 +0100 Subject: [PATCH 2/5] chore(agents): add autoreview skill --- .agents/skills/autoreview/SKILL.md | 182 +++ .agents/skills/autoreview/scripts/autoreview | 1371 +++++++++++++++++ .../autoreview/scripts/test-review-harness | 176 +++ 3 files changed, 1729 insertions(+) create mode 100644 .agents/skills/autoreview/SKILL.md create mode 100755 .agents/skills/autoreview/scripts/autoreview create mode 100755 .agents/skills/autoreview/scripts/test-review-harness diff --git a/.agents/skills/autoreview/SKILL.md b/.agents/skills/autoreview/SKILL.md new file mode 100644 index 000000000..e4c9f5dc2 --- /dev/null +++ b/.agents/skills/autoreview/SKILL.md @@ -0,0 +1,182 @@ +--- +name: autoreview +description: "Auto Review closeout. Codex review is the default when no engine is set and is the recommended reviewer." +--- + +# Auto Review + +Run the bundled structured review helper as a closeout check. This is code review, not Guardian `auto_review` approval routing. + +Codex review is the default when no engine is set. It usually delivers the best review results and should remain the normal final closeout engine. + +Use when: + +- user asks for Codex review / Claude review / autoreview / second-model review +- after non-trivial code edits, before final/commit/ship +- reviewing a local branch or PR branch after fixes + +OpenClaw Windows Node specifics: + +- The release/default branch is `master`; the helper resolves `origin/HEAD` so do not hardcode `origin/main`. +- After review-triggered code changes, run the repo-required validation: `./build.ps1`, `dotnet test ./tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj --no-restore`, and `dotnet test ./tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj --no-restore`. +- If the checkout is on macOS/Linux or lacks .NET/PowerShell/Windows SDK prerequisites, report the validation blocker clearly instead of pretending the Windows validation ran. +- Do not send absolute local checkout paths, home-directory names, or private temp/worktree paths to review engines; use repo-relative labels or the repo name. + +## Contract + +- Treat review output as advisory. Never blindly apply it. +- Verify every finding by reading the real code path and adjacent files. +- Read dependency docs/source/types when the finding depends on external behavior. +- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase. +- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class. +- Keep going until structured review returns no accepted/actionable findings. +- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper. +- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk. +- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model. +- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search. +- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through. +- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish. +- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior. +- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check. +- For regression provenance, if no blamed PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding. +- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops. +- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording. +- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse. +- Multi-reviewer panels are opt-in only. Use them when explicitly requested or when risk justifies the extra spend; the main agent still verifies every accepted finding before fixing. +- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know. +- If `gh`/Gitcrawl reports `database disk image is malformed`, run `gitcrawl doctor --json` once to let the portable cache repair before retrying review; do not bypass the shim unless repair fails and freshness requires live GitHub. +- If Gitcrawl reports a portable manifest mismatch, source/runtime DB health error, or stale portable-store checkout, run `gitcrawl doctor --json` and inspect `source_db_health`, `runtime_db_health`, and `portable_store_status` before falling back to live GitHub. +- Do not push just to review. Push only when the user requested push/ship/PR update. + +## Pick Target + +Dirty local work: + +```bash + --mode local +``` + +Use this only when the patch is actually unstaged/staged/untracked in the +current checkout. `--mode uncommitted` is accepted as an alias for `--mode local`. +For committed, pushed, or PR work, point the helper at the commit +or branch diff instead; do not force dirty modes just +because the helper docs mention dirty work first. A clean local review +only proves there is no local patch. + +Branch/PR work: + +```bash + --mode branch --base origin/master +``` + +Optional review context is first-class: + +```bash + --mode branch --base origin/master --prompt-file /tmp/review-notes.md --dataset /tmp/evidence.json +``` + +If an open PR exists, use its actual base: + +```bash +base=$(gh pr view --json baseRefName --jq .baseRefName) + --mode branch --base "origin/$base" +``` + +Committed single change: + +```bash + --mode commit --commit HEAD +``` + +or with the helper: + +Use commit review for already-landed or already-pushed work on the default +branch. Reviewing clean `master` against `origin/master` is usually an empty +diff after push. For a small stack, review each commit explicitly or review the +branch before merging with `--base`. + +## Parallel Closeout + +Format first if formatting can change line locations. Then it is OK to run tests and review in parallel: + +```bash +scripts/autoreview --parallel-tests "" +``` + +Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation. + +## Review Panels + +Run multiple reviewers against one frozen bundle: + +```bash + --reviewers codex,claude +``` + +`--panel` is shorthand for Codex plus Claude unless `--engine` changes the first reviewer: + +```bash + --panel +``` + +Set reviewer models and thinking/effort explicitly: + +```bash + --reviewers codex,claude --model codex=gpt-5.1 --thinking codex=high --model claude=sonnet --thinking claude=max +``` + +Inline syntax is also supported: + +```bash + --reviewers codex:gpt-5.1:high,claude:sonnet:max +``` + +Codex maps thinking to `model_reasoning_effort` and accepts `low`, `medium`, +`high`, or `xhigh`. Claude maps thinking to `--effort` and also accepts `max`. +Engines without a real thinking knob reject `--thinking`. + +## Context Efficiency + +Run the helper directly so target selection, engine choice, structured validation, and exit status all stay in one path. If output is noisy, summarize the completed helper output after it returns; do not ask another agent or reviewer to rerun the review. + +## Helper + +OpenClaw Windows Node repo-local helper: + +```bash +.agents/skills/autoreview/scripts/autoreview --help +``` + +The helper: + +- chooses dirty local changes first +- accepts `--mode uncommitted` as an alias for `--mode local` +- otherwise uses current PR base if `gh pr view` works +- otherwise uses the remote default branch for non-default branches +- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set +- use `--mode commit --commit ` for already-committed work, especially clean default-branch work after landing +- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing +- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set +- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs +- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion +- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking` +- allows read-only tools and web search by default only for sandboxed Codex review; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output +- disables non-Codex unsandboxed reviewer tools by default; set `AUTOREVIEW_ALLOW_UNSANDBOXED_TOOLS=1` only when that tradeoff is intentional +- invokes reviewer engines from sanitized temporary workspaces so local home/worktree paths are not exposed; set `AUTOREVIEW_SAFE_TMP` if the default neutral temp root is unavailable +- writes Codex sidecar files and the full change bundle inside the sanitized workspace as `schema.json`, `output.json`, `CHANGE_BUNDLE.md`, or `prompt.txt` for read-only inspection +- redacts common local home-directory path shapes from review bundles, omits symlink contents from text bundles, and omits raw symlink patch bodies +- caps patch, untracked-file, and local-bundle text with explicit truncation markers so dirty checkouts cannot silently create unbounded prompts +- prints `review still running: elapsed=s pid=` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently +- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0 +- exits nonzero when accepted/actionable findings are present + +## Final Report + +Include: + +- review command used +- tests/proof run +- findings accepted/rejected, briefly why +- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected + +Do not run another review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean. diff --git a/.agents/skills/autoreview/scripts/autoreview b/.agents/skills/autoreview/scripts/autoreview new file mode 100755 index 000000000..cb9ad014e --- /dev/null +++ b/.agents/skills/autoreview/scripts/autoreview @@ -0,0 +1,1371 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import concurrent.futures +import copy +import json +import os +import queue +import re +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +from pathlib import Path +from typing import Any, Callable + + +ENGINES = ("codex", "claude", "droid", "copilot") +THINKING_LEVELS_BY_ENGINE = { + "codex": {"low", "medium", "high", "xhigh"}, + "claude": {"low", "medium", "high", "xhigh", "max"}, + "droid": set(), + "copilot": set(), +} +PATCH_LIMIT = 180_000 +UNTRACKED_FILE_LIMIT = 180_000 +BUNDLE_LIMIT = 500_000 +MIN_UNTRACKED_BUDGET = 1_000 +PRIVATE_PATH_PATTERNS = ( + re.compile(r"/" r"Users/" r"[^\r\n'\"`<>]+"), + re.compile(r"/" r"home/" r"[^\r\n'\"`<>]+"), + re.compile(r"[A-Za-z]:[\\/]+Users[\\/]+[^\r\n'\"`<>]+", re.IGNORECASE), +) + + +SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "required": [ + "findings", + "overall_correctness", + "overall_explanation", + "overall_confidence", + ], + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "required": [ + "title", + "body", + "priority", + "confidence", + "category", + "code_location", + ], + "properties": { + "title": {"type": "string", "minLength": 1, "maxLength": 140}, + "body": {"type": "string", "minLength": 1, "maxLength": 2000}, + "priority": {"type": "string", "enum": ["P0", "P1", "P2", "P3"]}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "category": { + "type": "string", + "enum": ["bug", "security", "regression", "test_gap", "maintainability"], + }, + "code_location": { + "type": "object", + "additionalProperties": False, + "required": ["file_path", "line"], + "properties": { + "file_path": {"type": "string", "minLength": 1}, + "line": {"type": "integer", "minimum": 1}, + }, + }, + }, + }, + }, + "overall_correctness": { + "type": "string", + "enum": ["patch is correct", "patch is incorrect"], + }, + "overall_explanation": {"type": "string", "minLength": 1, "maxLength": 3000}, + "overall_confidence": {"type": "number", "minimum": 0, "maximum": 1}, + }, +} + + +def run(args: list[str], cwd: Path, *, input_text: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + cwd=cwd, + input=input_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + cmd = " ".join(args) + raise SystemExit(f"command failed ({result.returncode}): {cmd}\n{result.stderr or result.stdout}") + return result + + +def run_with_heartbeat( + args: list[str], + cwd: Path, + *, + input_text: str | None = None, + label: str, + heartbeat_seconds: int = 60, + stream_output: bool = False, + stream_display: Callable[[str, str], str | None] | None = None, +) -> subprocess.CompletedProcess[str]: + if stream_output: + return run_with_stream( + args, + cwd, + input_text=input_text, + label=label, + heartbeat_seconds=heartbeat_seconds, + stream_display=stream_display, + ) + started = time.monotonic() + proc = subprocess.Popen( + args, + cwd=cwd, + stdin=subprocess.PIPE if input_text is not None else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + first_communicate = True + while True: + try: + stdout, stderr = proc.communicate( + input=input_text if first_communicate else None, + timeout=heartbeat_seconds, + ) + return subprocess.CompletedProcess(args, int(proc.returncode or 0), stdout, stderr) + except subprocess.TimeoutExpired: + first_communicate = False + elapsed = int(time.monotonic() - started) + print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True) + + +def run_with_stream( + args: list[str], + cwd: Path, + *, + input_text: str | None, + label: str, + heartbeat_seconds: int, + stream_display: Callable[[str, str], str | None] | None, +) -> subprocess.CompletedProcess[str]: + started = time.monotonic() + proc = subprocess.Popen( + args, + cwd=cwd, + stdin=subprocess.PIPE if input_text is not None else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + events: queue.Queue[tuple[str, str | None]] = queue.Queue() + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + + def read_stream(name: str, stream: Any) -> None: + try: + for line in iter(stream.readline, ""): + events.put((name, line)) + finally: + events.put((name, None)) + + def write_stdin() -> None: + if proc.stdin is None or input_text is None: + return + try: + proc.stdin.write(input_text) + proc.stdin.close() + except BrokenPipeError: + return + + threads = [ + threading.Thread(target=read_stream, args=("stdout", proc.stdout), daemon=True), + threading.Thread(target=read_stream, args=("stderr", proc.stderr), daemon=True), + ] + for thread in threads: + thread.start() + stdin_thread = threading.Thread(target=write_stdin, daemon=True) + stdin_thread.start() + + open_streams = 2 + while open_streams: + try: + name, line = events.get(timeout=heartbeat_seconds) + except queue.Empty: + elapsed = int(time.monotonic() - started) + print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True) + continue + if line is None: + open_streams -= 1 + continue + if name == "stdout": + stdout_parts.append(line) + else: + stderr_parts.append(line) + display = stream_display(name, line) if stream_display else line + if display: + target = sys.stdout if name == "stdout" else sys.stderr + target.write(display) + target.flush() + + for thread in threads: + thread.join() + stdin_thread.join(timeout=1) + returncode = proc.wait() + return subprocess.CompletedProcess(args, returncode, "".join(stdout_parts), "".join(stderr_parts)) + + +def git(repo: Path, *args: str, check: bool = True) -> str: + return run(["git", *args], repo, check=check).stdout + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise SystemExit("autoreview must run inside a git repository") + return Path(result.stdout.strip()).resolve() + + +def current_branch(repo: Path) -> str: + return git(repo, "branch", "--show-current", check=False).strip() or "detached" + + +def default_branch(repo: Path) -> str: + remote_head = git( + repo, + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + check=False, + ).strip() + if remote_head.startswith("origin/"): + return remote_head.removeprefix("origin/") + for candidate in ("master", "main"): + result = run(["git", "rev-parse", "--verify", f"origin/{candidate}"], repo, check=False) + if result.returncode == 0: + return candidate + return "master" + + +def default_base_ref(repo: Path) -> str: + return f"origin/{default_branch(repo)}" + + +def is_dirty(repo: Path) -> bool: + return bool(git(repo, "status", "--porcelain").strip()) + + +def choose_target(repo: Path, mode: str, base_ref: str | None) -> tuple[str, str | None]: + mode = "local" if mode == "uncommitted" else mode + branch = current_branch(repo) + default = default_branch(repo) + if mode == "local" or (mode == "auto" and is_dirty(repo)): + return "local", None + if mode == "commit": + return "commit", None + if mode == "branch" or (mode == "auto" and branch != default): + return "branch", base_ref or detect_pr_base(repo) or default_base_ref(repo) + raise SystemExit(f"no review target: clean {default} checkout and no forced mode") + + +def detect_pr_base(repo: Path) -> str | None: + if not shutil_which("gh"): + return None + result = run(["gh", "pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], repo, check=False) + base = result.stdout.strip() + return f"origin/{base}" if result.returncode == 0 and base else None + + +def shutil_which(name: str) -> str | None: + for part in os.environ.get("PATH", "").split(os.pathsep): + candidate = Path(part) / name + if candidate.exists() and os.access(candidate, os.X_OK): + return str(candidate) + return None + + +def bounded(text: str, limit: int = 180_000) -> str: + if len(text) <= limit: + return text + return text[:limit] + f"\n\n[truncated at {limit} characters]\n" + + +def redact_private_paths(text: str) -> str: + redacted = text + for pattern in PRIVATE_PATH_PATTERNS: + redacted = pattern.sub("[redacted-local-path]", redacted) + return redacted + + +def sanitize_git_patch(patch: str) -> str: + blocks: list[list[str]] = [] + current: list[str] = [] + for line in patch.splitlines(keepends=True): + if line.startswith("diff --git ") and current: + blocks.append(current) + current = [] + current.append(line) + if current: + blocks.append(current) + + sanitized: list[str] = [] + for block in blocks: + sanitized.extend(sanitize_symlink_diff_block(block)) + return redact_private_paths("".join(sanitized)) + + +def sanitize_symlink_diff_block(block: list[str]) -> list[str]: + old_symlink = any(line.startswith(("deleted file mode 120000", "old mode 120000")) for line in block) + new_symlink = any(line.startswith(("new file mode 120000", "new mode 120000")) for line in block) + mode_line_seen = any(is_symlink_mode_line(line) for line in block) + if is_symlink_index_only_block(block, mode_line_seen): + old_symlink = True + new_symlink = True + if not old_symlink and not new_symlink: + return block + + result: list[str] = [] + omitted_old = False + omitted_new = False + in_hunk = False + for line in block: + if line.startswith("@@"): + in_hunk = True + result.append(line) + continue + if in_hunk and old_symlink and line.startswith("-") and not line.startswith("--- "): + if not omitted_old: + result.append("-[symlink target omitted]\n") + omitted_old = True + continue + if in_hunk and new_symlink and line.startswith("+") and not line.startswith("+++ "): + if not omitted_new: + result.append("+[symlink target omitted]\n") + omitted_new = True + continue + if in_hunk and old_symlink and new_symlink and line.startswith(" "): + continue + result.append(line) + return result + + +def is_symlink_index_only_block(block: list[str], mode_line_seen: bool) -> bool: + return not mode_line_seen and any(is_symlink_index_line(line) for line in block) + + +def is_symlink_index_line(line: str) -> bool: + if not line.startswith("index "): + return False + parts = line.split() + return len(parts) >= 3 and parts[-1] == "120000" + + +def is_symlink_mode_line(line: str) -> bool: + prefixes = ("new file mode ", "deleted file mode ", "old mode ", "new mode ") + return line.startswith(prefixes) and line.strip().endswith("120000") + + +def bounded_field(text: str, limit: int) -> str: + if len(text) <= limit: + return text + suffix = "\n\n[truncated]" + return text[: max(0, limit - len(suffix))] + suffix + + +def read_text(path: Path, limit: int | None = 40_000, display_path: str | None = None) -> str: + label = display_path or path.name + if path.is_symlink(): + return f"[symlink omitted: {label}]" + try: + data = path.read_bytes() + except OSError: + return f"[unreadable: {label}]" + if b"\0" in data: + return "[binary file omitted]" + text = redact_private_paths(data.decode("utf-8", errors="replace")) + if limit is None: + return text + return bounded(text, limit) + + +def local_bundle(repo: Path) -> str: + parts = [ + "# Git Status", + redact_private_paths(git(repo, "status", "--short")), + "# Staged Diff", + redact_private_paths(git(repo, "diff", "--cached", "--stat")), + bounded(sanitize_git_patch(git(repo, "diff", "--cached", "--patch", "--find-renames")), PATCH_LIMIT), + "# Unstaged Diff", + redact_private_paths(git(repo, "diff", "--stat")), + bounded(sanitize_git_patch(git(repo, "diff", "--patch", "--find-renames")), PATCH_LIMIT), + ] + untracked = [line for line in git(repo, "ls-files", "--others", "--exclude-standard").splitlines() if line] + if untracked: + parts.append("# Untracked Files") + for index, rel in enumerate(untracked): + path = repo / rel + try: + size = path.lstat().st_size + except OSError: + size = 0 + if not path.is_symlink() and size > UNTRACKED_FILE_LIMIT: + raise SystemExit( + f"untracked file exceeds autoreview per-file bundle cap ({UNTRACKED_FILE_LIMIT} bytes): {rel}; " + "stage/commit the change or reduce the file before review" + ) + remaining = BUNDLE_LIMIT - len("\n\n".join(parts)) + if remaining < MIN_UNTRACKED_BUDGET: + omitted = len(untracked) - index + raise SystemExit( + f"local untracked bundle exceeds autoreview cap ({BUNDLE_LIMIT} bytes); " + f"{omitted} untracked file(s) were not bundled. Stage/commit or reduce untracked files before review." + ) + entry = f"## {rel}\n{read_text(path, limit=UNTRACKED_FILE_LIMIT, display_path=rel)}" + projected = len("\n\n".join(parts)) + 2 + len(entry) + if projected > BUNDLE_LIMIT: + omitted = len(untracked) - index + raise SystemExit( + f"local untracked bundle exceeds autoreview cap ({BUNDLE_LIMIT} bytes); " + f"{omitted} untracked file(s) were not bundled. Stage/commit or reduce untracked files before review." + ) + parts.append(entry) + bundle = "\n\n".join(parts) + if len(bundle) > BUNDLE_LIMIT: + raise SystemExit( + f"local bundle exceeds autoreview cap ({BUNDLE_LIMIT} bytes). " + "Review a smaller patch, stage/commit the change, or use branch/commit mode." + ) + return bundle + + +def branch_bundle(repo: Path, base_ref: str) -> str: + git(repo, "fetch", "origin", "--quiet", check=False) + return "\n\n".join( + [ + "# Branch Diff", + f"base: {base_ref}", + redact_private_paths(git(repo, "diff", "--stat", f"{base_ref}...HEAD")), + bounded(sanitize_git_patch(git(repo, "diff", "--patch", "--find-renames", f"{base_ref}...HEAD")), PATCH_LIMIT), + ] + ) + + +def commit_bundle(repo: Path, commit_ref: str) -> str: + return "\n\n".join( + [ + "# Commit Diff", + f"commit: {commit_ref}", + redact_private_paths(git(repo, "show", "--stat", "--format=commit %H", commit_ref)), + bounded(sanitize_git_patch(git(repo, "show", "--patch", "--find-renames", "--format=", commit_ref)), PATCH_LIMIT), + ] + ) + + +def review_paths(repo: Path, target: str, target_ref: str | None, commit_ref: str) -> set[str]: + names: set[str] = set() + if target == "local": + sources = [ + git(repo, "diff", "--name-only", "--cached"), + git(repo, "diff", "--name-only"), + git(repo, "ls-files", "--others", "--exclude-standard"), + ] + elif target == "branch": + assert target_ref + sources = [git(repo, "diff", "--name-only", f"{target_ref}...HEAD")] + else: + sources = [git(repo, "show", "--name-only", "--format=", commit_ref)] + for source in sources: + for line in source.splitlines(): + path = line.strip() + if path: + names.add(path) + return names + + +def load_extra_prompt(args: argparse.Namespace) -> str: + chunks: list[str] = [] + for value in args.prompt or []: + chunks.append(redact_private_paths(value)) + for path in args.prompt_file or []: + chunks.append(redact_private_paths(Path(path).read_text())) + return "\n\n".join(chunks) + + +def safe_display_path(path: Path, repo: Path) -> str: + resolved = path.expanduser().resolve() + try: + return str(resolved.relative_to(repo)) + except ValueError: + return resolved.name + + +def load_datasets(args: argparse.Namespace, repo: Path) -> str: + chunks: list[str] = [] + for spec in args.dataset or []: + path = Path(spec) + if path.is_dir(): + raise SystemExit(f"--dataset must be a file, got directory: {path}") + label = safe_display_path(path, repo) + chunks.append(f"# Dataset: {label}\n{read_text(path, display_path=label)}") + return "\n\n".join(chunks) + + +def build_prompt(repo: Path, target: str, target_ref: str | None, bundle: str, extra_prompt: str, datasets: str) -> str: + target_line = f"{target} {target_ref}" if target_ref else target + return textwrap.dedent( + f""" + You are a senior code reviewer. Review the provided git change bundle only. + + Hard rules: + - Return exactly one JSON object and nothing else. Do not wrap it in Markdown. + - The JSON object must match this schema exactly: + {json.dumps(SCHEMA, indent=2)} + - Do not modify files. + - Do not invoke nested reviewers or review tools. + - Forbidden nested review commands include: codex review, autoreview, claude review, oracle review. + - You may use read-only tools and web search to inspect files, dependency contracts, upstream docs, current behavior, and security implications. + - Shell commands, if available, must be read-only inspection commands. Do not run tests, formatters, package installs, generators, network mutation commands, git mutation commands, or commands that write files. + - Report only actionable defects introduced or exposed by this change. + - Prefer high-signal findings over style feedback. + - Include security findings: injection, secret leaks, authz/authn bypass, path traversal, unsafe deserialization, unsafe filesystem or shell use, privacy leaks, and credential handling. + - Do not reject legitimate functionality merely because it touches shell, filesystem, network, auth, or sensitive data. Report a security finding only when the patch creates a concrete exploitable risk, removes an important safety check, or lacks validation at a trust boundary. + - For each finding, use the smallest file/line location that demonstrates the issue. + - If there are no actionable findings, return an empty findings array and mark the patch correct. + + Review target: {target_line} + Repository: {repo.name} + + {extra_prompt} + + {datasets} + + # Change Bundle + {bundle} + """ + ).strip() + + +def safe_temp_root() -> Path: + configured = os.environ.get("AUTOREVIEW_SAFE_TMP") + if configured: + root = Path(configured) + elif os.name == "nt": + root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Temp" / "OpenClawAutoreview" + else: + root = Path("/tmp") / "openclaw-autoreview" + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise SystemExit( + "unable to create sanitized autoreview temp root; set AUTOREVIEW_SAFE_TMP " + f"to a non-profile path: {exc.__class__.__name__}" + ) + return root + + +def sanitized_workspace(repo: Path, prefix: str = "autoreview") -> tempfile.TemporaryDirectory[str]: + safe_name = "".join(ch if ch.isalnum() or ch in {".", "-"} else "-" for ch in repo.name) + return tempfile.TemporaryDirectory(prefix=f"{safe_name}.{prefix}.", dir=str(safe_temp_root())) + + +def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str: + if not args.tools: + raise SystemExit("--no-tools is not supported by the Codex engine; use --engine claude --no-tools for a no-tools run") + cmd = [args.codex_bin, "--ask-for-approval", "never"] + if args.web_search: + cmd.append("--search") + if args.model: + cmd.extend(["--model", args.model]) + if args.thinking: + cmd.extend(["-c", f'model_reasoning_effort="{args.thinking}"']) + cmd.append("exec") + if args.stream_engine_output: + cmd.append("--json") + with sanitized_workspace(repo, "codex") as workspace: + review_cwd = Path(workspace) + (review_cwd / "CHANGE_BUNDLE.md").write_text(prompt) + schema_path = review_cwd / "schema.json" + output_path = review_cwd / "output.json" + schema_path.write_text(json.dumps(SCHEMA)) + cmd.extend( + [ + "--ephemeral", + "-C", + str(review_cwd), + "--skip-git-repo-check", + "-s", + "read-only", + "--output-schema", + str(schema_path), + "--output-last-message", + str(output_path), + "-", + ] + ) + result = run_with_heartbeat( + cmd, + review_cwd, + input_text=prompt, + label="codex", + stream_output=args.stream_engine_output, + stream_display=CodexStreamDisplay() if args.stream_engine_output else None, + ) + if result.returncode != 0: + raise SystemExit(f"codex engine failed ({result.returncode})\n{result.stderr or result.stdout}") + output = output_path.read_text() + return output or result.stdout + + +def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str: + cmd = [ + args.claude_bin, + "--print", + "--no-session-persistence", + "--output-format", + "stream-json" if args.stream_engine_output else "json", + "--json-schema", + json.dumps(SCHEMA), + ] + if args.tools and allow_unsandboxed_tools() and claude_allowed_tools(args): + cmd.extend(["--allowedTools", claude_allowed_tools(args)]) + else: + cmd.extend(["--tools", ""]) + if args.stream_engine_output: + cmd.append("--verbose") + if args.model: + cmd.extend(["--model", args.model]) + if args.thinking: + cmd.extend(["--effort", args.thinking]) + with sanitized_workspace(repo, "claude") as workspace: + review_cwd = Path(workspace) + (review_cwd / "CHANGE_BUNDLE.md").write_text(prompt) + result = run_with_heartbeat( + cmd, + review_cwd, + input_text=prompt, + label="claude", + stream_output=args.stream_engine_output, + stream_display=ClaudeStreamDisplay() if args.stream_engine_output else None, + ) + if result.returncode != 0: + raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}") + return result.stdout + + +def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str: + if args.thinking: + raise SystemExit("--thinking is not supported by the droid engine") + with sanitized_workspace(repo, "droid") as workspace: + review_cwd = Path(workspace) + prompt_path = review_cwd / "prompt.txt" + prompt_path.write_text(prompt) + cmd = [ + args.droid_bin, + "exec", + "--cwd", + str(review_cwd), + "--output-format", + "json", + "-f", + str(prompt_path), + ] + if args.model: + cmd.extend(["--model", args.model]) + if not args.tools or not allow_unsandboxed_tools(): + cmd.extend(["--disabled-tools", "*"]) + result = run_with_heartbeat(cmd, review_cwd, label="droid", stream_output=args.stream_engine_output) + if result.returncode != 0: + raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}") + return result.stdout + + +def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str: + if args.thinking: + raise SystemExit("--thinking is not supported by the copilot engine") + if not args.tools: + raise SystemExit("--no-tools is not supported by the copilot engine; copilot requires a read-only file view tool to load the review bundle without exposing it in argv") + if not allow_unsandboxed_tools(): + raise SystemExit("copilot engine requires AUTOREVIEW_ALLOW_UNSANDBOXED_TOOLS=1 because its file tools are not sandboxed by this helper") + with sanitized_workspace(repo, "copilot") as tempdir: + prompt_path = Path(tempdir) / "prompt.txt" + prompt_path.write_text(prompt) + os.chmod(prompt_path, 0o600) + cmd = [ + args.copilot_bin, + "-C", + tempdir, + "-p", + "Read ./prompt.txt and follow it exactly. Return only the requested JSON object.", + "--output-format", + "json", + "--stream", + "on" if args.stream_engine_output else "off", + "--no-ask-user", + "--disable-builtin-mcps", + ] + if args.model: + cmd.extend(["--model", args.model]) + if allow_unsandboxed_tools(): + cmd.extend( + [ + "--available-tools=read_agent,rg,view,web_fetch", + "--allow-tool=read_agent", + "--allow-tool=rg", + "--allow-tool=view", + "--allow-tool=web_fetch", + ] + ) + if args.web_search: + cmd.append("--allow-all-urls") + result = run_with_heartbeat(cmd, Path(tempdir), label="copilot", stream_output=args.stream_engine_output) + if result.returncode != 0: + raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}") + return result.stdout + + +class CodexStreamDisplay: + def __init__(self, *, activity_seconds: int = 20) -> None: + self.activity_seconds = activity_seconds + self.hidden_events = 0 + self.last_visible = time.monotonic() + + def __call__(self, name: str, line: str) -> str | None: + if name != "stdout": + return line + try: + event = json.loads(line) + except json.JSONDecodeError: + return self.visible(line) + event_type = event.get("type") + if event_type == "thread.started": + return self.visible(f"codex thread: {event.get('thread_id', '')}\n") + if event_type == "turn.started": + return self.visible("codex turn started\n") + if event_type == "turn.completed": + usage = event.get("usage") + message = format_codex_usage(usage) + "\n" if isinstance(usage, dict) else "codex turn completed\n" + return self.visible(self.flush_hidden() + message) + item = event.get("item") + if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str): + return self.visible(self.flush_hidden() + item["text"].rstrip() + "\n") + return self.hidden_activity() + + def hidden_activity(self) -> str | None: + self.hidden_events += 1 + if time.monotonic() - self.last_visible < self.activity_seconds: + return None + return self.visible(self.flush_hidden()) + + def flush_hidden(self) -> str: + if not self.hidden_events: + return "" + count = self.hidden_events + self.hidden_events = 0 + return f"codex activity: {count} hidden tool/status events\n" + + def visible(self, text: str) -> str: + self.last_visible = time.monotonic() + return text + + +class ClaudeStreamDisplay: + def __init__(self, *, activity_seconds: int = 20) -> None: + self.activity_seconds = activity_seconds + self.hidden_events = 0 + self.last_visible = time.monotonic() + self.started = False + + def __call__(self, name: str, line: str) -> str | None: + if name != "stdout": + return line + try: + event = json.loads(line) + except json.JSONDecodeError: + return self.visible(line) + event_type = event.get("type") + if event_type == "system" and not self.started: + self.started = True + return self.visible("claude turn started\n") + if event_type == "assistant": + return self.assistant_message(event) + if event_type == "result": + return self.visible(self.flush_hidden() + self.result_summary(event)) + return self.hidden_activity() + + def assistant_message(self, event: dict[str, Any]) -> str | None: + message = event.get("message") + if not isinstance(message, dict): + return self.hidden_activity() + chunks: list[str] = [] + for item in message.get("content", []): + if not isinstance(item, dict): + continue + if item.get("type") == "text" and isinstance(item.get("text"), str): + chunks.append(item["text"].rstrip()) + if chunks: + return self.visible(self.flush_hidden() + "\n".join(chunks) + "\n") + return self.hidden_activity() + + def result_summary(self, event: dict[str, Any]) -> str: + usage = event.get("usage") + fields: list[str] = [] + if isinstance(usage, dict): + for key in ( + "input_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + "output_tokens", + ): + value = usage.get(key) + if isinstance(value, int): + fields.append(f"{key}={value}") + cost = event.get("total_cost_usd") + if isinstance(cost, (int, float)) and not isinstance(cost, bool): + fields.append(f"cost_usd={cost:.6f}") + return "claude usage: " + " ".join(fields) + "\n" if fields else "claude turn completed\n" + + def hidden_activity(self) -> str | None: + self.hidden_events += 1 + if time.monotonic() - self.last_visible < self.activity_seconds: + return None + return self.visible(self.flush_hidden()) + + def flush_hidden(self) -> str: + if not self.hidden_events: + return "" + count = self.hidden_events + self.hidden_events = 0 + return f"claude activity: {count} hidden tool/status events\n" + + def visible(self, text: str) -> str: + self.last_visible = time.monotonic() + return text + + +def format_codex_usage(usage: dict[str, Any]) -> str: + fields = [ + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_output_tokens", + ] + parts = [f"{field}={usage[field]}" for field in fields if isinstance(usage.get(field), int)] + return "codex usage: " + " ".join(parts) if parts else "codex usage: unavailable" + + +def claude_allowed_tools(args: argparse.Namespace) -> str: + tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()] + if not args.web_search: + tools = [tool for tool in tools if tool not in {"WebSearch", "WebFetch"}] + return ",".join(tools) + + +def allow_unsandboxed_tools() -> bool: + return os.environ.get("AUTOREVIEW_ALLOW_UNSANDBOXED_TOOLS") == "1" + + +def extract_json(text: str) -> dict[str, Any]: + stripped = text.strip() + if not stripped: + raise SystemExit("review engine returned empty output") + try: + parsed = json.loads(stripped) + except json.JSONDecodeError as exc: + fenced_report = parse_json_candidate(stripped) + if isinstance(fenced_report, dict) and "findings" in fenced_report: + return fenced_report + jsonl_report = extract_json_from_jsonl(stripped) + if jsonl_report: + return jsonl_report + raise SystemExit(f"review engine returned non-JSON output: {exc}\n{stripped[:2000]}") + if isinstance(parsed, dict) and "findings" in parsed: + return parsed + if isinstance(parsed, dict) and isinstance(parsed.get("structured_output"), dict): + return parsed["structured_output"] + if isinstance(parsed, dict) and isinstance(parsed.get("result"), str): + result_json = parse_json_candidate(parsed["result"]) + if isinstance(result_json, dict) and "findings" in result_json: + return result_json + raise SystemExit(f"review engine result was not structured JSON:\n{parsed['result'][:2000]}") + jsonl_report = extract_json_from_jsonl(stripped) + if jsonl_report: + return jsonl_report + raise SystemExit(f"review engine returned unexpected JSON shape:\n{json.dumps(parsed)[:2000]}") + + +def extract_json_from_jsonl(text: str) -> dict[str, Any] | None: + candidates: list[str | dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(event, dict): + continue + part = event.get("part") + if isinstance(part, dict) and isinstance(part.get("text"), str): + candidates.append(part["text"]) + data = event.get("data") + if isinstance(data, dict) and isinstance(data.get("content"), str): + candidates.append(data["content"]) + if isinstance(event.get("result"), str): + candidates.append(event["result"]) + if isinstance(event.get("structured_output"), dict): + candidates.append(event["structured_output"]) + for candidate in reversed(candidates): + if isinstance(candidate, dict): + if "findings" in candidate: + return candidate + continue + parsed = parse_json_candidate(candidate) + if isinstance(parsed, dict) and "findings" in parsed: + return parsed + return None + + +def parse_json_candidate(text: str) -> Any | None: + stripped = text.strip() + if stripped.startswith("```"): + lines = stripped.splitlines() + if lines and lines[0].startswith("```") and lines[-1].strip() == "```": + stripped = "\n".join(lines[1:-1]).strip() + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + return None + if isinstance(parsed, str) and parsed != text: + nested = parse_json_candidate(parsed) + return nested if nested is not None else parsed + return parsed + + +def validate_report(report: dict[str, Any], repo: Path, changed_paths: set[str], required: list[str]) -> None: + allowed_top = {"findings", "overall_correctness", "overall_explanation", "overall_confidence"} + extra_top = set(report) - allowed_top + if extra_top: + raise SystemExit(f"review JSON has unexpected top-level keys: {sorted(extra_top)}") + for key in SCHEMA["required"]: + if key not in report: + raise SystemExit(f"review JSON missing required key: {key}") + if not isinstance(report["findings"], list): + raise SystemExit("review JSON findings must be an array") + if report.get("overall_correctness") not in {"patch is correct", "patch is incorrect"}: + raise SystemExit(f"review JSON has invalid overall_correctness: {report.get('overall_correctness')}") + if not isinstance(report.get("overall_explanation"), str) or not report["overall_explanation"]: + raise SystemExit("review JSON overall_explanation must be a non-empty string") + if len(report["overall_explanation"]) > 3000: + raise SystemExit("review JSON overall_explanation is too long") + if not number_in_range(report.get("overall_confidence")): + raise SystemExit("review JSON overall_confidence must be numeric") + finding_text = "" + kept_findings: list[dict[str, Any]] = [] + ignored_findings: list[tuple[int, dict[str, Any], str, int]] = [] + for index, finding in enumerate(report["findings"]): + if not isinstance(finding, dict): + raise SystemExit(f"finding {index} must be an object") + allowed_finding = {"title", "body", "priority", "confidence", "category", "code_location"} + extra_finding = set(finding) - allowed_finding + if extra_finding: + raise SystemExit(f"finding {index} has unexpected keys: {sorted(extra_finding)}") + for key in allowed_finding: + if key not in finding: + raise SystemExit(f"finding {index} missing required key: {key}") + title = finding.get("title") + if not isinstance(title, str) or not title or len(title) > 140: + raise SystemExit(f"finding {index} has invalid title") + body = finding.get("body") + if not isinstance(body, str) or not body or len(body) > 2000: + raise SystemExit(f"finding {index} has invalid body") + priority = finding.get("priority") + if priority not in {"P0", "P1", "P2", "P3"}: + raise SystemExit(f"finding {index} has invalid priority: {priority}") + if not number_in_range(finding.get("confidence")): + raise SystemExit(f"finding {index} has invalid confidence") + category = finding.get("category") + if category not in {"bug", "security", "regression", "test_gap", "maintainability"}: + raise SystemExit(f"finding {index} has invalid category: {category}") + location = finding.get("code_location") + if not isinstance(location, dict): + raise SystemExit(f"finding {index} missing code_location") + rel = normalize_review_path(str(location.get("file_path", "")).strip(), changed_paths) + location["file_path"] = rel + line = location.get("line") + if not rel or not isinstance(line, int) or line < 1: + raise SystemExit(f"finding {index} has invalid location: {location}") + if Path(rel).is_absolute() or ".." in Path(rel).parts: + raise SystemExit(f"finding {index} uses invalid file path: {rel}") + if rel not in changed_paths: + ignored_findings.append((index, finding, rel, line)) + continue + kept_findings.append(finding) + finding_text += "\n" + json.dumps(finding, sort_keys=True) + if ignored_findings: + for index, finding, rel, line in ignored_findings: + title = finding.get("title", "") + print( + f"autoreview ignored out-of-scope finding {index}: {title} ({rel}:{line})", + file=sys.stderr, + ) + print(bounded_field(str(finding.get("body", "")), 500), file=sys.stderr) + report["findings"] = kept_findings + if not kept_findings and report["overall_correctness"] == "patch is incorrect": + note = f"Ignored {len(ignored_findings)} out-of-scope finding(s) outside the reviewed change." + explanation = report["overall_explanation"].rstrip() + report["overall_correctness"] = "patch is correct" + report["overall_explanation"] = bounded_field(f"{explanation}\n\n{note}", 3000) + haystack = finding_text.lower() + for needle in required: + if needle.lower() not in haystack: + raise SystemExit(f"required finding text not found: {needle}") + + +def normalize_review_path(path: str, changed_paths: set[str]) -> str: + normalized = path.replace("\\", "/").removeprefix("./") + if normalized in changed_paths: + return normalized + for prefix in ("a/", "b/"): + if normalized.startswith(prefix) and normalized[len(prefix) :] in changed_paths: + return normalized[len(prefix) :] + for changed_path in sorted(changed_paths, key=len, reverse=True): + if normalized.endswith(f"/{changed_path}"): + return changed_path + return normalized + + +def number_in_range(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1 + + +def print_report(report: dict[str, Any], *, label: str = "autoreview") -> None: + findings = report["findings"] + if findings: + print(f"{label} findings: {len(findings)}") + elif report["overall_correctness"] == "patch is incorrect": + print(f"{label} verdict: patch is incorrect without discrete findings") + else: + print(f"{label} clean: no accepted/actionable findings reported") + for finding in findings: + loc = finding["code_location"] + print(f"[{finding['priority']}] {finding['title']}") + print(f"{loc['file_path']}:{loc['line']}") + print(f"{finding['body']}") + print() + print(f"overall: {report['overall_correctness']} ({report['overall_confidence']})") + print(report["overall_explanation"]) + + +def start_parallel_tests(command: str, repo: Path) -> tuple[subprocess.Popen, float]: + print(f"tests: {command}") + return subprocess.Popen(command, cwd=repo, shell=True), time.time() + + +def finish_parallel_tests(proc: subprocess.Popen, started: float) -> int: + proc.wait() + print(f"tests exit: {proc.returncode} after {int(time.time() - started)}s") + return int(proc.returncode or 0) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Bundle-driven AI code review.") + parser.add_argument("--mode", choices=["auto", "local", "uncommitted", "branch", "commit"], default="auto") + parser.add_argument("--base") + parser.add_argument("--commit", default="HEAD") + parser.add_argument("--engine", choices=ENGINES, default=os.environ.get("AUTOREVIEW_ENGINE", "codex")) + parser.add_argument("--reviewers", help="Comma-separated review panel, e.g. codex,claude or codex:gpt-5:high.") + parser.add_argument("--panel", action="store_true", help="Run a Codex/Claude review panel unless --engine changes the first reviewer.") + parser.add_argument("--model", action="append", help="Model for all reviewers or engine=model. Repeatable.") + parser.add_argument("--thinking", action="append", help="Thinking/effort for all reviewers or engine=level. Repeatable. Codex: low, medium, high, xhigh. Claude: low, medium, high, xhigh, max.") + parser.add_argument("--allow-partial-panel", action="store_true", help="Continue panel output when one reviewer fails.") + parser.add_argument("--codex-bin", default=os.environ.get("CODEX_BIN", "codex")) + parser.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude")) + parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid")) + parser.add_argument("--copilot-bin", default=os.environ.get("COPILOT_BIN", "copilot")) + parser.add_argument("--no-tools", dest="tools", action="store_false", default=True, help="Disable tools for engines that support it. Codex and copilot reject no-tools review.") + parser.add_argument("--no-web-search", dest="web_search", action="store_false", default=True) + parser.add_argument( + "--claude-allowed-tools", + default=os.environ.get( + "AUTOREVIEW_CLAUDE_TOOLS", + "Read,Grep,Glob,WebSearch,WebFetch", + ), + ) + parser.add_argument("--prompt", action="append", help="Additional review instruction text.") + parser.add_argument("--prompt-file", action="append", help="Additional review instruction file.") + parser.add_argument("--dataset", action="append", help="Extra evidence file to include in the review bundle.") + parser.add_argument("--output", help="Write human output to a file as well as stdout.") + parser.add_argument("--json-output", help="Write validated structured review JSON.") + parser.add_argument( + "--stream-engine-output", + action="store_true", + default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1", + help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.", + ) + parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.") + parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.") + parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + if args.engine not in ENGINES: + raise SystemExit(f"invalid --engine/AUTOREVIEW_ENGINE: {args.engine}") + return args + + +def run_engine(args: argparse.Namespace, repo: Path, prompt: str) -> str: + if args.engine == "codex": + return run_codex(args, repo, prompt) + if args.engine == "claude": + return run_claude(args, repo, prompt) + if args.engine == "droid": + return run_droid(args, repo, prompt) + if args.engine == "copilot": + return run_copilot(args, repo, prompt) + raise SystemExit(f"unsupported engine: {args.engine}") + + +def parse_keyed_options(values: list[str] | None, option: str) -> tuple[str | None, dict[str, str]]: + global_value: str | None = None + per_engine: dict[str, str] = {} + for raw in values or []: + value = raw.strip() + if not value: + raise SystemExit(f"--{option} cannot be empty") + if "=" in value: + engine, engine_value = value.split("=", 1) + engine = engine.strip() + engine_value = engine_value.strip() + if engine not in ENGINES: + raise SystemExit(f"--{option} uses unknown engine: {engine}") + if not engine_value: + raise SystemExit(f"--{option} for {engine} cannot be empty") + if engine in per_engine: + raise SystemExit(f"--{option} specified more than once for {engine}") + per_engine[engine] = engine_value + else: + if global_value is not None: + raise SystemExit(f"--{option} global value specified more than once") + global_value = value + return global_value, per_engine + + +def parse_reviewer_token(token: str) -> tuple[str, str | None, str | None]: + parts = [part.strip() for part in token.split(":")] + if len(parts) > 3 or not parts[0]: + raise SystemExit(f"invalid reviewer spec: {token}") + engine = parts[0] + if engine not in ENGINES: + raise SystemExit(f"unknown reviewer engine: {engine}") + model = parts[1] if len(parts) >= 2 and parts[1] else None + thinking = parts[2] if len(parts) == 3 and parts[2] else None + return engine, model, thinking + + +def reviewer_args(args: argparse.Namespace) -> list[argparse.Namespace]: + global_model, model_by_engine = parse_keyed_options(args.model, "model") + global_thinking, thinking_by_engine = parse_keyed_options(args.thinking, "thinking") + reviewers: list[tuple[str, str | None, str | None]] = [] + if args.reviewers: + tokens = [token.strip() for token in args.reviewers.split(",") if token.strip()] + if len(tokens) == 1 and tokens[0] == "all": + tokens = list(ENGINES) + reviewers = [parse_reviewer_token(token) for token in tokens] + elif args.panel: + engines = [args.engine] + for engine in ("codex", "claude"): + if engine not in engines: + engines.append(engine) + reviewers = [(engine, None, None) for engine in engines] + else: + reviewers = [(args.engine, None, None)] + + seen: set[str] = set() + result: list[argparse.Namespace] = [] + for engine, inline_model, inline_thinking in reviewers: + if engine in seen: + raise SystemExit(f"reviewer specified more than once: {engine}") + seen.add(engine) + model = inline_model or model_by_engine.get(engine) or global_model + thinking = inline_thinking or thinking_by_engine.get(engine) or global_thinking + if thinking and thinking not in THINKING_LEVELS_BY_ENGINE[engine]: + valid = ", ".join(sorted(THINKING_LEVELS_BY_ENGINE[engine])) or "none" + raise SystemExit(f"invalid thinking level for {engine}: {thinking} (valid: {valid})") + clone = copy.copy(args) + clone.engine = engine + clone.model = model + clone.thinking = thinking + result.append(clone) + return result + + +def reviewer_label(args: argparse.Namespace) -> str: + parts = [args.engine] + if args.model: + parts.append(f"model={args.model}") + if args.thinking: + parts.append(f"thinking={args.thinking}") + return " ".join(parts) + + +def run_reviewer(args: argparse.Namespace, repo: Path, prompt: str, changed_paths: set[str], required: list[str]) -> dict[str, Any]: + raw = run_engine(args, repo, prompt) + report = extract_json(raw) + validate_report(report, repo, changed_paths, required) + return report + + +def merge_panel_reports(reports: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]: + findings: list[dict[str, Any]] = [] + seen: set[tuple[str, int, str, str]] = set() + for label, report in reports: + for finding in report["findings"]: + location = finding["code_location"] + key = ( + location["file_path"], + location["line"], + finding["category"], + " ".join(finding["title"].lower().split()), + ) + if key in seen: + continue + seen.add(key) + merged = copy.deepcopy(finding) + merged["body"] = bounded_field(f"Reviewer: {label}\n\n{merged['body']}", 2000) + findings.append(merged) + incorrect = bool(findings) or any(report["overall_correctness"] == "patch is incorrect" for _, report in reports) + summary = ", ".join(f"{label}: {len(report['findings'])} finding(s)" for label, report in reports) + return { + "findings": findings, + "overall_correctness": "patch is incorrect" if incorrect else "patch is correct", + "overall_explanation": f"Panel review complete. {summary}.", + "overall_confidence": max((report["overall_confidence"] for _, report in reports), default=0.5), + } + + +def run_panel(args: argparse.Namespace, reviewers: list[argparse.Namespace], repo: Path, prompt: str, changed_paths: set[str]) -> dict[str, Any]: + reports: list[tuple[str, dict[str, Any]]] = [] + failures: list[str] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(reviewers)) as executor: + future_by_label = { + executor.submit(run_reviewer, reviewer, repo, prompt, changed_paths, []): reviewer_label(reviewer) + for reviewer in reviewers + } + for future in concurrent.futures.as_completed(future_by_label): + label = future_by_label[future] + try: + reports.append((label, future.result())) + except SystemExit as exc: + failures.append(f"{label}: {exc}") + except Exception as exc: + failures.append(f"{label}: {exc}") + if failures and not args.allow_partial_panel: + raise SystemExit("autoreview panel failed\n" + "\n".join(failures)) + if failures: + for failure in failures: + print(f"panel reviewer failed: {failure}") + if not reports: + raise SystemExit("autoreview panel produced no reports") + reports.sort(key=lambda item: item[0]) + report = merge_panel_reports(reports) + validate_report(report, repo, changed_paths, args.require_finding) + return report + + +def main() -> int: + args = parse_args() + reviewers = reviewer_args(args) + repo = repo_root() + target, target_ref = choose_target(repo, args.mode, args.base) + print(f"autoreview target: {target}") + print(f"branch: {current_branch(repo)}") + if len(reviewers) == 1 and not args.reviewers and not args.panel: + print(f"engine: {reviewers[0].engine}") + if reviewers[0].model: + print(f"model: {reviewers[0].model}") + if reviewers[0].thinking: + print(f"thinking: {reviewers[0].thinking}") + else: + print(f"reviewers: {', '.join(reviewer_label(reviewer) for reviewer in reviewers)}") + print(f"tools: {'on' if args.tools else 'off'}") + print(f"web_search: {'on' if args.web_search else 'off'}") + display_ref = args.commit if target == "commit" else target_ref + if display_ref: + print(f"ref: {display_ref}") + if args.dry_run: + return 0 + + if target == "local": + bundle = local_bundle(repo) + elif target == "branch": + assert target_ref + bundle = branch_bundle(repo, target_ref) + else: + bundle = commit_bundle(repo, args.commit) + target_ref = args.commit + prompt = build_prompt(repo, target, target_ref, bundle, load_extra_prompt(args), load_datasets(args, repo)) + changed_paths = review_paths(repo, target, target_ref, args.commit) + print(f"bundle: {len(prompt)} chars") + + tests_proc: tuple[subprocess.Popen, float] | None = None + if args.parallel_tests: + tests_proc = start_parallel_tests(args.parallel_tests, repo) + try: + if len(reviewers) == 1: + report = run_reviewer(reviewers[0], repo, prompt, changed_paths, args.require_finding) + label = "autoreview" + else: + report = run_panel(args, reviewers, repo, prompt, changed_paths) + label = "autoreview panel" + if args.json_output: + Path(args.json_output).write_text(json.dumps(report, indent=2) + "\n") + + if args.output: + original_stdout = sys.stdout + with Path(args.output).open("w") as handle: + sys.stdout = Tee(original_stdout, handle) + print_report(report, label=label) + sys.stdout = original_stdout + else: + print_report(report, label=label) + finally: + tests_status = finish_parallel_tests(*tests_proc) if tests_proc else 0 + + has_findings = bool(report["findings"]) + overall_incorrect = report["overall_correctness"] == "patch is incorrect" + if tests_status != 0: + return 1 + if args.expect_findings: + return 0 if has_findings else 1 + return 1 if has_findings or overall_incorrect else 0 + + +class Tee: + def __init__(self, *streams: Any) -> None: + self.streams = streams + + def write(self, data: str) -> None: + for stream in self.streams: + stream.write(data) + + def flush(self) -> None: + for stream in self.streams: + stream.flush() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/autoreview/scripts/test-review-harness b/.agents/skills/autoreview/scripts/test-review-harness new file mode 100755 index 000000000..58105bc55 --- /dev/null +++ b/.agents/skills/autoreview/scripts/test-review-harness @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: test-review-harness [--fixture malicious|benign] [--engine codex|claude|droid|copilot]... + +Creates a temporary git repo with either a deliberately unsafe patch or a +security-sensitive-but-safe patch, then verifies each selected engine through +autoreview. +Default engines: codex, claude. +EOF +} + +engines=() +fixture=malicious +while [[ $# -gt 0 ]]; do + case "$1" in + --fixture) + fixture=${2:-} + shift 2 + ;; + --engine) + engines+=("${2:-}") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac +done + +case "$fixture" in + malicious|benign) ;; + *) + usage >&2 + exit 2 + ;; +esac + +if [[ ${#engines[@]} -eq 0 ]]; then + engines=(codex claude) +fi + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo=$(mktemp -d "${TMPDIR:-/tmp}/autoreview-fixture.XXXXXX") +trap 'rm -rf "$repo"' EXIT + +cd "$repo" +git init --quiet +git config user.name "Review Fixture" +git config user.email "review-fixture@example.com" + +if [[ "$fixture" == malicious ]]; then + cat > app.js <<'EOF' +export function uploadPath(name) { + return `uploads/${name.replaceAll("/", "")}`; +} + +export function publicUser(user) { + return { id: user.id, name: user.name }; +} +EOF +else + cat > app.js <<'EOF' +import path from "node:path"; + +const uploadsRoot = path.resolve("uploads"); + +function safeChildPath(root, name) { + if (name.includes("/") || name.includes("\\") || name.includes("\0")) { + throw new Error("invalid child name"); + } + const resolved = path.resolve(root, name); + if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root"); + return resolved; +} + +export function uploadPath(name) { + return safeChildPath(uploadsRoot, name); +} + +export function publicUser(user) { + return { id: user.id, name: user.name }; +} +EOF +fi + +git add app.js +git commit --quiet -m "initial safe version" + +if [[ "$fixture" == malicious ]]; then + cat > app.js <<'EOF' +import { execSync } from "node:child_process"; + +export function uploadPath(name) { + return `uploads/${name}`; +} + +export function deleteUpload(name) { + return execSync(`rm -rf uploads/${name}`); +} + +export function publicUser(user) { + return { id: user.id, name: user.name, password: user.password }; +} +EOF +else + cat > app.js <<'EOF' +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; + +const uploadsRoot = path.resolve("uploads"); +const reposRoot = path.resolve("repos"); +const execFileAsync = promisify(execFile); + +function safeChildPath(root, name) { + if (name.includes("/") || name.includes("\\") || name.includes("\0")) { + throw new Error("invalid child name"); + } + const resolved = path.resolve(root, name); + if (!resolved.startsWith(root + path.sep)) throw new Error("path escaped root"); + return resolved; +} + +function repoChildPath(root, name) { + if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error("invalid repo name"); + return safeChildPath(root, name); +} + +export function uploadPath(name) { + return safeChildPath(uploadsRoot, name); +} + +export async function repoStatus(repoName) { + const { stdout } = await execFileAsync("git", ["status", "--short"], { + cwd: repoChildPath(reposRoot, repoName), + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; +} + +export function publicUser(user) { + return { id: user.id, name: user.name }; +} + +export function accountSettingsForOwner(user, requesterId) { + if (String(requesterId) !== String(user.id)) throw new Error("forbidden"); + return { id: user.id, hasPassword: Boolean(user.passwordHash) }; +} +EOF +fi + +for engine in "${engines[@]}"; do + echo "== $engine ==" + if [[ "$fixture" == malicious ]]; then + "$script_dir/autoreview" \ + --mode local \ + --engine "$engine" \ + --prompt "This is an acceptance test fixture. The changed app.js patch contains real security bugs. Review normally and report only actionable defects from the patch." \ + --require-finding "command" \ + --expect-findings + else + "$script_dir/autoreview" \ + --mode local \ + --engine "$engine" \ + --prompt "Security calibration fixture: this patch intentionally uses filesystem paths, async execFile, and owner-gated password-adjacent state safely. Do not flag legitimate shell/filesystem/auth-adjacent functionality unless there is a concrete exploitable risk in the diff." + fi +done From 64b650cb3313cb53a38a7ee15c166b542643649d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 22:08:15 +0100 Subject: [PATCH 3/5] fix(pairing): harden Windows node bootstrap reconnects --- .../GatewayConnectionManager.cs | 73 +++++++- src/OpenClaw.Connection/GatewayRecord.cs | 3 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 1 + src/OpenClaw.Shared/WindowsNodeClient.cs | 1 + .../Services/SettingsManager.cs | 22 +++ tests/Directory.Build.props | 9 + .../GatewayConnectionManagerTests.cs | 171 ++++++++++++++++++ .../OpenClawGatewayClientTests.cs | 18 ++ .../WindowsNodeClientTests.cs | 50 +++++ .../SettingsRoundTripTests.cs | 6 + 10 files changed, 352 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index 8a5d74d01..61986a6ce 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -33,6 +33,7 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private bool _disposed; private Task? _disposeTask; private bool _gatewayNeedsV2Signature; // remembered across reconnects + private string? _operatorTokenRecoveryAttemptedGatewayId; private string? _lastAutoApprovedRequestId; // prevent auto-approve loops private string? _autoApproveInFlight; // atomic guard against concurrent approval of same requestId @@ -149,6 +150,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _diagnostics.RecordCredentialResolution(credential); _activeIdentityPath = perGatewayIdentityDir; _activeGatewayRecordId = record.Id; + _gatewayNeedsV2Signature = record.IsLocal || record.RequiresV2Signature; if (credential == null) { @@ -241,12 +243,13 @@ private async Task ConnectCoreAsync(string? gatewayId = null) }; lifecycle.DataClient.V2SignatureFallback += (s, _) => { - _gatewayNeedsV2Signature = true; + if (Interlocked.Read(ref _generation) != gen) return; + RememberGatewayNeedsV2Signature(record.Id); }; // Local gateways only support v2 signatures β€” skip the v3 attempt entirely // to avoid a spurious "metadata-upgrade" re-pairing triggered by the v3β†’v2 fallback. - if (record.IsLocal) + if (record.IsLocal || record.RequiresV2Signature) _gatewayNeedsV2Signature = true; // If we already know this gateway needs v2, tell the client upfront @@ -488,6 +491,9 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) { if (Interlocked.Read(ref _generation) != gen) return; + if (TryScheduleOperatorTokenRecovery(message, gen)) + return; + var prev = _stateMachine.Current.OverallState; _diagnostics.Record("error", "Authentication failed", message); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message); @@ -499,6 +505,48 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) } } + private bool TryScheduleOperatorTokenRecovery(string message, long gen) + { + if (!IsOperatorDeviceTokenMismatch(message) || + _activeGatewayRecordId == null || + _activeIdentityPath == null || + _operatorTokenRecoveryAttemptedGatewayId == _activeGatewayRecordId) + { + return false; + } + + var record = _registry.GetById(_activeGatewayRecordId); + if (record == null || string.IsNullOrWhiteSpace(record.BootstrapToken)) + return false; + + if (!DeviceIdentity.TryClearDeviceToken(_activeIdentityPath, _logger)) + return false; + + _operatorTokenRecoveryAttemptedGatewayId = _activeGatewayRecordId; + _diagnostics.Record("credential", "Cleared stale operator device token; reconnecting with bootstrap token"); + + _ = Task.Run(async () => + { + try + { + await _reconnectDelay(TimeSpan.FromMilliseconds(200)); + if (Interlocked.Read(ref _generation) != gen || _disposed) return; + await ReconnectAsync(); + } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Operator token recovery reconnect failed: {ex.Message}"); + } + }); + + return true; + } + + private static bool IsOperatorDeviceTokenMismatch(string message) => + message.Contains("device token mismatch", StringComparison.OrdinalIgnoreCase) || + message.Contains("AUTH_DEVICE_TOKEN_MISMATCH", StringComparison.OrdinalIgnoreCase); + private async Task HandleHandshakeSucceededAsync(long gen) { await _transitionSemaphore.WaitAsync(); @@ -510,6 +558,8 @@ private async Task HandleHandshakeSucceededAsync(long gen) _diagnostics.Record("state", "Handshake succeeded (hello-ok)"); _stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + if (_operatorTokenRecoveryAttemptedGatewayId == _activeGatewayRecordId) + _operatorTokenRecoveryAttemptedGatewayId = null; // Update device ID from client if (_activeLifecycle?.DataClient is { } client) @@ -579,6 +629,25 @@ private void HandleDeviceTokenReceived(DeviceTokenReceivedEventArgs e) } } + private void RememberGatewayNeedsV2Signature(string? gatewayRecordId) + { + _gatewayNeedsV2Signature = true; + + if (string.IsNullOrWhiteSpace(gatewayRecordId)) + return; + + try + { + _registry.Update(gatewayRecordId, r => r.RequiresV2Signature ? r : r with { RequiresV2Signature = true }); + _registry.Save(); + _diagnostics.Record("credential", "Remembered gateway v2 signature requirement"); + } + catch (Exception ex) + { + _logger.Warn($"[ConnMgr] Failed to persist v2 signature requirement: {ex.Message}"); + } + } + private async Task HandlePairingRequiredAsync(string? requestId, long gen) { await _transitionSemaphore.WaitAsync(); diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs index 025bc22f6..d0680d637 100644 --- a/src/OpenClaw.Connection/GatewayRecord.cs +++ b/src/OpenClaw.Connection/GatewayRecord.cs @@ -27,6 +27,9 @@ public sealed record GatewayRecord /// True for gateways provisioned locally (localhost/WSL). public bool IsLocal { get; init; } + /// True when this gateway is known to require v2 auth signatures. + public bool RequiresV2Signature { get; init; } + /// WSL distro name for gateway records provisioned by SetupEngine. public string? SetupManagedDistroName { get; init; } diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index dfaa089da..30795c31e 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -244,6 +244,7 @@ public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? l _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); _connectAuthToken = _deviceIdentity.DeviceToken ?? (_tokenIsBootstrapToken ? string.Empty : _token); + _useV2Signature |= _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken); } public async Task DisconnectAsync() diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 28fac6285..2c3a88789 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -117,6 +117,7 @@ public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpen // Initialize device identity _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); + _useV2Signature |= !string.IsNullOrEmpty(_bootstrapToken) && string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); // Initialize registration _registration = new NodeRegistration diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 4d369671d..dad83207f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -424,6 +424,28 @@ public void Save() return ProtectedSecretPrefix + Convert.ToBase64String(protectedBytes); } + internal static bool CanProtectSettingSecretsForCurrentUser() + { + if (!OperatingSystem.IsWindows()) + return false; + + try + { + var bytes = Encoding.UTF8.GetBytes("openclaw-dpapi-probe"); + var protectedBytes = ProtectedData.Protect(bytes, ProtectedSecretEntropy, DataProtectionScope.CurrentUser); + var unprotectedBytes = ProtectedData.Unprotect(protectedBytes, ProtectedSecretEntropy, DataProtectionScope.CurrentUser); + return bytes.SequenceEqual(unprotectedBytes); + } + catch (CryptographicException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + } + internal static string? UnprotectSettingSecret(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 8415b6d55..04ea82bfd 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -11,6 +11,7 @@ enable false true + 1.0.20.1 all @@ -33,4 +34,12 @@ + + + + + + + + diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 15fbb2dfd..72f7369b1 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -245,6 +245,146 @@ public async Task HandshakeSucceeded_StartsManagerNodeConnector_WhenGateAllows() Assert.Equal("wss://remote.example", nodeConnector.LastGatewayUrl); } + [Fact] + public async Task ConnectAsync_WithPersistedV2Requirement_SetsClientUseV2Signature() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-remote", + Url = "wss://remote.example", + RequiresV2Signature = true + }); + _registry.SetActive("gw-remote"); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + + await _manager.ConnectAsync("gw-remote"); + + Assert.True(_factory.CreatedClients[0].DataClient.UseV2Signature); + } + + [Fact] + public async Task V2SignatureFallback_PersistsGatewayRequirement() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + + await _manager.ConnectAsync("gw-remote"); + + var lifecycle = _factory.CreatedClients[0]; + lifecycle.SimulateV2SignatureFallback(); + + Assert.True(_registry.GetById("gw-remote")?.RequiresV2Signature); + } + + [Fact] + public async Task AuthenticationFailed_DeviceTokenMismatchWithBootstrap_ReconnectsWithBootstrap() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-remote", + Url = "wss://remote.example", + BootstrapToken = "bootstrap-token" + }); + _registry.SetActive("gw-remote"); + + var identityDir = _registry.GetIdentityDirectory("gw-remote"); + var identity = new DeviceIdentity(identityDir, NullLogger.Instance); + identity.Initialize(); + identity.StoreDeviceToken("stale-device-token"); + + var resolver = new CredentialResolver(new DeviceIdentityFileReader()); + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + _registry, + NullLogger.Instance, + reconnectDelay: _ => Task.CompletedTask); + + await manager.ConnectAsync("gw-remote"); + Assert.Equal(CredentialResolver.SourceDeviceToken, factory.CreatedCredentials[0].Source); + + factory.CreatedClients[0].SimulateAuthFailed("unauthorized: device token mismatch (rotate/reissue device token)"); + + await WaitUntilAsync(() => factory.CreatedCredentials.Count >= 2); + + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identityDir)); + Assert.Equal(CredentialResolver.SourceBootstrapToken, factory.CreatedCredentials[1].Source); + Assert.True(factory.CreatedCredentials[1].IsBootstrapToken); + } + + [Fact] + public async Task AuthenticationFailed_DeviceTokenMismatchAfterSuccessfulRecovery_CanRecoverAgain() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-remote", + Url = "wss://remote.example", + BootstrapToken = "bootstrap-token" + }); + _registry.SetActive("gw-remote"); + + var identityDir = _registry.GetIdentityDirectory("gw-remote"); + var identity = new DeviceIdentity(identityDir, NullLogger.Instance); + identity.Initialize(); + identity.StoreDeviceToken("stale-device-token-1"); + + var resolver = new CredentialResolver(new DeviceIdentityFileReader()); + var factory = new MockClientFactory(); + using var manager = new GatewayConnectionManager( + resolver, + factory, + _registry, + NullLogger.Instance, + reconnectDelay: _ => Task.CompletedTask); + + await manager.ConnectAsync("gw-remote"); + factory.CreatedClients[0].SimulateAuthFailed("AUTH_DEVICE_TOKEN_MISMATCH"); + await WaitUntilAsync(() => factory.CreatedCredentials.Count >= 2); + + factory.CreatedClients[1].SimulateHandshake(); + await WaitUntilAsync(() => manager.CurrentSnapshot.OperatorState == RoleConnectionState.Connected); + + identity.Initialize(); + identity.StoreDeviceToken("stale-device-token-2"); + + await manager.ReconnectAsync(); + await WaitUntilAsync(() => factory.CreatedCredentials.Count >= 3); + Assert.Equal(CredentialResolver.SourceDeviceToken, factory.CreatedCredentials[2].Source); + + factory.CreatedClients[2].SimulateAuthFailed("AUTH_DEVICE_TOKEN_MISMATCH"); + await WaitUntilAsync(() => factory.CreatedCredentials.Count >= 4); + + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identityDir)); + Assert.Equal(CredentialResolver.SourceBootstrapToken, factory.CreatedCredentials[3].Source); + Assert.True(factory.CreatedCredentials[3].IsBootstrapToken); + } + + [Fact] + public async Task HandshakeSucceeded_StartsNodeConnectorWithPersistedV2Requirement() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-remote", + Url = "wss://remote.example", + RequiresV2Signature = true + }); + _registry.SetActive("gw-remote"); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, _factory, _registry, NullLogger.Instance, + nodeConnector: nodeConnector, + shouldStartNodeConnection: (_, _) => true); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(1, nodeConnector.ConnectCount); + Assert.True(nodeConnector.LastUseV2Signature); + } + [Fact] public async Task ChatPageNavigationReadiness_DoesNotCompleteUntilHandshakeSucceeded() { @@ -282,6 +422,18 @@ private static async Task InvokeHandshakeSucceededAsync(GatewayConnectionManager await task; } + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); + while (!condition()) + { + if (DateTime.UtcNow >= deadline) + throw new TimeoutException("Condition was not met before the timeout."); + + await Task.Delay(20); + } + } + // ─── EnsureNodeConnectedAsync tests ─── [Fact] @@ -467,11 +619,13 @@ private sealed class MockCredentialResolver : ICredentialResolver private sealed class MockClientFactory : IGatewayClientFactory { public List CreatedClients { get; } = []; + public List CreatedCredentials { get; } = []; public IGatewayClientLifecycle Create(string gatewayUrl, GatewayCredential credential, string identityPath, IOpenClawLogger logger) { var mock = new MockLifecycle(gatewayUrl); CreatedClients.Add(mock); + CreatedCredentials.Add(credential); return mock; } } @@ -500,6 +654,9 @@ public void SimulateAuthFailed(string msg) => public void SimulateHandshake() => _client.SimulateHandshakeSucceeded(); + public void SimulateV2SignatureFallback() => + _client.SimulateV2SignatureFallback(); + public void Dispose() { } } @@ -515,6 +672,18 @@ public void SimulateHandshakeSucceeded() OnHandshakeSucceeded(); } + public void SimulateV2SignatureFallback() + { + var field = typeof(OpenClawGatewayClient).GetField( + nameof(V2SignatureFallback), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + if (field != null) + { + var handler = field.GetValue(this) as EventHandler; + handler?.Invoke(this, EventArgs.Empty); + } + } + // Protected invoker β€” OpenClawGatewayClient.HandshakeSucceeded is a public event. // We use reflection because the event doesn't have a virtual invoker. private void OnHandshakeSucceeded() @@ -580,6 +749,7 @@ private sealed class CountingNodeConnector : INodeConnector { public int ConnectCount { get; private set; } public string? LastGatewayUrl { get; private set; } + public bool LastUseV2Signature { get; private set; } public bool IsConnected => ConnectCount > 0; public PairingStatus PairingStatus { get; private set; } = PairingStatus.Unknown; public string? NodeDeviceId => "test-node"; @@ -595,6 +765,7 @@ public Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string { ConnectCount++; LastGatewayUrl = gatewayUrl; + LastUseV2Signature = useV2Signature; PairingStatus = PairingStatus.Paired; return Task.CompletedTask; } diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index 89f325939..7ccbe5148 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -423,6 +423,24 @@ public void OperatorConnect_FreshDevice_RequestsBootstrapHandoffScopes() Assert.False(auth.ContainsKey("deviceToken")); } + [Fact] + public void OperatorConnect_FreshBootstrapDevice_StartsWithV2Signature() + { + var helper = new GatewayClientTestHelper(tokenIsBootstrapToken: true); + helper.SetDeviceTokenForTest(null); + + Assert.True(helper.Client.UseV2Signature); + } + + [Fact] + public void OperatorConnect_SharedTokenDevice_StartsWithV3Signature() + { + var helper = new GatewayClientTestHelper(tokenIsBootstrapToken: false); + helper.SetDeviceTokenForTest(null); + + Assert.False(helper.Client.UseV2Signature); + } + [Fact] public async Task RequestSkillsStatusAsync_RemembersRequestedAgentScope() { diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs index f386d602f..e8481657c 100644 --- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs @@ -807,6 +807,56 @@ public void BuildNodeConnectMessage_UsesBootstrapToken_WhenNoStoredDeviceToken() } } + [Fact] + public void BuildNodeConnectMessage_FreshBootstrapDevice_StartsWithV2Signature() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + using var client = new WindowsNodeClient( + "ws://localhost:18789", + "", + dataPath, + bootstrapToken: "bootstrap-token-123"); + + Assert.True(client.UseV2Signature); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + + [Fact] + public void BuildNodeConnectMessage_StoredDeviceTokenWithBootstrap_DoesNotForceV2Signature() + { + var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dataPath); + + try + { + var identity = new DeviceIdentity(dataPath); + identity.Initialize(); + identity.StoreDeviceTokenForRole("node", "stored-device-token", null); + + using var client = new WindowsNodeClient( + "ws://localhost:18789", + "", + dataPath, + bootstrapToken: "bootstrap-token-123"); + + Assert.False(client.UseV2Signature); + } + finally + { + if (Directory.Exists(dataPath)) + Directory.Delete(dataPath, true); + } + } + [Fact] public void BuildNodeConnectMessage_UsesStoredDeviceToken_OverBootstrapToken() { diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index be7101f61..c961824f9 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -283,6 +283,9 @@ public void SettingsManager_PersistsRecordingConsentFlags() [WindowsFact] public void SettingsManager_ProtectsElevenLabsApiKeyForStorage() { + if (!SettingsManager.CanProtectSettingSecretsForCurrentUser()) + return; + var protectedValue = SettingsManager.ProtectSettingSecret("elevenlabs-key"); Assert.NotNull(protectedValue); @@ -300,6 +303,9 @@ public void SettingsManager_ReturnsNullForCorruptedProtectedSecret() [WindowsFact] public void SettingsManager_SaveProtectsSecretsWithoutMutatingInMemoryData() { + if (!SettingsManager.CanProtectSettingSecretsForCurrentUser()) + return; + var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N")); var settingsPath = Path.Combine(dir, "settings.json"); From 8af4b70360a9e44a2d0f4a087ed57c6047fe6038 Mon Sep 17 00:00:00 2001 From: Ranjesh <28935693+ranjeshj@users.noreply.github.com> Date: Fri, 29 May 2026 15:12:08 -0700 Subject: [PATCH 4/5] Add uninstall choice for local WSL gateway (#591) * Add uninstall gateway choice Prompt users during uninstall before removing the local WSL gateway and route the uninstall through a direct WSL cleanup helper so OpenClaw binaries are not launched from the install directory. The helper unregisters OpenClawGateway, cleans setup-managed gateway artifacts, and preserves generated gateway state when the user declines removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Guard gateway cleanup against reparse points Refuse recursive gateway directory deletion when the target is a junction or symlink, matching the SetupEngine safety guard and preventing cleanup from crossing outside the app-owned WSL directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/images/uninstall-complete.png | Bin 0 -> 17303 bytes .../images/uninstall-local-gateway-choice.png | Bin 0 -> 61961 bytes .../uninstall-local-gateway-progress.png | Bin 0 -> 36386 bytes .../uninstall-standard-confirmation.png | Bin 0 -> 18019 bytes installer.iss | 171 ++++- scripts/Uninstall-LocalGateway.ps1 | 670 ++++++++++++++++-- .../InstallerIssAssertionTests.cs | 51 +- 7 files changed, 802 insertions(+), 90 deletions(-) create mode 100644 docs/images/uninstall-complete.png create mode 100644 docs/images/uninstall-local-gateway-choice.png create mode 100644 docs/images/uninstall-local-gateway-progress.png create mode 100644 docs/images/uninstall-standard-confirmation.png diff --git a/docs/images/uninstall-complete.png b/docs/images/uninstall-complete.png new file mode 100644 index 0000000000000000000000000000000000000000..7ffd41508d10a2fcbeb6b6c36caedbc983c594db GIT binary patch literal 17303 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 I`5XR!0j?SRT>t<8 literal 0 HcmV?d00001 diff --git a/docs/images/uninstall-local-gateway-choice.png b/docs/images/uninstall-local-gateway-choice.png new file mode 100644 index 0000000000000000000000000000000000000000..fb5ab62089b046a33651085a37465a540b83d2b1 GIT binary patch literal 61961 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 literal 0 HcmV?d00001 diff --git a/docs/images/uninstall-local-gateway-progress.png b/docs/images/uninstall-local-gateway-progress.png new file mode 100644 index 0000000000000000000000000000000000000000..1ec0b77d235df578808c1d9c080cc7e022045a6b GIT binary patch literal 36386 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 wxR%d)$l$a-e9I|YbEtlB_kuF*VelC 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 SCri>-wKeqbm8(B_`F{YkxeK@e literal 0 HcmV?d00001 diff --git a/installer.iss b/installer.iss index ceb89a0f2..dcfd2a097 100644 --- a/installer.iss +++ b/installer.iss @@ -69,7 +69,7 @@ Name: "startupicon"; Description: "Start OpenClaw Companion when Windows starts" [Files] ; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; WSL gateway uninstall helper β€” invoked by [UninstallRun] to drive clean removal +; WSL gateway uninstall helper copied to {tmp} by [Code] during uninstall. Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion [Registry] @@ -91,25 +91,150 @@ Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: st [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent -[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..." -; The hook launches our self-contained tray executable from {app}. Windows can -; keep just-loaded .NET/WinUI DLL handles around briefly after the process exits; -; give those handles time to release before Inno deletes the app directory. -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Start-Sleep -Seconds 3"""; Flags: waituntilterminated runhidden; StatusMsg: "Preparing installed files for removal..." - -[UninstallDelete] -; The tray creates runtime state (logs, WSL files, JSON metadata) under the -; dedicated {app} directory. Remove the directory after the gateway cleanup hook -; has finished so uninstall leaves neither app files nor generated state behind. -Type: filesandordirs; Name: "{app}" +[Code] +var + LocalGatewayCleanupChoiceInitialized: Boolean; + LocalGatewayCleanupRequested: Boolean; + LocalGatewayCleanupSucceeded: Boolean; + +procedure EnsureLocalGatewayCleanupChoice; +begin + if LocalGatewayCleanupChoiceInitialized then + Exit; + + LocalGatewayCleanupChoiceInitialized := True; + + if UninstallSilent() then + begin + LocalGatewayCleanupRequested := True; + Log('Silent uninstall: local gateway cleanup will run automatically.'); + end + else + begin + LocalGatewayCleanupRequested := + MsgBox( + 'Do you also want to remove the OpenClaw local WSL gateway?' + #13#10#13#10 + + 'Choose Yes to unregister the OpenClawGateway WSL distro and remove generated local gateway state.' + #13#10 + + 'Choose No to leave the local gateway and generated local state on this computer.', + mbConfirmation, + MB_YESNO) = IDYES; + + if LocalGatewayCleanupRequested then + Log('User chose to remove the local WSL gateway.') + else + Log('User chose to preserve the local WSL gateway and generated state.'); + end; +end; + +function RunLocalGatewayCleanupOnce(var ResultCode: Integer): Boolean; +var + SourceScriptPath: string; + TempScriptPath: string; + Params: string; +begin + SourceScriptPath := ExpandConstant('{app}\Uninstall-LocalGateway.ps1'); + TempScriptPath := ExpandConstant('{tmp}\Uninstall-LocalGateway.ps1'); + + if not FileExists(SourceScriptPath) then + begin + ResultCode := 2; + Log('Local gateway cleanup script is missing: ' + SourceScriptPath); + Result := False; + Exit; + end; + + if FileExists(TempScriptPath) then + DeleteFile(TempScriptPath); + + if not CopyFile(SourceScriptPath, TempScriptPath, False) then + begin + ResultCode := 3; + Log('Failed to copy local gateway cleanup script to: ' + TempScriptPath); + Result := False; + Exit; + end; + + Params := + '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')); + + Log('Running local gateway cleanup script from {tmp}.'); + Result := + Exec( + ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + Params, + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode); + + if Result then + Log('Local gateway cleanup script exited with code ' + IntToStr(ResultCode) + '.') + else + Log('Failed to start local gateway cleanup script. System error: ' + IntToStr(ResultCode) + '.'); +end; + +procedure RunLocalGatewayCleanup; +var + ResultCode: Integer; + Retry: Boolean; + Started: Boolean; +begin + if not LocalGatewayCleanupRequested then + Exit; + + LocalGatewayCleanupSucceeded := False; + + repeat + Retry := False; + UninstallProgressForm.StatusLabel.Caption := 'Removing local WSL gateway...'; + Started := RunLocalGatewayCleanupOnce(ResultCode); + + if Started and (ResultCode = 0) then + begin + LocalGatewayCleanupSucceeded := True; + Log('Local gateway cleanup completed successfully.'); + Exit; + end; + + if UninstallSilent() then + begin + Log('Local gateway cleanup failed during silent uninstall; continuing without deleting generated state.'); + Exit; + end; + + Retry := + MsgBox( + 'OpenClaw could not remove the local WSL gateway.' + #13#10#13#10 + + 'Exit code: ' + IntToStr(ResultCode) + #13#10#13#10 + + 'Select Retry to try again, or Cancel to continue uninstalling OpenClaw and leave local gateway state on disk.', + mbError, + MB_RETRYCANCEL) = IDRETRY; + until not Retry; + + Log('User continued uninstall after local gateway cleanup failed; generated state will be preserved.'); +end; + +procedure DeleteGeneratedAppState; +begin + if not LocalGatewayCleanupSucceeded then + Exit; + + if DelTree(ExpandConstant('{app}'), True, True, True) then + Log('Deleted generated app state from {app}.') + else + Log('Generated app state in {app} could not be fully deleted; continuing uninstall.'); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + EnsureLocalGatewayCleanupChoice; + RunLocalGatewayCleanup; + end + else if CurUninstallStep = usPostUninstall then + begin + DeleteGeneratedAppState; + end; +end; diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index cde043c95..f8eefbf81 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -1,79 +1,625 @@ <# .SYNOPSIS - Inno Setup [UninstallRun] helper β€” removes the local WSL gateway via the - OpenClaw tray CLI flag. + Removes the OpenClaw local WSL gateway during app uninstall. .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. + This helper is launched by the Inno uninstaller after the user chooses to + remove the local gateway. It deliberately calls WSL directly instead of + launching OpenClaw binaries from the install directory, so the app payload is + not kept loaded while Inno removes installed files. #> [CmdletBinding()] -param() +param( + [string]$AppRoot = $PSScriptRoot +) $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 +$DistroName = 'OpenClawGateway' +$resultPath = Join-Path $AppRoot 'uninstall-gateway-result.json' +$errorPath = Join-Path $AppRoot 'uninstall-gateway-error.log' +$wslLogPath = Join-Path $AppRoot 'uninstall-gateway-wsl.log' +$cleanupWarnings = New-Object 'System.Collections.Generic.List[string]' + +function Ensure-AppRoot { + if (-not [string]::IsNullOrWhiteSpace($AppRoot) -and -not (Test-Path -LiteralPath $AppRoot)) { + New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null + } +} + +function Write-GatewayLog { + param([string]$Message) + + try { + Ensure-AppRoot + "[$(Get-Date -Format 'o')] $Message" | Out-File -LiteralPath $wslLogPath -Encoding UTF8 -Append -Force + } catch { + Write-Verbose "Failed to write gateway uninstall log: $($_.Exception.Message)" + } +} + +function Add-CleanupWarning { + param([string]$Message) + + $script:cleanupWarnings.Add($Message) + Write-GatewayLog "Windows artifact cleanup warning: $Message" +} + +function Write-GatewayResult { + param( + [bool]$Succeeded, + [int]$ExitCode, + [string]$Message, + [object]$Details = $null + ) + + try { + Ensure-AppRoot + [ordered]@{ + timestamp = (Get-Date).ToString('o') + succeeded = $Succeeded + exitCode = $ExitCode + message = $Message + details = $Details + } | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $resultPath -Encoding UTF8 -Force + } catch { + $fallback = "[$(Get-Date -Format 'o')] Failed to write gateway uninstall result: $($_.Exception.Message)" + try { $fallback | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + } +} + +function Resolve-AppDataDir { + if ($env:OPENCLAW_TRAY_DATA_DIR) { + return $env:OPENCLAW_TRAY_DATA_DIR + } + + return Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ApplicationData)) 'OpenClawTray' + } + + function Resolve-LocalDataDir { + if ($env:OPENCLAW_TRAY_LOCALAPPDATA_DIR) { + return Join-Path $env:OPENCLAW_TRAY_LOCALAPPDATA_DIR 'OpenClawTray' + } + + if ($env:OPENCLAW_TRAY_LOCAL_DATA_DIR) { + return $env:OPENCLAW_TRAY_LOCAL_DATA_DIR + } + + return Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'OpenClawTray' + } + + function Get-JsonPropertyValue { + param( + [object]$Object, + [string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + return $property.Value + } + + function Read-JsonFile { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + try { + return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } catch { + Add-CleanupWarning "Failed to read JSON file '$Path': $($_.Exception.Message)" + return $null + } + } + + function Write-JsonFileAtomic { + param( + [string]$Path, + [object]$Value + ) + + $directory = Split-Path -Parent $Path + if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $tempPath = Join-Path $directory ('.' + (Split-Path -Leaf $Path) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') + try { + $Value | ConvertTo-Json -Depth 50 | Out-File -LiteralPath $tempPath -Encoding UTF8 -Force + Move-Item -LiteralPath $tempPath -Destination $Path -Force + } catch { + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + throw + } + } + + function Test-LocalGatewayUrl { + param([string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { + return $false + } + + try { + $uri = [Uri]$Url + $host = $uri.Host.ToLowerInvariant() + return $host -eq 'localhost' -or $host -eq '127.0.0.1' -or $host -eq '::1' -or $host -eq '[::1]' + } catch { + return $false + } + } + + function Test-SetupManagedLocalRecord { + param([object]$Record) + + $isLocal = [bool](Get-JsonPropertyValue $Record 'isLocal') + $sshTunnel = Get-JsonPropertyValue $Record 'sshTunnel' + if (-not $isLocal -or $null -ne $sshTunnel) { + return $false + } + + $setupManagedDistroName = [string](Get-JsonPropertyValue $Record 'setupManagedDistroName') + if ([string]::Equals($setupManagedDistroName, $DistroName, [StringComparison]::Ordinal)) { + return $true + } + + if (-not [string]::IsNullOrWhiteSpace($setupManagedDistroName)) { + return $false + } + + $friendlyName = [string](Get-JsonPropertyValue $Record 'friendlyName') + $url = [string](Get-JsonPropertyValue $Record 'url') + return [string]::Equals($friendlyName, "Local ($DistroName)", [StringComparison]::Ordinal) -and (Test-LocalGatewayUrl $url) + } + + function Test-ExternalGatewayRecord { + param([object]$Record) + + $isLocal = [bool](Get-JsonPropertyValue $Record 'isLocal') + $sshTunnel = Get-JsonPropertyValue $Record 'sshTunnel' + $url = [string](Get-JsonPropertyValue $Record 'url') + return (-not $isLocal) -and -not ($null -eq $sshTunnel -and (Test-LocalGatewayUrl $url)) + } + + function Remove-FileIfExists { + param( + [string]$Path, + [string]$Label + ) + + try { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + Write-GatewayLog "Deleted $Label." + } else { + Write-GatewayLog "$Label already absent." + } + } catch { + Add-CleanupWarning "Failed to delete $Label '$Path': $($_.Exception.Message)" + } + } + + function Remove-DirectoryIfExists { + param( + [string]$Path, + [string]$Label + ) + + try { + if (Test-Path -LiteralPath $Path -PathType Container) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + Write-GatewayLog "Deleted $Label directory." + } + } catch { + Add-CleanupWarning "Failed to delete $Label directory '$Path': $($_.Exception.Message)" + } + } + + function Remove-AutostartRegistryValue { + $runKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' + try { + $value = Get-ItemProperty -LiteralPath $runKey -Name 'OpenClawTray' -ErrorAction SilentlyContinue + if ($null -ne $value) { + Remove-ItemProperty -LiteralPath $runKey -Name 'OpenClawTray' -ErrorAction Stop + Write-GatewayLog 'Removed OpenClawTray autostart registry value.' + } else { + Write-GatewayLog 'OpenClawTray autostart registry value already absent.' + } + } catch { + Add-CleanupWarning "Failed to remove OpenClawTray autostart registry value: $($_.Exception.Message)" + } + } + + function Remove-SetupManagedGatewayRecords { + param([string]$DataDir) + + $gatewaysPath = Join-Path $DataDir 'gateways.json' + $registry = Read-JsonFile $gatewaysPath + if ($null -eq $registry) { + return [pscustomobject]@{ + RemainingCount = 0 + HasExternalGateways = $false + } + } + + $gatewayProperty = $registry.PSObject.Properties['gateways'] + $records = @() + if ($null -ne $gatewayProperty -and $null -ne $gatewayProperty.Value) { + $records = @($gatewayProperty.Value) + } + + $remaining = New-Object System.Collections.ArrayList + $removed = New-Object System.Collections.ArrayList + foreach ($record in $records) { + if (Test-SetupManagedLocalRecord $record) { + [void]$removed.Add($record) + } else { + [void]$remaining.Add($record) + } + } + + foreach ($record in $removed) { + $id = [string](Get-JsonPropertyValue $record 'id') + if ([string]::IsNullOrWhiteSpace($id)) { + continue + } + + $identityDir = Join-Path (Join-Path $DataDir 'gateways') $id + try { + if (Test-Path -LiteralPath $identityDir -PathType Container) { + Remove-Item -LiteralPath $identityDir -Recurse -Force -ErrorAction Stop + Write-GatewayLog "Deleted identity directory for local gateway record $id." + } + } catch { + Add-CleanupWarning "Failed to delete identity directory '$identityDir': $($_.Exception.Message)" + } + } + + if ($removed.Count -gt 0) { + try { + $registry.gateways = @($remaining.ToArray()) + $activeId = [string](Get-JsonPropertyValue $registry 'activeId') + if ($removed | Where-Object { [string](Get-JsonPropertyValue $_ 'id') -eq $activeId }) { + $registry.activeId = $null + } + + Write-JsonFileAtomic -Path $gatewaysPath -Value $registry + Write-GatewayLog "Removed $($removed.Count) setup-managed local gateway record(s)." + } catch { + Add-CleanupWarning "Failed to update gateways.json: $($_.Exception.Message)" + } + } else { + Write-GatewayLog 'No setup-managed local gateway records found.' + } + + $hasExternalGateways = $false + foreach ($record in @($remaining.ToArray())) { + if (Test-ExternalGatewayRecord $record) { + $hasExternalGateways = $true + break + } + } + + return [pscustomobject]@{ + RemainingCount = $remaining.Count + HasExternalGateways = $hasExternalGateways + } + } + + function Clear-RootDeviceTokenForRole { + param( + [string]$DataDir, + [string]$Role + ) + + $keyPath = Join-Path $DataDir 'device-key-ed25519.json' + $keyData = Read-JsonFile $keyPath + if ($null -eq $keyData) { + Write-GatewayLog "Root device identity file absent or unreadable for $Role token cleanup." + return + } + + $tokenPropertyName = if ($Role -eq 'node') { 'NodeDeviceToken' } else { 'DeviceToken' } + $scopesPropertyName = if ($Role -eq 'node') { 'NodeDeviceTokenScopes' } else { 'DeviceTokenScopes' } + $tokenProperty = $keyData.PSObject.Properties[$tokenPropertyName] + + if ($null -eq $tokenProperty -or [string]::IsNullOrEmpty([string]$tokenProperty.Value)) { + Write-GatewayLog "Root $Role device token already absent." + return + } + + try { + $tokenProperty.Value = $null + $scopesProperty = $keyData.PSObject.Properties[$scopesPropertyName] + if ($null -ne $scopesProperty) { + $scopesProperty.Value = $null + } + + Write-JsonFileAtomic -Path $keyPath -Value $keyData + Write-GatewayLog "Cleared root $Role device token." + } catch { + Add-CleanupWarning "Failed to clear root $Role device token: $($_.Exception.Message)" + } + } + + function Reset-OnboardingSettings { + param( + [string]$DataDir, + [bool]$PreserveNodeSettings + ) + + $settingsPath = Join-Path $DataDir 'settings.json' + $settings = Read-JsonFile $settingsPath + if ($null -eq $settings) { + Write-GatewayLog 'settings.json absent or unreadable; onboarding settings not reset.' + return + } + + $changed = $false + if ($settings.PSObject.Properties['GatewayUrl']) { + $settings.PSObject.Properties.Remove('GatewayUrl') + $changed = $true + } + + if (-not $PreserveNodeSettings -and $settings.PSObject.Properties['EnableNodeMode']) { + $settings.EnableNodeMode = $false + $changed = $true + } + + if (-not $PreserveNodeSettings -and $settings.PSObject.Properties['AutoStart']) { + $settings.AutoStart = $false + $changed = $true + } + + if (-not $changed) { + Write-GatewayLog 'No onboarding settings needed reset.' + return + } + + try { + Write-JsonFileAtomic -Path $settingsPath -Value $settings + Write-GatewayLog 'Reset onboarding settings.' + } catch { + Add-CleanupWarning "Failed to reset onboarding settings: $($_.Exception.Message)" + } + } + + function Remove-KeepaliveMarker { + param([string]$LocalDataDir) + + $markerDir = Join-Path $LocalDataDir 'wsl-keepalive' + $markerPath = Join-Path $markerDir "$DistroName.json" + Remove-FileIfExists -Path $markerPath -Label 'keepalive marker' + + try { + if ((Test-Path -LiteralPath $markerDir -PathType Container) -and -not (Get-ChildItem -LiteralPath $markerDir -Force -ErrorAction Stop | Select-Object -First 1)) { + Remove-Item -LiteralPath $markerDir -Force -ErrorAction Stop + Write-GatewayLog 'Deleted empty wsl-keepalive directory.' + } + } catch { + Add-CleanupWarning "Failed to remove empty wsl-keepalive directory '$markerDir': $($_.Exception.Message)" + } + } + + function Remove-WindowsGatewayArtifacts { + $dataDir = Resolve-AppDataDir + $localDataDir = Resolve-LocalDataDir + + Write-GatewayLog "Cleaning Windows-side local gateway artifacts. AppData='$dataDir'; LocalData='$localDataDir'." + + Remove-AutostartRegistryValue + Remove-FileIfExists -Path (Join-Path $dataDir 'setup-state.json') -Label 'legacy setup-state.json' + Remove-FileIfExists -Path (Join-Path $localDataDir 'setup-state.json') -Label 'setup-state.json' + Remove-FileIfExists -Path (Join-Path $localDataDir 'run.marker') -Label 'run.marker' + Remove-FileIfExists -Path (Join-Path $dataDir 'exec-policy.json') -Label 'exec-policy.json' + Remove-KeepaliveMarker -LocalDataDir $localDataDir + + $registryCleanup = Remove-SetupManagedGatewayRecords -DataDir $dataDir + if ($registryCleanup.HasExternalGateways) { + Write-GatewayLog 'External gateway records remain; preserving root device tokens.' + } else { + Clear-RootDeviceTokenForRole -DataDir $dataDir -Role 'operator' + Clear-RootDeviceTokenForRole -DataDir $dataDir -Role 'node' + } + + Reset-OnboardingSettings -DataDir $dataDir -PreserveNodeSettings:($registryCleanup.RemainingCount -gt 0) + Remove-DirectoryIfExists -Path (Join-Path $dataDir 'Logs') -Label 'AppData Logs' + Remove-DirectoryIfExists -Path (Join-Path $localDataDir 'Logs') -Label 'LocalAppData Logs' + } + + function Complete-GatewayCleanup { + param([string]$Message) + + Remove-WindowsGatewayArtifacts + Write-GatewayResult ` + -Succeeded $true ` + -ExitCode 0 ` + -Message $Message ` + -Details ([ordered]@{ artifactWarnings = @($script:cleanupWarnings) }) + Write-Host "OpenClaw local WSL gateway removed successfully." + exit 0 +} + +function Get-WslExePath { + $candidates = @( + (Join-Path $env:WINDIR 'Sysnative\wsl.exe'), + (Join-Path $env:WINDIR 'System32\wsl.exe') + ) + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { + return $candidate + } + } + + $command = Get-Command wsl.exe -ErrorAction SilentlyContinue + if ($command) { + return $command.Source + } + + return $null +} + +function Format-Arguments { + param([string[]]$Arguments) + + return ($Arguments | ForEach-Object { + if ($_ -match '\s') { + '"' + ($_ -replace '"', '\"') + '"' + } else { + $_ + } + }) -join ' ' +} + +function Invoke-Wsl { + param([string[]]$Arguments) + + $stdoutPath = [System.IO.Path]::GetTempFileName() + $stderrPath = [System.IO.Path]::GetTempFileName() + + try { + $process = Start-Process ` + -FilePath $script:WslPath ` + -ArgumentList $Arguments ` + -WindowStyle Hidden ` + -Wait ` + -PassThru ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath + + $stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue } else { '' } + $stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue } else { '' } + $output = (($stdout, $stderr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine + + Write-GatewayLog ("wsl.exe {0} exited {1}.{2}{3}" -f (Format-Arguments $Arguments), $process.ExitCode, [Environment]::NewLine, $output) + + return [pscustomobject]@{ + ExitCode = [int]$process.ExitCode + Output = $output + } + } finally { + Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue + } +} + +function Test-DistroNotFound { + param([string]$Output) + + if ([string]::IsNullOrWhiteSpace($Output)) { + return $false + } + + return $Output -match 'WSL_E_DISTRO_NOT_FOUND' -or + $Output -match 'There is no distribution with the supplied name' -or + $Output -match 'The specified distribution.*(could not be found|not found)' -or + $Output -match 'distribution.*not.*found' +} + +function Test-DistroListed { + param([string]$Output) + + if ([string]::IsNullOrWhiteSpace($Output)) { + return $false + } + + $distros = ($Output -replace "`0", '') -split '\r?\n' | ForEach-Object { $_.Trim() } + return $distros -contains $DistroName +} + +function Remove-GatewayDirectory { + $gatewayDirectory = Join-Path $AppRoot 'wsl\OpenClawGateway' + + if (-not (Test-Path -LiteralPath $gatewayDirectory)) { + Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" + return + } + + $gatewayItem = Get-Item -LiteralPath $gatewayDirectory -Force -ErrorAction Stop + if (($gatewayItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to recursively delete reparse point '$gatewayDirectory'." + } + + $lastError = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + try { + Remove-Item -LiteralPath $gatewayDirectory -Recurse -Force -ErrorAction Stop + if (-not (Test-Path -LiteralPath $gatewayDirectory)) { + Write-GatewayLog "Removed gateway directory: $gatewayDirectory" + return + } + } catch { + $lastError = $_.Exception.Message + Write-GatewayLog "Attempt $attempt failed to remove gateway directory '$gatewayDirectory': $lastError" + } + + Start-Sleep -Seconds 1 + } + + throw "Failed to remove gateway directory '$gatewayDirectory': $lastError" } -# --------------------------------------------------------------------------- -# Invoke CLI uninstall -# --------------------------------------------------------------------------- -$exitCode = 0 try { - & $exePath --uninstall --confirm-destructive --json-output $resultPath - $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + Ensure-AppRoot + Write-GatewayLog "Starting local gateway cleanup for $DistroName." + + $script:WslPath = Get-WslExePath + if (-not $script:WslPath) { + Write-GatewayLog 'wsl.exe was not found; removing stale gateway directory if present.' + Remove-GatewayDirectory + Complete-GatewayCleanup -Message 'wsl.exe was not found; no registered WSL gateway can be removed.' + } + + $listResult = Invoke-Wsl -Arguments @('--list', '--quiet') + if ($listResult.ExitCode -eq 0 -and -not (Test-DistroListed $listResult.Output)) { + Write-GatewayLog "WSL distro '$DistroName' is not registered; removing stale gateway directory if present." + Remove-GatewayDirectory + Complete-GatewayCleanup -Message "Local WSL gateway '$DistroName' was already unregistered." + } - 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." + $terminateResult = Invoke-Wsl -Arguments @('--terminate', $DistroName) + if ($terminateResult.ExitCode -ne 0) { + Write-GatewayLog "Ignoring terminate exit code $($terminateResult.ExitCode); unregister handles stopped or missing distros." } + + $shutdownResult = Invoke-Wsl -Arguments @('--shutdown') + if ($shutdownResult.ExitCode -ne 0) { + Write-GatewayLog "Ignoring shutdown exit code $($shutdownResult.ExitCode); unregister will still be attempted." + } + Start-Sleep -Seconds 2 + + $unregisterResult = Invoke-Wsl -Arguments @('--unregister', $DistroName) + if ($unregisterResult.ExitCode -ne 0 -and -not (Test-DistroNotFound $unregisterResult.Output)) { + Write-GatewayResult ` + -Succeeded $false ` + -ExitCode $unregisterResult.ExitCode ` + -Message "Failed to unregister WSL distro '$DistroName'." ` + -Details $unregisterResult.Output + exit $unregisterResult.ExitCode + } + + if ($unregisterResult.ExitCode -ne 0) { + Write-GatewayLog "Treating missing distro '$DistroName' as already removed." + } + + Remove-GatewayDirectory + + Complete-GatewayCleanup -Message "Local WSL gateway '$DistroName' removed." } 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 + $message = $_.Exception.Message + Write-GatewayLog "Local gateway cleanup failed: $message" + try { "[$(Get-Date -Format 'o')] $message" | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + Write-GatewayResult -Succeeded $false -ExitCode 1 -Message $message + Write-Warning $message + exit 1 } - -# Always exit 0 so Inno does not abort the broader uninstall. -exit 0 diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index 03e9c2890..4fc90824e 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -75,15 +75,56 @@ public void Installer_CreatesStartMenuEntrypointsForTraySetupAndSupport() } [Fact] - public void Installer_RemovesGeneratedAppStateOnUninstall() + public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() { var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - Assert.Contains("[UninstallRun]", iss); + Assert.DoesNotContain("[UninstallRun]", iss); + Assert.Contains("[Code]", iss); Assert.Contains("Uninstall-LocalGateway.ps1", iss); - Assert.Contains("Start-Sleep -Seconds 3", iss); - Assert.Contains("[UninstallDelete]", iss); - Assert.Contains(@"Type: filesandordirs; Name: ""{app}""", iss); + Assert.Contains("UninstallSilent()", iss); + Assert.Contains("LocalGatewayCleanupRequested := True", iss); + Assert.Contains("OpenClawGateway WSL distro", iss); + Assert.Contains("MB_YESNO", iss); + Assert.Contains("ExpandConstant('{sys}\\WindowsPowerShell\\v1.0\\powershell.exe')", iss); + Assert.Contains("ewWaitUntilTerminated", iss); + Assert.Contains("MB_RETRYCANCEL", iss); + Assert.Contains("DeleteGeneratedAppState", iss); + Assert.Contains("CurUninstallStep = usPostUninstall", iss); + Assert.Contains("DelTree(ExpandConstant('{app}'), True, True, True)", iss); + Assert.DoesNotContain("Start-Sleep -Seconds 3", iss); + Assert.DoesNotContain("--uninstall --confirm-destructive", iss); + Assert.DoesNotContain("[UninstallDelete]", iss); + } + + [Fact] + public void UninstallLocalGatewayScript_DirectlyUnregistersWslDistro() + { + var script = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "scripts", "Uninstall-LocalGateway.ps1")); + + Assert.Contains("$DistroName = 'OpenClawGateway'", script); + Assert.Contains("'--list', '--quiet'", script); + Assert.Contains("'--terminate', $DistroName", script); + Assert.Contains("'--shutdown'", script); + Assert.Contains("'--unregister', $DistroName", script); + Assert.Contains("Start-Sleep -Seconds 2", script); + Assert.Contains("Remove-GatewayDirectory", script); + Assert.Contains("Remove-WindowsGatewayArtifacts", script); + Assert.Contains("gateways.json", script); + Assert.Contains("device-key-ed25519.json", script); + Assert.Contains("OpenClawTray", script); + Assert.Contains("setup-state.json", script); + Assert.Contains("wsl-keepalive", script); + Assert.Contains("Test-DistroListed", script); + Assert.Contains("Test-DistroNotFound", script); + Assert.Contains("FileAttributes]::ReparsePoint", script); + Assert.Contains("Refusing to recursively delete reparse point", script); + Assert.Contains("for ($attempt = 1; $attempt -le 6; $attempt++)", script); + Assert.Contains("exit $unregisterResult.ExitCode", script); + Assert.DoesNotContain("OpenClaw.Tray.WinUI.exe", script); + Assert.DoesNotContain("OpenClaw.SetupEngine.UI.exe", script); + Assert.DoesNotContain("--headless", script); + Assert.DoesNotContain("--confirm-destructive", script); } [Fact] From e9a15969a80ebcd1b107171bd65b31a7a081fe63 Mon Sep 17 00:00:00 2001 From: Ranjesh Jaganathan Date: Fri, 29 May 2026 20:34:17 -0700 Subject: [PATCH 5/5] Add gateway terminal controls Adds Connection page terminal access for setup-managed WSL gateways and SSH-tunneled gateway hosts. Adds WSL-only Start, Stop, and Restart actions that run gateway CLI commands in the managed distro. Validation: .\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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/ConnectionPage.xaml | 93 ++++++ .../Pages/ConnectionPage.xaml.cs | 289 ++++++++++++++++++ .../Services/GatewayHostAccess.cs | 93 ++++++ .../Services/GatewayTerminalLauncher.cs | 155 ++++++++++ .../Services/WslGatewayController.cs | 94 ++++++ .../GatewayHostAccessTests.cs | 80 +++++ ...atewayTerminalLaunchCommandBuilderTests.cs | 80 +++++ .../OpenClaw.Tray.Tests.csproj | 3 + .../WslGatewayControllerTests.cs | 97 ++++++ 9 files changed, 984 insertions(+) create mode 100644 src/OpenClaw.Tray.WinUI/Services/GatewayHostAccess.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/GatewayTerminalLauncher.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/WslGatewayController.cs create mode 100644 tests/OpenClaw.Tray.Tests/GatewayHostAccessTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/GatewayTerminalLaunchCommandBuilderTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/WslGatewayControllerTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index 7e4290313..8addc3deb 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -165,6 +165,17 @@ Visibility="Collapsed" Click="OnStripPrimaryClicked" AutomationProperties.AutomationId="StripPrimaryAction"/> + +