diff --git a/.gitignore b/.gitignore index 7a60a7fa..5c10c361 100644 --- a/.gitignore +++ b/.gitignore @@ -349,3 +349,5 @@ test_ws.py # Local visual test output visual-test-output/ + +.squad/ diff --git a/.squad/decisions/inbox/aaron-actually-fix-2-3.md b/.squad/decisions/inbox/aaron-actually-fix-2-3.md new file mode 100644 index 00000000..801630c7 --- /dev/null +++ b/.squad/decisions/inbox/aaron-actually-fix-2-3.md @@ -0,0 +1,31 @@ +# Aaron: actual fixes for PR #274 bugs 2 and 3 + +## Bug 2 — tray quick-chat broken + +Traced tray left-click to `InitializeTrayIcon()` -> `_trayIcon.Selected += OnTrayIconSelected` -> `OnTrayIconSelected()` -> `ShowChatWindow()`. The quick-chat path did use `ShowChatWindow`, but it resolved only `settings.Token` while the working operator client resolves `settings.Token`, `settings.BootstrapToken`, then stored `DeviceIdentity.DeviceToken` via `GatewayCredentialResolver`. + +Changes: +- `App.ShowChatWindow()` and chat pre-warm now use the same `GatewayCredentialResolver` pattern as the operator client. +- `ShowChatWindow()` calls `ChatWindow.RefreshCredentials()` on every tray click, including newly-created windows. +- `ChatWindow.RefreshCredentials()` always rebuilds the URL and navigates initialized WebView2 to it; it no longer returns early when the same stale URL is cached. +- Added diagnostic logs: `[ChatWindow] Quick-chat credentials resolved from ...` and `[ChatWindow] Refreshing to ...`. +- Applied Mattingly Bug 4 handoff: bootstrap injection now runs from `ChatWindow` after successful WebView navigation. + +Manual validation for Mike: click tray icon; tail `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` and look for `[ChatWindow] Refreshing to ...`, then verify chat loads without login loop. + +## Bug 3 — pairing toast notification storm + +Searched toast paths and traced pairing notifications through `WindowsNodeClient` direct `PairingStatusChanged` emitters (`pairing.requested`, `pairing.resolved`, `NOT_PAIRED`, and `hello-ok`) plus tray toasts in `App.OnPairingStatusChanged()` and `App.OnNodeStatusChanged()`. + +Changes: +- Routed all `WindowsNodeClient` pairing emitters through `EmitPairingStatusOnTransition()`; duplicates now log `[NODE] Suppressing duplicate pairing status event: ...`. +- Added a toast-boundary 30-second dedupe in `App.ShowToast(builder, toastTag, deviceId)`, keyed by `(toastTag, deviceId)`. +- Tagged node pairing pending/paired/rejected and node-connected toasts. +- Suppressed the node-connected toast if a node-paired toast was just shown for the same device. +- Added diagnostic logs: `[ToastDeduper] Showing toast tag=... deviceId=...` and `[ToastDeduper] Suppressed duplicate toast tag=... deviceId=...`. + +Manual validation for Mike: complete pairing; expect exactly one node-paired toast and log line `[ToastDeduper] Showing toast tag=node-paired deviceId=...`; duplicates should log suppression. + +## Validation + +Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`. diff --git a/.squad/decisions/inbox/mattingly-actually-fix-1-4-5.md b/.squad/decisions/inbox/mattingly-actually-fix-1-4-5.md new file mode 100644 index 00000000..2adb6085 --- /dev/null +++ b/.squad/decisions/inbox/mattingly-actually-fix-1-4-5.md @@ -0,0 +1,45 @@ +# Mattingly: actual fixes for PR #274 bugs 1, 4, 5 + +## Bug 1 — chat window auto-launch on Finish + +Changed `OnboardingWindow.OnWizardComplete()` to ignore `WizardLifecycleState == "complete"`. The signal now is: the window is completing from `OnboardingRoute.Ready` and `StartupSetupState.RequiresSetup(settings, identityDataPath)` is false. That is the path the Finish button actually takes: `Ready` page Finish -> `OnboardingState.Complete()` -> `OnOnboardingFinished()` -> `OnWizardComplete()`. + +Log to validate: `[OnboardingWindow] OnWizardComplete launching chat`. + +## Bug 4 — BOOTSTRAP.md kickoff injection + +Hardened `BootstrapMessageInjector`: + +- Traverses shadow DOM for Lit UI controls. +- Probes and logs visible control count: `[OpenClaw] Bootstrap probe controls=N`. +- Supports `textarea`, text inputs, contenteditable, and role=textbox. +- Uses native value setters so controlled inputs see the value. +- Clicks Send/form-submit/Enter fallbacks. +- Does **not** burn `HasInjectedFirstRunBootstrap` when the script returns `no-input`; the gate is only persisted on `sent`. + +Aaron still needs to move the call site to after successful chat navigation because current `App.ShowChatWindow()` can see `TryGetScriptExecutor()==null` when the WebView2 is still initializing. + +Exact handoff line for Aaron in `ChatWindow.xaml.cs` NavigationCompleted success branch after `RequestChatInputFocus();`: + +```csharp +OpenClawTray.Services.BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); +_ = OpenClawTray.Services.BootstrapMessageInjector.InjectAsync(exec, ((App)Microsoft.UI.Xaml.Application.Current).Settings, initialDelayMs: 500); +``` + +If `App.Settings` is not exposed, add an internal property returning `_settings`, or route the existing `_settings` from `App.ShowChatWindow()` into a ChatWindow method. The important point is that the call must happen inside `NavigationCompleted` when `e.IsSuccess` is true. + +## Bug 5 — autostart default/toggle + +Changed `ReadyPage` to render the toggle ON as a safety default, then sync to `Settings.AutoStart` on mount and immediately call `AutoStartManager.SetAutoStart()` so a user who never toggles still gets the Run-key. The toggle handler still persists settings and updates the Run-key immediately. + +Changed `AutoStartManager.SetAutoStart()` to use `Registry.CurrentUser.CreateSubKey(...)` instead of `OpenSubKey(...)`, so it can create the Run key/value when missing instead of silently returning. + +Manual registry validation: + +```powershell +Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name OpenClawTray -ErrorAction SilentlyContinue +``` + +## Validation + +Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`. diff --git a/.squad/decisions/inbox/mattingly-finish-actually-hub.md b/.squad/decisions/inbox/mattingly-finish-actually-hub.md new file mode 100644 index 00000000..fcb3318a --- /dev/null +++ b/.squad/decisions/inbox/mattingly-finish-actually-hub.md @@ -0,0 +1,58 @@ +# Mattingly — PR #274 finish should open Hub chat + +## Audit + +Command requested: `grep -rn "launching chat\|ShowChatWindow\|ShowHub\|OnWizardComplete" src/OpenClaw.Tray.WinUI` (run with ripgrep equivalent because `rg` was not on PATH in PowerShell; Copilot rg tool was used against the same tree). + +HEAD before this fix: `8c68111 Launch hub chat after onboarding`. + +Matches found: + +- `src/OpenClaw.Tray.WinUI/App.xaml.cs:498` — tray icon click calls `ShowChatWindow()`. +- `src/OpenClaw.Tray.WinUI/App.xaml.cs:501` — `ShowChatWindow()` method. +- `src/OpenClaw.Tray.WinUI/App.xaml.cs:542` — `ShowChatWindow` deferred-show warning string. +- `src/OpenClaw.Tray.WinUI/App.xaml.cs:644` — tray menu `openchat` calls `ShowChatWindow()`. +- `src/OpenClaw.Tray.WinUI/App.xaml.cs:562,581,647,652,654,710,1043,1855,2809,2928,3048,3101,3603,4265` — `ShowHub(...)` method/call sites. +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:587` — Finish event calls `OnWizardComplete()`. +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:596` — X/Closed path calls `OnWizardComplete()`. +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:620` — single `OnWizardComplete()` implementation. +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:649` — required diagnostic log line. +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:650,658,660,667,671,675,679` — deferred Hub chat launch helper. +- Documentation/comment-only references in `ChatWindow.xaml.cs`, `HubWindow.xaml.cs`, `VoiceOverlayWindow.xaml.cs`, and `OnboardingState.cs`. + +The literal old string `launching chat` has no remaining source match in this worktree. + +## Diagnosis + +The log Mike captured (`[OnboardingWindow] OnWizardComplete launching chat`) corresponds to the pre-`8c68111` body of `OnboardingWindow.OnWizardComplete` in `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs`, the only wizard-completion implementation. In the current clean worktree, `8c68111` did change that exact method to log `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab` and call `App.ShowHub("chat")`. + +I did not find a second `OnWizardComplete`, overload, post-finish hook, or hidden `ShowHub` fallback to `ChatWindow`. `App.ShowHub(...)` creates a `HubWindow` when `_hubWindow` is null/closed, sets state, navigates, and activates it. The remaining `ShowChatWindow()` calls are tray quick-chat entry points, not wizard finish paths. + +The prior fix therefore did not take in the live run because that run was not executing source/binaries containing `8c68111` (or was launched from another stale build/worktree). To make the wizard finish path more robust and easier to verify, this follow-up keeps the exact required log line and dispatches `ShowHub("chat")` at low priority after the wizard close event settles, so the Hub opens after the wizard finishes closing and cannot lose an ordering fight to wizard teardown. + +## Changes + +- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs` + - Keeps the required log line: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`. + - Replaces the inline post-finish call with `ShowHubChatAfterWizardClose()`. + - The helper dispatches `App.ShowHub("chat")` on the UI dispatcher at low priority, with a direct fallback if enqueue fails. + - Adds an explicit warning if `Application.Current` is not the tray `App`. + - Updates stale bootstrap comment from `App.ShowChatWindow()` to HubWindow chat navigation. + +- `src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs` + - Updates stale route comment to say the Ready path launches the Hub chat tab, not the old chat window. + +- `src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs` + - Updates stale comment to describe HubWindow chat page injection instead of post-wizard `App.ShowChatWindow()`. + +## Validation + +- `git pull --rebase fork feat/wsl-gateway-clean` before commit: already up to date. +- `./build.ps1`: passed. +- Tests intentionally not run per active directive: NO tests, incremental `./build.ps1` only. + +## Verification log line + +Mike should verify this exact line on the next finish run: + +`[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab` diff --git a/.squad/decisions/inbox/mattingly-finish-launch-hubwindow.md b/.squad/decisions/inbox/mattingly-finish-launch-hubwindow.md new file mode 100644 index 00000000..395d47b5 --- /dev/null +++ b/.squad/decisions/inbox/mattingly-finish-launch-hubwindow.md @@ -0,0 +1,21 @@ +# Mattingly: Finish opens HubWindow chat + +## Summary +Onboarding completion from Ready now launches the full HubWindow directly on the Chat tab instead of the standalone quick-chat ChatWindow. + +## Changes +- `src\OpenClaw.Tray.WinUI\App.xaml.cs` + - Made `ShowHub(string? navigateTo = null, bool activate = true)` internal so onboarding can reuse the existing hub-opening path. +- `src\OpenClaw.Tray.WinUI\Onboarding\OnboardingWindow.cs` + - Replaced `ShowChatWindow()` completion launch with `ShowHub("chat")`. + - Added diagnostic log: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`. +- `src\OpenClaw.Tray.WinUI\Pages\ChatPage.xaml.cs` + - Wired `BootstrapMessageInjector.InjectAsync` into the Hub chat WebView2 `NavigationCompleted` success path, matching the standalone `ChatWindow` gated injection behavior. + +## Validation +- Ran `./build.ps1` successfully after the code change. +- Per active session directive, did not run tests after the fix. + +## Architectural notes +- Hub already exposes tag-based navigation through `NavigateTo("chat")`; `ShowHub("chat")` selects the existing NavigationView item and navigates to `ChatPage`. +- Bootstrap injection remains wired in both standalone `ChatWindow` and Hub `ChatPage`; the existing global `Settings.HasInjectedFirstRunBootstrap` gate ensures only one path injects. diff --git a/docs/wsl-owner-open-issues.md b/docs/wsl-owner-open-issues.md new file mode 100644 index 00000000..eaa607f7 --- /dev/null +++ b/docs/wsl-owner-open-issues.md @@ -0,0 +1,369 @@ +# OpenClaw Windows local gateway: WSL-owner Q&A + +This document is the structured record of the questions we asked Craig Loewen +(WSL) about the Windows OpenClaw local-gateway design, and Craig's answers. +It is the canonical "why does the architecture look like this?" reference +for the Windows local-gateway PR. + +Companion: [`docs/wsl-owner-validation.md`](wsl-owner-validation.md) +describes the resulting design as it ships. + +**Status legend:** ✅ Answered (verbatim or paraphrased Craig answer +recorded). 🟡 Open. + +**Source:** Craig Loewen's review of the prototype `wsl-owner-open-issues.md` +(2026-05-04). His answers are summarized authoritatively in +`.squad/decisions.md` under "Decision: Craig Loewen's WSL Answers +(Authoritative)" and underpinned the Phase 3 plan revision in +`.squad/decisions-archive.md`. The architecture statements below are +paraphrased; Mike's relayed verbatim Q&A lives in the squad decisions thread, +not in the public PR. + +The design is built on three coupled choices: + +1. **Distribution model:** create a dedicated `OpenClawGateway` instance from + the Store Ubuntu-24.04 package and configure it post-install — no custom + OpenClaw rootfs. +2. **Networking model:** loopback only between the Windows tray and the + gateway in WSL — no WSL-IP fallback, no `lan`/`auto` bind. +3. **Lifecycle model:** instance-scoped `wsl --terminate OpenClawGateway` for + repair; user-systemd plus a tray-owned keepalive for liveness; no global + `wsl --shutdown` and no global `.wslconfig` mutation. + +The goal remains a low-maintenance implementation that uses the public +OpenClaw Linux installer unchanged and does not maintain a custom OpenClaw +Linux distribution. + +## Final shape + +1. The Windows tray verifies WSL/WSL2 availability. +2. The tray creates a dedicated WSL2 instance named `OpenClawGateway` from + the Store Ubuntu-24.04 package: + ```powershell + wsl.exe --install Ubuntu-24.04 ` + --name OpenClawGateway ` + --location "$env:LOCALAPPDATA\OpenClawTray\wsl" ` + --no-launch ` + --version 2 + ``` +3. The tray launches the instance as root and applies OpenClaw-owned + configuration: + - create the `openclaw` user; + - create `/home/openclaw/.openclaw`, `/opt/openclaw`, + `/var/lib/openclaw`, and `/var/log/openclaw`; + - write `/etc/wsl.conf` and `/etc/wsl-distribution.conf`; + - set the default user to `openclaw` via + `wsl --manage OpenClawGateway --set-default-user openclaw`; + - terminate only `OpenClawGateway` so WSL config takes effect. +4. The tray runs the public OpenClaw Linux installer inside the instance: + `https://openclaw.ai/install-cli.sh` with prefix `/opt/openclaw`. No + forked or patched gateway installer. +5. The tray uses upstream OpenClaw CLI/service commands to configure and + start the gateway. +6. The tray calls upstream `openclaw qr --json`, consumes the upstream + setup-code/bootstrap-token handoff, and pairs Windows tray operator and + Windows tray node sessions; both device tokens land in + `%APPDATA%\OpenClawTray\device-key-ed25519.json`. + +## Issue 1: Ubuntu Store package + post-install configuration + +### Q1.1 — Is `wsl --install Ubuntu-24.04 --name OpenClawGateway --location ... --no-launch --version 2` a supported primitive for a Windows app creating a dedicated app-owned WSL instance? + +**Status:** ✅ Answered. + +**Craig:** Yes — supportable. This is the canonical primitive for an +app-owned WSL instance. + +**Implication:** `LocalGatewaySetup.cs` issues exactly this command. The +clean port removed `--web-download`, `--from-file`, and any rootfs-import +fallback. + +### Q1.2 — Is it acceptable to treat the install as successful when post-conditions pass, even if the `wsl --install` process itself hangs or exits unclearly? + +**Status:** ✅ Answered. + +**Craig:** **Trust the exit code.** The hang-fallback pattern from the +prototype is not needed. + +**Implication:** The clean engine treats `wsl --install` exit 0 as the +success signal, and additionally confirms `OpenClawGateway` appears in +`wsl --list --quiet` to defend against the "winget-style" failure mode where +exit 0 reports success without registering a distro (see Q1.3). Non-zero +exit ⇒ install failure; no postcondition-on-hang path. + +### Q1.3 — Should we prefer generic `Ubuntu`, explicit `Ubuntu-24.04`, `--web-download`, `--from-file`, or another source for the default path? + +**Status:** ✅ Answered. + +**Craig:** Use **explicit `Ubuntu-24.04`**, not generic `Ubuntu`. No +`--web-download` and no `--from-file` are needed. + +**Implication:** The clean install command is pinned to `Ubuntu-24.04`. The +prototype's "generic `Ubuntu` channel was more reliable on this dev machine" +observation is not a basis for a final product default. + +Empirical confirmation (2026-05-04, 20-iter harness on Windows 10.0.26200, +WSL 2.6.3.0): `wsl --install Ubuntu-24.04 --name --location +--no-launch --version 2` succeeded **10/10**; `winget install --id +Canonical.Ubuntu.2404 -e --silent --accept-source-agreements +--accept-package-agreements --disable-interactivity` succeeded **0/10** +(stages the launcher APPX but never registers a WSL distro under +`--silent --disable-interactivity`). Raw artifacts: +`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`. + +### Q1.4 — What is the recommended enterprise/offline fallback when Store access is blocked? + +**Status:** ✅ Answered. + +**Craig:** Modern WSL distributions are no longer Store-gated; an offline +fallback is **not needed** for this PR. + +**Implication:** No offline fallback path ships in this PR. If a future +enterprise scenario surfaces a real blocker, that decision can be revisited +separately. + +### Q1.5 — Are `automount=false`, `interop=false`, and `appendWindowsPath=false` appropriate for this managed instance? + +**Status:** ✅ Answered. + +**Craig:** Yes — all three settings are appropriate for an app-owned +appliance. + +**Implication:** `/etc/wsl.conf` ships with all three disabled (see +`docs/wsl-owner-validation.md`). + +### Q1.6 — Are there WSL/systemd/machine-id/DNS/timezone details we should explicitly repair or validate after cloning/configuring an Ubuntu instance? + +**Status:** ✅ Answered. + +**Craig:** **No post-clone repairs needed** — machine-id / DNS / timezone +work as delivered. + +**Implication:** The setup engine does not regenerate `/etc/machine-id`, +does not rewrite `/etc/resolv.conf`, and does not touch timezone state. It +relies on `useWindowsTimezone=true` in `/etc/wsl.conf` for clock alignment. + +### Q1.7 — Should OpenClaw avoid writing `/etc/wsl-distribution.conf`, or is it appropriate to suppress shortcuts/terminal profile for the dedicated instance? + +**Status:** ✅ Answered. + +**Craig:** Use both `wsl.conf` and `wsl-distribution.conf`. Suppressing +shortcut/terminal entries is the correct application of +`wsl-distribution.conf` for a privately managed instance. + +**Implication:** The setup engine writes `/etc/wsl-distribution.conf` with +`shortcut.enabled=false` and `terminal.enabled=false`. + +## Issue 2: Local networking between Windows and the WSL gateway + +### Q2.1 — Is Windows localhost forwarding to a WSL2 service reliable enough to make `loopback` the final default? + +**Status:** ✅ Answered. + +**Craig:** **Yes — loopback only.** Windows localhost forwarding to a WSL2 +service is a reliable core WSL promise. + +**Implication:** Gateway binds to loopback inside WSL on `:18789`. Windows +tray connects via `http://localhost:18789` / `ws://localhost:18789`. The +prototype's earlier observations of localhost-forwarding flakiness were +attributed to other lifecycle issues (see Issue 3) and not to the forwarding +contract itself. + +### Q2.2 — If localhost forwarding fails, is WSL-IP fallback a supported/recommended pattern for a Windows app-owned WSL instance? + +**Status:** ✅ Answered. + +**Craig:** **No.** WSL-IP fallback is not the recommended pattern. + +**Implication:** The clean port has **no** WSL-IP fallback. The endpoint +resolver does not enumerate WSL interface addresses, does not run +`hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp` inside WSL, and +returns exactly one candidate: `http://localhost:18789`. + +### Q2.3 — Is `gateway.bind=lan` inside the WSL instance acceptable for the fallback path, assuming the Windows tray still only advertises/selects local endpoints by default? + +**Status:** ✅ Answered. + +**Craig:** **No** — loopback only. + +**Implication:** The setup engine never writes `gateway.bind=lan`. The +runtime configuration surface for `gateway.bind` was removed. + +### Q2.4 — Should we implement `auto` bind promotion instead of defaulting to `lan`? + +**Status:** ✅ Answered. + +**Craig:** **No.** Loopback only; no `auto` promotion. + +**Implication:** No promotion logic exists in the clean port. There is one +bind mode, and it is loopback. + +### Q2.5 — Are there WSL NAT, mirrored networking, firewall, or portproxy recommendations we should follow while still avoiding global `.wslconfig` changes? + +**Status:** ✅ Answered. + +**Craig:** No — loopback forwarding works without any of those +modifications. + +**Implication:** The tray does not write to `.wslconfig`, does not configure +mirrored networking, does not add Windows firewall rules, and does not run +`netsh interface portproxy` for normal local-gateway operation. + +### Q2.6 — What diagnostics should we capture before asking users/maintainers to file WSL networking bugs? + +**Status:** ✅ Answered. + +**Craig:** Point at ****. Do not scrape WSL internal +log files from the product. + +**Implication:** On any setup or networking failure, the +`LocalSetupProgressPage` shows an aka.ms/wsllogs hint, the validation +script's `Save-DiagnosticsSnapshot` records `wslLogsHelp = +https://aka.ms/wsllogs`, and the run summary appends a "Diagnostics: see +https://aka.ms/wsllogs..." note. The product captures only its own state +(Windows-side `:18789` listener snapshot, loopback `/health` probe, +redacted setup-state.json) and a generated repro guide. + +## Issue 3: WSL gateway lifecycle and service ownership + +### Q3.1 — For an app-owned WSL appliance, should the gateway be a user-systemd service, a root/system service wrapper, or something else? + +**Status:** ✅ Answered. + +**Craig:** Both **user-systemd** and a **tray-owned keepalive** are +acceptable for this shape. + +**Implication:** The clean port uses upstream OpenClaw service primitives +under the `openclaw` user, plus a tray-owned WSL keepalive +(`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) while +local-gateway mode is active. Readiness still requires Windows-side +`/health` to succeed — `systemctl active` alone does not imply Windows +reachability. + +### Q3.2 — Is `loginctl enable-linger openclaw` expected to be reliable in this WSL shape, or should we avoid depending on it? + +**Status:** ✅ Answered. + +**Craig:** Linger is acceptable for this shape (alongside the tray +keepalive). + +**Implication:** Setup runs `loginctl enable-linger openclaw`. The tray +keepalive remains as belt-and-suspenders for the active local-gateway +window. + +### Q3.3 — Is a tray-owned keepalive process acceptable, or should it be treated as validation-only? + +**Status:** ✅ Answered. + +**Craig:** Acceptable as a product primitive (see Q3.1). It is not +validation-only. + +**Implication:** The keepalive ships as part of the runtime, not just as a +test scaffold. + +### Q3.4 — Is instance-scoped `wsl --terminate OpenClawGateway` the right repair/restart primitive? + +**Status:** ✅ Answered. + +**Craig:** **Yes.** Use `wsl --terminate OpenClawGateway` only. **Never** +global `wsl --shutdown`. + +**Implication:** Setup, repair, validation, and removal paths all use +`wsl --terminate OpenClawGateway`. `git grep 'wsl --shutdown'` over the +clean worktree returns no product or validation hits. + +### Q3.5 — Are there cases where global `wsl --shutdown` is recommended or unavoidable, despite our desire to avoid it? + +**Status:** ✅ Answered. + +**Craig:** **No.** Do not issue `wsl --shutdown` from this product. + +**Implication:** Recreate / FreshMachine validation scenarios use +`wsl --unregister OpenClawGateway` for destructive cleanup. They never +issue a global shutdown. + +### Q3.6 — What lifecycle diagnostics should the tray collect when WSL reports the service active but Windows cannot connect? + +**Status:** ✅ Answered. + +**Craig:** Same answer as Q2.6 — point at ; the +product should not scrape WSL logs. + +**Implication:** The product collects only its own state and points at the +WSL-team-owned diagnostics page. See Q2.6. + +## Mac app comparison: operator vs node + +The macOS app runs operator/UI and a local Mac node from the same app +binary/process via separate gateway sessions: + +- `GatewayConnection.shared` owns one `GatewayChannelActor` for + operator/UI scopes (`role: "operator"`, `clientMode: "ui"`). +- `MacNodeModeCoordinator.shared.start()` owns a separate + `GatewayNodeSession` and `MacNodeRuntime` (`role: "node"`, + `clientId: "openclaw-macos"`, capabilities for canvas / screen / browser + / etc.), connecting to the same gateway URL over a distinct WebSocket. +- In local mode, `GatewayProcessManager` manages the local gateway via + launchd / OpenClaw CLI behavior; in remote mode, + `ConnectionModeCoordinator` stops the local gateway and uses + `NodeServiceManager.start()` against the remote gateway. + +**Implication for Windows (decided by Mike):** The Windows tray pairs as +**both operator and node** against the local gateway, mirroring the macOS +in-app node model. There is **no separate WSL-internal worker** in this +PR. `StartWorker` / `PairWorker` phases were dropped; the +`PreserveWorkerData` parameter and `worker_data_preserved` lifecycle step +were removed in Phase 3 cleanup. + +If a future scope adds a Linux worker inside the WSL gateway instance, it +will require a separate upstream-supported install/start/list proof and a +new owner decision — not a re-litigation of the current PR. + +## Architectural decisions captured + +For traceability, the high-order decisions implied by Craig's answers are: + +1. **Distribution model** — Store Ubuntu-24.04 + post-install configuration; + no custom rootfs; no offline fallback. (Q1.1, Q1.3, Q1.4) +2. **Configuration** — `wsl.conf` (systemd, automount/interop/appendPath + off, default user `openclaw`, `useWindowsTimezone=true`) + + `wsl-distribution.conf` (no shortcut, no terminal). No post-clone + repairs. (Q1.5, Q1.6, Q1.7) +3. **Networking** — Loopback only, port 18789. No WSL-IP fallback. No + `lan`/`auto` bind. No `.wslconfig` / portproxy / firewall mutation. + (Q2.1–Q2.5) +4. **Lifecycle** — User-systemd + tray keepalive. Linger acceptable. + `wsl --terminate OpenClawGateway` for repair. **Never** global + `wsl --shutdown`. (Q3.1–Q3.5) +5. **Diagnostics** — `https://aka.ms/wsllogs`. No internal log scraping. + (Q2.6, Q3.6) +6. **Roles in scope** — Windows tray operator + Windows tray node. + Worker-in-WSL out of scope. (Mac app comparison + Mike's Phase-0 + decision.) + +These decisions are reflected one-for-one in: + +- `src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs` +- `src/OpenClaw.Tray.WinUI/App.xaml.cs` (factory + identity-path wiring) +- `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` +- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs` +- `src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs` +- `scripts/validate-wsl-gateway.ps1` (4 scenarios) +- `scripts/reset-openclaw-wsl-validation-state.ps1` (exact-target gated + cleanup) + +## Open follow-ups + +These are not open architecture questions for Craig — they are tracked +work items that intentionally fall outside this PR: + +- **Off-box / LAN / phone reachability via OpenClaw relay.** Blocked on + relay ownership / protocol clarity. Not addressed in this PR. +- **`winget install Microsoft.WSL` as a platform repair fallback.** Deeper + research in flight; does not change the Phase 3 decision to use + `wsl --install` for distro creation in this PR. +- **Onboarding copy localization.** `Onboarding_SetupWarning_*` / + `Onboarding_LocalSetupProgress_*` resw entries to be added across + supported locales after Mike signs off final copy. + +No open questions for Craig remain that block this PR. diff --git a/docs/wsl-owner-validation.md b/docs/wsl-owner-validation.md new file mode 100644 index 00000000..e4836820 --- /dev/null +++ b/docs/wsl-owner-validation.md @@ -0,0 +1,384 @@ +# OpenClaw Windows local gateway: WSL design validation + +This document describes the WSL design that ships in this PR. It reflects Craig +Loewen's authoritative review of `docs/wsl-owner-open-issues.md` (verbatim Q&A +reproduced inline in that companion doc). Where the prototype enumerated +options, this version states the chosen design. + +The current scope is: + +- A dedicated app-owned **Ubuntu-24.04** WSL2 instance named `OpenClawGateway`, + created from the standard Ubuntu Store package and then configured by the + Windows tray. +- The public OpenClaw Linux installer (`https://openclaw.ai/install-cli.sh`) + runs unchanged inside that instance with prefix `/opt/openclaw`. +- **Loopback-only** local networking (`http://localhost:18789`) between the + Windows tray and the gateway. +- Repair / restart via instance-scoped `wsl --terminate OpenClawGateway`. +- Diagnostics on failure pointed at . +- The Windows tray pairs as both **operator** and **node** against the local + gateway (matching the macOS app's in-app node model). No worker-in-WSL is + installed by the Windows tray in this PR. + +Out of scope for this PR (explicitly): + +- No custom OpenClaw rootfs / OpenClaw-distributed Linux image. +- No `--web-download` / `--from-file` / signed offline-base-artifact fallback. +- No WSL-IP / `lan` / `auto`-bind fallback. No `gateway.bind` overrides. +- No global `.wslconfig` mutation. No global `wsl --shutdown` from any product + or validation path. +- No `\\wsl$` or `\\wsl.localhost` file I/O. All WSL file operations go through + `wsl.exe -d OpenClawGateway -- ...`. + +## High-level user experience + +1. User installs or opens the Windows tray app. +2. The first onboarding page (`SetupWarningPage`) offers **Set up locally** + (default) or **Advanced setup**. +3. **Set up locally** opens `LocalSetupProgressPage`, which drives + `LocalGatewaySetupEngine` to: + - preflight the WSL host; + - create the `OpenClawGateway` instance from Ubuntu-24.04; + - apply OpenClaw-owned WSL configuration (`/etc/wsl.conf`, + `/etc/wsl-distribution.conf`, `openclaw` user, state directories); + - install OpenClaw via the public installer; + - prepare and start the gateway service; + - mint a bootstrap setup-code via `openclaw qr --json`; + - pair the Windows tray operator and Windows tray node; + - verify end-to-end reachability over loopback. +4. On terminal failure, the page surfaces a link to ; + no internal log scraping is attempted. + +## End-state architecture + +```mermaid +flowchart LR + subgraph Windows["Windows user session"] + Tray["OpenClaw Tray app"] + Identity["%APPDATA%\OpenClawTray\
device-key-ed25519.json (operator + node)"] + Engine["LocalGatewaySetupEngine"] + WslFeature["Windows WSL platform"] + end + + subgraph WSL["WSL2: OpenClawGateway"] + Ubuntu["Ubuntu-24.04 (Store)"] + WslConf["/etc/wsl.conf
systemd=true
automount=false
interop=false
appendWindowsPath=false
default user=openclaw"] + DistroConf["/etc/wsl-distribution.conf
shortcut=false
terminal=false"] + Systemd["systemd"] + Installer["public installer
install-cli.sh
--prefix /opt/openclaw"] + GatewaySvc["openclaw gateway
bind=loopback :18789"] + State["/var/lib/openclaw"] + end + + Tray --> Engine + Engine -->|"wsl --install Ubuntu-24.04 --name OpenClawGateway --location \OpenClawTray\wsl --no-launch --version 2"| WslFeature + WslFeature --> Ubuntu + Ubuntu --> WslConf + Ubuntu --> DistroConf + WslConf --> Systemd + Engine -->|"wsl -d OpenClawGateway -u root -- bash install-cli.sh"| Installer + Installer --> GatewaySvc + Systemd --> GatewaySvc + GatewaySvc --> State + Tray -->|"http://localhost:18789 (operator + node WebSocket sessions)"| GatewaySvc + Tray --> Identity +``` + +## WSL touch points + +### Dedicated WSL instance lifecycle + +The tray treats WSL as an application-owned runtime boundary and uses a single +dedicated WSL2 instance named `OpenClawGateway`. The base is **Ubuntu-24.04** +from the Store; the OpenClaw-owned configuration is applied after the instance +is laid down. + +| Operation | WSL command | Scope | +| --- | --- | --- | +| Preflight | `wsl.exe --status`, `wsl.exe --list --verbose` | Read-only WSL capability checks | +| Instance creation | `wsl.exe --install Ubuntu-24.04 --name OpenClawGateway --location <%LOCALAPPDATA%>\OpenClawTray\wsl --no-launch --version 2` | Creates only the dedicated OpenClaw instance | +| In-instance configuration | `wsl.exe -d OpenClawGateway -u root -- ...` | Writes `/etc/wsl.conf`, `/etc/wsl-distribution.conf`, creates `openclaw` user and state dirs | +| Default user | `wsl.exe --manage OpenClawGateway --set-default-user openclaw` | Locks default user to `openclaw` | +| Apply config | `wsl.exe --terminate OpenClawGateway` (then implicit restart on next command) | Picks up `wsl.conf` changes | +| Public OpenClaw install | `wsl.exe -d OpenClawGateway -u root -- bash -c "curl -fsSL https://openclaw.ai/install-cli.sh \| bash -s -- --prefix /opt/openclaw"` | Runs the public installer unchanged | +| Service start/check | `wsl.exe -d OpenClawGateway -u root -- systemctl ...` | Starts/checks OpenClaw gateway | +| Repair | `wsl.exe --terminate OpenClawGateway` | Instance-scoped restart only | +| Remove | `wsl.exe --terminate OpenClawGateway`, `wsl.exe --unregister OpenClawGateway` | Requires explicit user confirmation | + +Guarantees: + +- **WSL2 only** for the OpenClaw instance. +- The tray never modifies the user's default WSL instance. +- The tray never modifies global `.wslconfig`. +- The tray never calls global `wsl.exe --shutdown` in any product, validation, + repair, or removal path. +- The tray never unregisters arbitrary WSL instances; only the exact + `OpenClawGateway` name is eligible, and destructive cleanup requires explicit + confirmation in scripts. + +### Install command and success criterion + +The single canonical install primitive is: + +```powershell +wsl.exe --install Ubuntu-24.04 ` + --name OpenClawGateway ` + --location "$env:LOCALAPPDATA\OpenClawTray\wsl" ` + --no-launch ` + --version 2 +``` + +Success criterion (per Craig): **trust the `wsl --install` exit code**. +There is no postcondition-on-hang fallback. After exit, the engine confirms +that `OpenClawGateway` appears in `wsl --list --quiet`; failure of that +post-condition is treated as install failure regardless of stdout. + +`Ubuntu-24.04` is used explicitly (not the generic `Ubuntu` channel). No +`--web-download` and no `--from-file` are used; there is no offline base +fallback in this PR. + +#### Empirical evidence + +The literature recommendation (`wsl --install` over `winget install +Canonical.Ubuntu.2404`) was confirmed empirically on 2026-05-04 with a 20-iter +harness: + +| Path | success | failure | strict success rate | +|---|---:|---:|---| +| `wsl --install Ubuntu-24.04 --name --location --no-launch --version 2` | 10 | 0 | **10/10** | +| `winget install --id Canonical.Ubuntu.2404 -e --silent --accept-source-agreements --accept-package-agreements --disable-interactivity` | 0 | 10 | **0/10** | + +Success ≡ exit 0 AND target distro registered in `wsl --list --quiet`. + +Root cause for winget 0/10: `Canonical.Ubuntu.2404` is the launcher APPX, not +a WSL distro creator; with `--silent --disable-interactivity` the launcher is +never invoked, so the APPX stages but no distro registers. winget cannot pass +`--name` or `--location` to the launcher. + +Harness, raw timings, exit codes, and per-iteration `detail.json`: +`artifacts/wsl-install-vs-winget/run-20260504-131837/summary.json`. (The +`artifacts/` tree is gitignored; the summary will be present on any host that +runs `scripts/experiments/wsl-install-vs-winget-empirical-2026-05-04.ps1`.) + +A deeper winget research thread is in flight (Aaron-9, prototype worktree). +That work may broaden the picture for `winget install Microsoft.WSL` as a +**platform** repair fallback — it does not change the Phase 3 decision to use +`wsl --install` for distro creation in this PR. + +### `/etc/wsl.conf` + +```ini +[boot] +systemd=true + +[automount] +enabled=false +mountFsTab=false + +[interop] +enabled=false +appendWindowsPath=false + +[user] +default=openclaw + +[time] +useWindowsTimezone=true +``` + +Rationale (Craig confirmed all settings appropriate for an app-owned +appliance): + +- `systemd=true` — gateway is a systemd-managed service. +- `automount.enabled=false` / `mountFsTab=false` — the gateway does not need + Windows drive mounts. +- `interop.enabled=false` / `appendWindowsPath=false` — the appliance does not + shell out to Windows binaries. +- `default=openclaw` — non-root default user; root only via explicit + `wsl.exe -d OpenClawGateway -u root -- ...`. +- `useWindowsTimezone=true` — gateway timestamps align with the user's + Windows session. + +Per Craig: no post-clone repairs needed (machine-id / DNS / timezone work as +delivered by Ubuntu-24.04). + +### `/etc/wsl-distribution.conf` + +```ini +[oobe] +defaultName=OpenClawGateway + +[shortcut] +enabled=false + +[terminal] +enabled=false +``` + +Rationale: the OpenClaw instance is an implementation detail; users should not +see a Start menu shortcut or Windows Terminal profile for it. Craig confirmed +this is the correct use of `wsl-distribution.conf` for a privately managed +instance. + +### Networking — loopback only + +The gateway binds to **loopback inside WSL on port 18789**. The Windows tray +connects via `http://localhost:18789` / `ws://localhost:18789`. + +Per Craig: Windows localhost forwarding to a WSL2 service is a reliable core +WSL promise. **No** WSL-IP fallback. **No** `lan` or `auto` bind. **No** +`gateway.bind` overrides written by the tray. **No** Windows portproxy or +firewall mutation. + +The endpoint resolver and validation runner do not enumerate WSL interface +addresses, do not run `hostname -I` / `ip -4 addr` / `ip route` / `ss -ltnp` +inside WSL, and do not promote between bind modes. There is one Windows-side +TCP listener snapshot of port 18789 plus a loopback `/health` probe. + +Off-box / LAN / phone reachability is out of scope for this PR and will be +handled separately when relay ownership and protocol are clear. + +### Lifecycle and service ownership + +- The gateway is started/managed via upstream OpenClaw CLI commands invoked + through `wsl.exe -d OpenClawGateway -u root -- ...`. +- `loginctl enable-linger openclaw` plus a tray-owned WSL keepalive + (`wsl.exe -d OpenClawGateway -u openclaw -- sleep 2147483647`) keep the + instance reachable while local-gateway mode is active. Both patterns are + acceptable per Craig. +- Repair primitive: `wsl.exe --terminate OpenClawGateway`. Global + `wsl --shutdown` is **never** issued. +- Removal: `wsl.exe --unregister OpenClawGateway` only (after explicit user + confirmation), preceded by `wsl.exe --terminate OpenClawGateway`. Cleanup + also removes the install-location directory. + +Product readiness for the gateway requires all of: + +1. service start/restart command returns; +2. WSL listener exists on `:18789`; +3. Windows-side `http://localhost:18789/health` probe succeeds; +4. gateway status / RPC succeeds with the device token; +5. setup-code mint succeeds. + +`systemctl active` alone is not treated as readiness. + +### Diagnostics + +On any setup failure, the engine and validation script surface the link + for the user/maintainer to collect WSL logs. The +product does **not** scrape WSL internal log files or invoke +`wsl --shutdown` to collect them. The validation script's +`Save-DiagnosticsSnapshot` records `wslLogsHelp = https://aka.ms/wsllogs` and +`Write-Summary` appends a "Diagnostics: see https://aka.ms/wsllogs..." note +to `summary.md` on failure. + +### Host filesystem and file I/O + +All WSL file operations from Windows go through `wsl.exe -d OpenClawGateway +-- ...` subprocess calls. `\\wsl$` and `\\wsl.localhost` are forbidden in +product code, validation scripts, tests, and ad-hoc PowerShell. The instance +does not depend on any Windows drive mount after setup. + +### Pairing and protocol boundary + +OpenClaw pairing is implemented entirely through the upstream OpenClaw +protocol. The tray never edits gateway pairing stores directly. + +1. Gateway starts with local token auth from + `/var/lib/openclaw/gateway.env`. +2. Tray invokes `wsl.exe -d OpenClawGateway -- openclaw qr --json` and + decodes the upstream setup-code payload (with short-lived bootstrap + token). +3. Tray (operator) connects over WebSocket using its Ed25519 device identity + and `auth.bootstrapToken`; gateway returns `hello-ok.auth.deviceToken`, + stored in `%APPDATA%\OpenClawTray\device-key-ed25519.json` (operator + token field). +4. Tray (node) opens a separate WebSocket session with role `node` and + pairs through the same setup-code/bootstrap-token flow; the resulting + device token is stored in the same identity file under the **node** + field. +5. Subsequent reconnects use `auth.deviceToken`. Node tokens are never + reused as `auth.token` and vice versa. + +Identity-path invariant: operator and node device tokens share +`%APPDATA%\OpenClawTray\device-key-ed25519.json` (`OPENCLAW_TRAY_APPDATA_DIR` +override honored), with role distinction inside the file. The +prototype-era split between `%APPDATA%` (operator) and `%LOCALAPPDATA%` +(node) was closed in Phase 4. + +The Windows tray node parallels the macOS app's in-app node model +(`MacNodeModeCoordinator` with role `node`, separate session, capabilities +declared). No WSL-internal worker is paired by the Windows tray in this PR. + +## Validation + +`scripts/validate-wsl-gateway.ps1` provides four scenarios. Each writes a +JSON+markdown summary under `artifacts/validate-wsl-gateway//`. + +Validation AppData isolation uses this canonical contract: + +- `OPENCLAW_TRAY_DATA_DIR` is the settings/logs/run-marker root consumed by + `SettingsManager`, `App.DataPath`, `Logger`, and token path resolution. +- `OPENCLAW_TRAY_APPDATA_DIR` is the roaming identity-store root consumed by + `DeviceIdentity`/pairing paths. Validation sets it alongside + `OPENCLAW_TRAY_DATA_DIR` for backward compatibility and identity isolation. +- `OPENCLAW_TRAY_LOCALAPPDATA_DIR` is the local setup-state/WSL-install root. + +| Scenario | What it does | When to use | Destructive | +|---|---|---|---| +| `PreflightOnly` | Repo-layout sanity, WSL host status (`wsl --status`, `wsl --list --verbose`), relay-prototype probe (NotAvailable when no probe URI). No build, no install, no WSL state mutation. | Cheap CI / local sanity check. Safe on dev box. | No | +| `UpstreamInstall` | Build + tests, then drives the tray onboarding so the product itself runs the canonical `wsl --install Ubuntu-24.04 --name OpenClawGateway --location --no-launch --version 2` path. Smoke + bootstrap-token + operator+node pairing proofs over loopback. Reuses an existing `OpenClawGateway` instance if present. | Lab / dedicated machine. End-to-end product path. | Reuses existing distro state | +| `FreshMachine` | `UpstreamInstall` after a fresh-machine reset: `wsl --unregister OpenClawGateway` + AppData wipe (single shot). | Lab. Fresh install proof. | Yes, scoped to `OpenClawGateway` | +| `Recreate` | Iterated `FreshMachine`. Supports `-Iterations`. Uses `wsl --unregister` only — **never** `wsl --shutdown`. | Lab / repeatability harness. | Yes, scoped to `OpenClawGateway` | + +Scenarios deliberately removed from the prototype: `BuildRootfs`, +`InstallOnly`, `Smoke`, `Full`, `Loop`. Parameters deliberately removed: +`-BuildDevRootfs`, `-BaseRootfsPath`, `-GatewayPackagePath`, +`-UseExistingManifest`, `-RootfsPath`, `-AllowUnsignedDevArtifact`, +`-SigningKeyId`, `-PublicKeyPath`, +`-AllowNonStandardDistroNameForDestructiveClean`, `-NetworkingMode`, +`-LoopMode`, `-RequireWorkerPairing`, `-CleanOpenClawState`, +`-GoSkillProofCommand`, `-RequireGoSkillProof`. + +The validation script: + +- Drives onboarding via the `SetupWarningPage` "Set up locally" button + (`OnboardingSetupLocal` automation ID); `LocalSetupProgressPage` autostarts + the engine on appearance. +- Polls `setup-state.json` for `Complete` (terminal status). Worker / rootfs + phases are gone; terminal status is `Complete` only. +- Snapshots loopback diagnostics on failure (Windows-side `:18789` listener + state; loopback `/health` probe). Does **not** run any networking probes + inside WSL. +- Redacts sensitive output: `Redact-SensitiveGatewayOutput` over + `openclaw qr --json` stdout, `Save-RedactedSettings` strips `Token`, + `GatewayToken`, `BootstrapToken`, `bootstrap_token`, `NodeToken`, + `nodeToken`; relay probe body strips `token=...`. + +Scope guarantees from the validation script: + +- Only `OpenClawGateway` is ever the target of `wsl --unregister`. +- Global `wsl --shutdown` is never issued. +- No `\\wsl$` or `\\wsl.localhost` paths are read or written. + +Companion script: +`scripts/reset-openclaw-wsl-validation-state.ps1` — exact-target gated +cleanup for `OpenClawGateway` plus the `%APPDATA%\OpenClawTray` and +`%LOCALAPPDATA%\OpenClawTray` directories. Refuses to act on any other distro +name. + +## Outstanding follow-ups + +Tracked but outside the scope of this PR: + +- Off-box / LAN / phone reachability via OpenClaw relay (blocked on relay + ownership / protocol clarity). +- Optional `winget install Microsoft.WSL` as a **platform** repair fallback + (deeper research in flight). Distro creation stays on `wsl --install` + regardless. +- Internationalization of the onboarding copy (`Onboarding_SetupWarning_*` + / `Onboarding_LocalSetupProgress_*` resw entries across the supported + locales). + +See `docs/wsl-owner-open-issues.md` for the structured Q&A explaining **why** +this design is what it is, with Craig's verbatim answers. diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 0828de3e..4837aa64 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -27,6 +27,7 @@ + diff --git a/scripts/dev-reset-rebuild-launch.ps1 b/scripts/dev-reset-rebuild-launch.ps1 new file mode 100644 index 00000000..7e3ae858 --- /dev/null +++ b/scripts/dev-reset-rebuild-launch.ps1 @@ -0,0 +1,326 @@ +<# +.SYNOPSIS + Dev-loop helper: kill → backup/wipe state → optionally wipe WSL distro → build x64 → (optionally) launch tray. + +.DESCRIPTION + Consolidates the full dev-reset cycle used during OpenClaw tray development. + Idempotent: no error if nothing is running, state dirs are absent, or the WSL + distro is not registered. + + Process kills are always by PID (Stop-Process -Id). Name-based kills are + forbidden in this repo. + + WSL file operations use 'wsl bash -c' — never \\wsl$\ paths (which trigger + Windows permission prompts via the 9P protocol). + +.PARAMETER WipeWslDistro + Also unregister the OpenClawGateway WSL distro (wsl --unregister). + Default: off (preserve the distro). + +.PARAMETER CaptureDir + If set, exports OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR= + before launching the tray so the app auto-captures screenshots. + +.PARAMETER SkipBuild + Skip the 'dotnet build' step. Useful when you have just built. + +.PARAMETER DontLaunch + Reset and (optionally) build, but do not launch the tray. + +.PARAMETER WorktreePath + Root of the git worktree to operate in. + Default: result of 'git rev-parse --show-toplevel' in the current directory. + +.PARAMETER NoBackup + Instead of backing up state dirs to TEMP, delete them directly. + Faster, but no rollback. + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 + Standard reset + rebuild + launch (no WSL wipe, no capture). + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -WipeWslDistro + Full clean slate: also unregister the OpenClawGateway WSL distro. + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -DontLaunch + Reset + build only (useful before testing manually). + +.EXAMPLE + .\scripts\dev-reset-rebuild-launch.ps1 -CaptureDir .\visual-test-output\my-test + Reset + build + launch with OPENCLAW_VISUAL_TEST capture enabled. +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [switch]$WipeWslDistro, + [string]$CaptureDir = "", + [switch]$SkipBuild, + [switch]$DontLaunch, + [string]$WorktreePath = "", + [switch]$NoBackup +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ─── Resolve worktree path ──────────────────────────────────────────────────── + +if ([string]::IsNullOrWhiteSpace($WorktreePath)) { + $gitTop = & git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($gitTop)) { + Write-Error "Cannot resolve worktree path: not inside a git repository and -WorktreePath was not supplied." + exit 1 + } + $WorktreePath = $gitTop.Trim() +} +$WorktreePath = (Resolve-Path -LiteralPath $WorktreePath).Path + +# ─── Constants ──────────────────────────────────────────────────────────────── + +$DistroName = "OpenClawGateway" +$TrayProject = Join-Path $WorktreePath "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$AppDataDir = Join-Path $env:APPDATA "OpenClawTray" +$LocalAppDataDir = Join-Path $env:LOCALAPPDATA "OpenClawTray" +$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH-mm-ss") +$BackupRoot = Join-Path $env:TEMP "openclaw-test-backup-$timestamp" + +# ─── Summary state ──────────────────────────────────────────────────────────── + +$summary = [ordered]@{ + backupPath = $null + distroState = "not-checked" + buildResult = "skipped" + launchPid = $null +} + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +function Write-Step { + param([string]$Icon, [string]$Message) + Write-Host " $Icon $Message" +} +function Write-OK { param([string]$m) Write-Step "✓" $m } +function Write-Skip { param([string]$m) Write-Step "-" $m } +function Write-Fail { param([string]$m) Write-Step "x" $m } + +function Get-OpenClawProcesses { + @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -like "OpenClaw*" }) +} + +function Get-WslDistros { + $out = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $out) { return @() } + @($out | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ }) +} + +# ─── Banner ─────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "============================================================" +Write-Host " OpenClaw Dev Loop -- Reset / Rebuild / Launch" +Write-Host "============================================================" +Write-Host " Timestamp : $timestamp" +Write-Host " WorktreePath : $WorktreePath" +Write-Host " WipeWslDistro: $WipeWslDistro SkipBuild: $SkipBuild DontLaunch: $DontLaunch" +Write-Host " NoBackup : $NoBackup CaptureDir: $(if ($CaptureDir) { $CaptureDir } else { '(none)' })" +if ($WhatIfPreference) { + Write-Host " *** WHATIF MODE -- no state will be changed ***" +} +Write-Host "" + +# ============================================================================= +# STEP 1 -- Kill OpenClaw* processes (by PID; name-based kills are forbidden) +# ============================================================================= + +Write-Host "STEP 1: Kill OpenClaw* processes" +$procs = @(Get-OpenClawProcesses) + +if ($procs.Count -eq 0) { + Write-Skip "No OpenClaw* processes running" +} +else { + foreach ($p in $procs) { + if ($PSCmdlet.ShouldProcess("PID $($p.Id) ($($p.ProcessName))", "Stop-Process -Id")) { + try { + Stop-Process -Id $p.Id -Force + Write-OK "Stopped PID $($p.Id) ($($p.ProcessName))" + } + catch { + Write-Fail "Failed to stop PID $($p.Id): $_" + exit 1 + } + } + else { + Write-Skip "WhatIf: would stop PID $($p.Id) ($($p.ProcessName))" + } + } + if (-not $WhatIfPreference) { + Start-Sleep -Milliseconds 500 # brief pause for file-lock release + } +} + +# ============================================================================= +# STEP 2 -- Backup or wipe tray state dirs +# ============================================================================= + +Write-Host "" +Write-Host "STEP 2: $(if ($NoBackup) { 'Wipe' } else { 'Backup' }) tray state dirs" + +function Invoke-StateDirReset { + param([string]$Path, [string]$Label) + + if (-not (Test-Path -LiteralPath $Path)) { + Write-Skip "$Label not present -- nothing to do" + return + } + + if ($NoBackup) { + if ($PSCmdlet.ShouldProcess($Path, "Remove-Item -Recurse -Force")) { + Remove-Item -LiteralPath $Path -Recurse -Force + Write-OK "Deleted $Label ($Path)" + } + else { + Write-Skip "WhatIf: would delete $Label ($Path)" + } + } + else { + $dest = Join-Path $BackupRoot $Label + if ($PSCmdlet.ShouldProcess($Path, "Copy-Item to backup then Remove-Item")) { + New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null + Copy-Item -LiteralPath $Path -Destination $dest -Recurse -Force + Remove-Item -LiteralPath $Path -Recurse -Force + Write-OK "Backed up $Label --> $dest" + $script:summary.backupPath = $BackupRoot + } + else { + Write-Skip "WhatIf: would backup $Label --> $dest, then remove source" + $script:summary.backupPath = "(whatif) $BackupRoot" + } + } +} + +Invoke-StateDirReset -Path $AppDataDir -Label "AppData_OpenClawTray" +Invoke-StateDirReset -Path $LocalAppDataDir -Label "LocalAppData_OpenClawTray" + +# ============================================================================= +# STEP 3 -- Optionally wipe the WSL distro +# ============================================================================= + +Write-Host "" +Write-Host "STEP 3: WSL distro ($DistroName)" + +$distros = @(Get-WslDistros) +$distroExists = $distros -contains $DistroName + +if (-not $WipeWslDistro) { + Write-Skip "-WipeWslDistro not set -- preserving $DistroName" + $summary.distroState = if ($distroExists) { "preserved" } else { "absent" } +} +elseif (-not $distroExists) { + Write-Skip "$DistroName is not registered -- nothing to unregister" + $summary.distroState = "absent" +} +else { + if ($PSCmdlet.ShouldProcess($DistroName, "wsl --terminate then wsl --unregister")) { + & wsl.exe --terminate $DistroName 2>$null # ignore exit code -- distro may already be stopped + & wsl.exe --unregister $DistroName + if ($LASTEXITCODE -ne 0) { + Write-Fail "wsl --unregister $DistroName failed (exit $LASTEXITCODE)" + exit 1 + } + Write-OK "Unregistered WSL distro $DistroName" + $summary.distroState = "unregistered" + } + else { + Write-Skip "WhatIf: would terminate + unregister WSL distro $DistroName" + $summary.distroState = "(whatif) would-unregister" + } +} + +# ============================================================================= +# STEP 4 -- Build x64 tray +# ============================================================================= + +Write-Host "" +Write-Host "STEP 4: Build x64 tray" + +if ($SkipBuild) { + Write-Skip "-SkipBuild set -- skipping dotnet build" + $summary.buildResult = "skipped" +} +else { + if (-not (Test-Path -LiteralPath $TrayProject)) { + Write-Fail "Tray project not found: $TrayProject" + exit 1 + } + + if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet build -p:Platform=x64 --no-restore -v q")) { + Write-Verbose "Running: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q" + & dotnet build $TrayProject -p:Platform=x64 --no-restore -v q + if ($LASTEXITCODE -ne 0) { + Write-Fail "dotnet build failed (exit $LASTEXITCODE)" + $summary.buildResult = "failed" + exit 1 + } + Write-OK "Build succeeded" + $summary.buildResult = "succeeded" + } + else { + Write-Skip "WhatIf: would run: dotnet build `"$TrayProject`" -p:Platform=x64 --no-restore -v q" + $summary.buildResult = "(whatif) would-build" + } +} + +# ============================================================================= +# STEP 5 -- Launch tray +# ============================================================================= + +Write-Host "" +Write-Host "STEP 5: Launch tray" + +if ($DontLaunch) { + Write-Skip "-DontLaunch set -- not launching" +} +else { + if ($PSCmdlet.ShouldProcess($TrayProject, "dotnet run -p:Platform=x64")) { + if ($CaptureDir) { + $captureAbs = if ([System.IO.Path]::IsPathRooted($CaptureDir)) { + $CaptureDir + } + else { + Join-Path $WorktreePath $CaptureDir + } + $env:OPENCLAW_VISUAL_TEST = "1" + $env:OPENCLAW_VISUAL_TEST_DIR = $captureAbs + Write-Verbose "Set OPENCLAW_VISUAL_TEST=1 OPENCLAW_VISUAL_TEST_DIR=$captureAbs" + } + + Write-Verbose "Launching: dotnet run --project `"$TrayProject`" -p:Platform=x64" + $launchProc = Start-Process -FilePath "dotnet" ` + -ArgumentList "run", "--project", $TrayProject, "-p:Platform=x64" ` + -PassThru -WorkingDirectory $WorktreePath + $summary.launchPid = $launchProc.Id + Write-OK "Tray launched (PID $($launchProc.Id))" + } + else { + Write-Skip "WhatIf: would launch: dotnet run --project `"$TrayProject`" -p:Platform=x64" + if ($CaptureDir) { + Write-Skip "WhatIf: would also set OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR=$CaptureDir" + } + } +} + +# ============================================================================= +# Summary +# ============================================================================= + +Write-Host "" +Write-Host "---------------------------- Summary ----------------------------" +Write-Host " Backup path : $(if ($summary.backupPath) { $summary.backupPath } elseif ($NoBackup) { '(deleted directly)' } else { '(nothing backed up)' })" +Write-Host " Distro state : $($summary.distroState)" +Write-Host " Build result : $($summary.buildResult)" +Write-Host " Launch PID : $(if ($summary.launchPid) { $summary.launchPid } else { '(not launched)' })" +Write-Host "-----------------------------------------------------------------" +Write-Host "" diff --git a/scripts/reset-openclaw-wsl-validation-state.ps1 b/scripts/reset-openclaw-wsl-validation-state.ps1 new file mode 100644 index 00000000..04bf9fdc --- /dev/null +++ b/scripts/reset-openclaw-wsl-validation-state.ps1 @@ -0,0 +1,388 @@ +# reset-openclaw-wsl-validation-state.ps1 +# +# Exact-target destructive cleanup for OpenClaw-owned WSL validation state. +# +# Safety guarantees enforced by this script: +# 1. Without -ConfirmDestructiveClean, the script runs in DRY-RUN mode and +# reports what it WOULD do; it never mutates state. +# 2. The only WSL distro this script will ever touch is the production +# constant "OpenClawGateway". Any other distro name is rejected. +# 3. Destructive operations are preceded by a copy of the user's +# %APPDATA%\OpenClawTray and %LOCALAPPDATA%\OpenClawTray identity +# directories to a timestamped backup location (printed to console). +# 4. The script never calls `wsl --shutdown`. It uses +# `wsl --terminate OpenClawGateway` only. +# 5. The script never reads or writes \\wsl$ / \\wsl.localhost paths. + +[CmdletBinding()] +param( + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation\reset"), + [string]$BackupRoot, + [string]$AppDataRoot, + [string]$LocalAppDataRoot, + [string]$InstallLocation, + [switch]$CleanInstallLocation, + [switch]$ConfirmDestructiveClean, + [switch]$KeepRunningProcesses, + [switch]$PassThruJson +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Production-locked WSL distro name (Phase 3 constant). This script will +# refuse to act on any other distro, even via -DistroName overrides +# (which are intentionally absent). +$script:OpenClawDistroName = "OpenClawGateway" + +$startedAt = Get-Date +$timestamp = $startedAt.ToString("yyyyMMddHHmmss") + +if ([string]::IsNullOrWhiteSpace($BackupRoot)) { + $BackupRoot = Join-Path (Get-Location) "artifacts\reset-backups\$timestamp" +} + +$result = [ordered]@{ + script = "reset-openclaw-wsl-validation-state" + startedAt = $startedAt.ToString("o") + finishedAt = $null + outputDir = $OutputDir + backupRoot = $BackupRoot + distroName = $script:OpenClawDistroName + installLocation = $InstallLocation + appDataRoot = $AppDataRoot + localAppDataRoot = $LocalAppDataRoot + destructiveConfirmed = [bool]$ConfirmDestructiveClean + dryRun = -not $ConfirmDestructiveClean + targets = [ordered]@{} + steps = @() +} + +function Add-ResetStep { + param( + [string]$Name, + [string]$Status, + [string]$Message, + [hashtable]$Data = @{} + ) + + $script:result.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Invoke-CapturedCommand { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = (Get-Location).Path, + [switch]$IgnoreExitCode + ) + + $stepDir = Join-Path $OutputDir "commands" + New-Item -ItemType Directory -Force -Path $stepDir | Out-Null + $safeName = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $stepDir "$safeName.stdout.txt" + $stderr = Join-Path $stepDir "$safeName.stderr.txt" + + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } + finally { + Pop-Location + } + + Add-ResetStep $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath + arguments = ($ArgumentList -join " ") + exitCode = $exitCode + stdout = $stdout + stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Backup-Directory { + param( + [string]$Path, + [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path)) { + Add-ResetStep "backup-$Label" "Skipped" "$Path does not exist." + return + } + + New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null + $leaf = Split-Path -Leaf $Path + $destination = Join-Path $BackupRoot "$Label-$leaf" + + if ($result.dryRun) { + Add-ResetStep "backup-$Label" "DryRun" "Would copy $Path to $destination, then remove the original." @{ + source = $Path + destination = $destination + } + return + } + + if (Test-Path -LiteralPath $destination) { + $destination = Join-Path $BackupRoot ("{0}-{1:yyyyMMddHHmmss}" -f "$Label-$leaf", (Get-Date)) + } + + # Copy first so the user can recover even if removal fails partway. + Copy-Item -LiteralPath $Path -Destination $destination -Recurse -Force + Remove-Item -LiteralPath $Path -Recurse -Force + Add-ResetStep "backup-$Label" "Completed" "Backed up $Path to $destination, then removed the original." @{ + source = $Path + destination = $destination + } +} + +function Assert-DestructiveTargetIsAllowed { + # Hard-lock: this script will only ever touch the production OpenClawGateway distro. + # No override flag exists. If $script:OpenClawDistroName is ever something else, + # the script must refuse to run regardless of dry-run mode. + if ($script:OpenClawDistroName -ne "OpenClawGateway") { + throw "Refusing to run: distro name is locked to 'OpenClawGateway' but resolved to '$($script:OpenClawDistroName)'." + } +} + +function Get-PortOwnerSnapshot { + param([string]$Label) + + $port = 18789 + try { + $connections = @(Get-NetTCPConnection -LocalPort $port -ErrorAction Stop) + $snapshot = @($connections | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress + localPort = $_.LocalPort + state = $_.State.ToString() + owningProcess = $_.OwningProcess + } + }) + } + catch { + $snapshot = @() + } + + $snapshotPath = Join-Path $OutputDir "port-18789-$Label.json" + $snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotPath -Encoding UTF8 + Add-ResetStep "port-snapshot-$Label" "Completed" "Captured TCP listener snapshot for port 18789." @{ + path = $snapshotPath + ownerCount = @($snapshot).Count + } + return $snapshot +} + +function Get-WslDistros { + $output = & wsl.exe --list --quiet 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $output) { + return @() + } + + return @($output | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ }) +} + +function Get-OpenClawProcesses { + return @(Get-Process | Where-Object { $_.ProcessName -like "OpenClaw*" }) +} + +function Add-TargetSummary { + param( + [object[]]$Processes, + [string[]]$Distros, + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath, + [object[]]$PortOwners + ) + + $script:result.targets = [ordered]@{ + processes = @($Processes | ForEach-Object { + [ordered]@{ + pid = $_.Id + name = $_.ProcessName + path = $_.Path + } + }) + distroExists = ($Distros -contains $script:OpenClawDistroName) + distroName = $script:OpenClawDistroName + appDataPath = $AppDataPath + appDataExists = Test-Path -LiteralPath $AppDataPath + localAppDataPath = $LocalAppDataPath + localAppDataExists = Test-Path -LiteralPath $LocalAppDataPath + installLocationPath = $InstallLocationPath + installLocationExists = (-not [string]::IsNullOrWhiteSpace($InstallLocationPath)) -and (Test-Path -LiteralPath $InstallLocationPath) + installLocationCleanupRequested = [bool]$CleanInstallLocation + port18789OwnersBefore = @($PortOwners) + outputDir = $OutputDir + backupRoot = $BackupRoot + } + + Add-ResetStep "target-summary" "Completed" "Captured OpenClaw-owned reset targets." @{ + processCount = @($Processes).Count + distroExists = [bool]$script:result.targets.distroExists + appDataExists = [bool]$script:result.targets.appDataExists + localAppDataExists = [bool]$script:result.targets.localAppDataExists + installLocationExists = [bool]$script:result.targets.installLocationExists + } +} + +function Assert-CleanPostCondition { + param( + [string]$AppDataPath, + [string]$LocalAppDataPath, + [string]$InstallLocationPath + ) + + if ($result.dryRun) { + Add-ResetStep "postconditions" "Skipped" "Postconditions are skipped during dry-run." + return + } + + $remainingProcesses = @(Get-OpenClawProcesses) + if (-not $KeepRunningProcesses -and $remainingProcesses.Count -gt 0) { + throw "OpenClaw processes are still running after reset: $(@($remainingProcesses | ForEach-Object { $_.Id }) -join ', ')" + } + + $remainingDistros = @(Get-WslDistros) + if ($remainingDistros -contains $script:OpenClawDistroName) { + throw "WSL distro '$($script:OpenClawDistroName)' is still registered after reset." + } + + if (Test-Path -LiteralPath $AppDataPath) { + throw "AppData path still exists after reset: $AppDataPath" + } + + if (Test-Path -LiteralPath $LocalAppDataPath) { + throw "LocalAppData path still exists after reset: $LocalAppDataPath" + } + + if ($CleanInstallLocation -and -not [string]::IsNullOrWhiteSpace($InstallLocationPath) -and (Test-Path -LiteralPath $InstallLocationPath)) { + throw "Install location still exists after reset: $InstallLocationPath" + } + + $wslListAfterPath = Join-Path $OutputDir "wsl-list-after.txt" + & wsl.exe --list --verbose > $wslListAfterPath 2>&1 + $script:result.targets.port18789OwnersAfter = @(Get-PortOwnerSnapshot -Label "after") + Add-ResetStep "postconditions" "Passed" "OpenClaw-owned state reset postconditions passed." @{ + wslListAfter = $wslListAfterPath + } +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +try { + Assert-DestructiveTargetIsAllowed + + if ([string]::IsNullOrWhiteSpace($AppDataRoot)) { + $AppDataRoot = $env:APPDATA + $result.appDataRoot = $AppDataRoot + } + if ([string]::IsNullOrWhiteSpace($LocalAppDataRoot)) { + $LocalAppDataRoot = $env:LOCALAPPDATA + $result.localAppDataRoot = $LocalAppDataRoot + } + + $appData = Join-Path $AppDataRoot "OpenClawTray" + $localAppData = Join-Path $LocalAppDataRoot "OpenClawTray" + $processes = @(Get-OpenClawProcesses) + $distros = @(Get-WslDistros) + $portOwnersBefore = @(Get-PortOwnerSnapshot -Label "before") + Add-TargetSummary -Processes $processes -Distros $distros -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation -PortOwners $portOwnersBefore + + if ($result.dryRun) { + Add-ResetStep "mode" "DryRun" "No state will be changed. Pass -ConfirmDestructiveClean to reset OpenClaw-owned state." + Write-Host "DRY-RUN: pass -ConfirmDestructiveClean to actually reset OpenClaw-owned state." + } + else { + Add-ResetStep "mode" "Confirmed" "OpenClaw-owned state reset is enabled for this run." + Write-Host "Backups will be written under: $BackupRoot" + } + + if ($processes.Count -eq 0) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "No OpenClaw processes are running." + } + elseif ($KeepRunningProcesses) { + Add-ResetStep "stop-openclaw-processes" "Skipped" "Keeping running OpenClaw processes because -KeepRunningProcesses was set." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + elseif ($result.dryRun) { + Add-ResetStep "stop-openclaw-processes" "DryRun" "Would stop running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + else { + foreach ($process in $processes) { + Stop-Process -Id $process.Id -Force + } + Add-ResetStep "stop-openclaw-processes" "Completed" "Stopped running OpenClaw processes by PID." @{ + pids = @($processes | ForEach-Object { $_.Id }) + } + } + + $hasGatewayDistro = $distros -contains $script:OpenClawDistroName + $wslListPath = Join-Path $OutputDir "wsl-list-before.txt" + & wsl.exe --list --verbose > $wslListPath 2>&1 + Add-ResetStep "capture-wsl-list" "Completed" "Captured WSL distro list." @{ path = $wslListPath } + + if (-not $hasGatewayDistro) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "Skipped" "WSL distro '$($script:OpenClawDistroName)' is not registered." + } + elseif ($result.dryRun) { + Add-ResetStep "unregister-$($script:OpenClawDistroName)" "DryRun" "Would terminate and unregister only the '$($script:OpenClawDistroName)' WSL distro." @{ distroName = $script:OpenClawDistroName } + } + else { + # Exact-target only: --terminate , never --shutdown. + Invoke-CapturedCommand "wsl-terminate-$($script:OpenClawDistroName)" "wsl.exe" @("--terminate", $script:OpenClawDistroName) -IgnoreExitCode + Invoke-CapturedCommand "wsl-unregister-$($script:OpenClawDistroName)" "wsl.exe" @("--unregister", $script:OpenClawDistroName) + } + + Backup-Directory -Path $appData -Label "appdata" + Backup-Directory -Path $localAppData -Label "localappdata" + if ($CleanInstallLocation) { + if ([string]::IsNullOrWhiteSpace($InstallLocation)) { + Add-ResetStep "backup-install-location" "Skipped" "No install location was supplied." + } + else { + Backup-Directory -Path $InstallLocation -Label "install-location" + } + } + else { + Add-ResetStep "backup-install-location" "Skipped" "Install location cleanup was not requested." + } + Assert-CleanPostCondition -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation + + $result.finishedAt = (Get-Date).ToString("o") + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + if ($PassThruJson) { + $result | ConvertTo-Json -Depth 10 + } + else { + Write-Host "Reset summary: $summaryPath" + if (-not $result.dryRun) { + Write-Host "Backup root: $BackupRoot" + } + } +} +catch { + $result.finishedAt = (Get-Date).ToString("o") + Add-ResetStep "reset" "Failed" $_.Exception.Message + $summaryPath = Join-Path $OutputDir "reset-summary.json" + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + Write-Error $_.Exception.Message + exit 1 +} diff --git a/scripts/validate-wsl-gateway.ps1 b/scripts/validate-wsl-gateway.ps1 new file mode 100644 index 00000000..711b0f99 --- /dev/null +++ b/scripts/validate-wsl-gateway.ps1 @@ -0,0 +1,941 @@ +<# +.SYNOPSIS + Validate the OpenClaw WSL gateway local-setup product code path end-to-end. + +.DESCRIPTION + Phase 6 clean port. Drives the WinUI3 tray app from launch through the + forked onboarding (SetupWarningPage -> "Set up locally" -> LocalSetupProgressPage) + so the *product* code path that runs + + wsl --install Ubuntu-24.04 --name OpenClawGateway --location --no-launch --version 2 + + is exercised end-to-end. The script does NOT install WSL itself and does NOT + invoke `wsl --install` directly: it expects the tray engine to do that and + only verifies the postcondition. + + Networking diagnostics are loopback-only. There is no WSL-IP / lan / auto + fallback. Token / setup-code / private-key material is redacted in artifacts. + +.PARAMETER Scenario + PreflightOnly - Repo layout + WSL host status + relay probe (safe; no install). + UpstreamInstall - Build/test, drive tray onboarding to install OpenClawGateway, + run smoke + pairing proofs. Reuses an existing distro if present. + FreshMachine - Like UpstreamInstall, but unregisters any existing + OpenClawGateway distro first (simulates a clean machine). + Recreate - Iterated FreshMachine (unregister between runs). Use `-Iterations`. + +.NOTES + Diagnostics on networking/lifecycle health failures point operators at + https://aka.ms/wsllogs (per Craig). + + File I/O against WSL is via `wsl bash -c` only. NEVER \\wsl$ / \\wsl.localhost. +#> +[CmdletBinding()] +param( + [ValidateSet("PreflightOnly", "UpstreamInstall", "FreshMachine", "Recreate")] + [string]$Scenario = "PreflightOnly", + [string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation"), + [int]$Iterations = 1, + [switch]$ConfirmDestructiveClean, + [switch]$KeepFailedDistro, + [bool]$CleanupAfterSuccess = $true, + [switch]$ContinueOnCleanupFailure, + [switch]$NoBuild, + [int]$TimeoutSeconds = 600, + [string]$DistroName = "OpenClawGateway", + [string]$GatewayUrl = "ws://127.0.0.1:18789", + [string]$RelayProbeUri, + [switch]$RequireRelayProbe, + [switch]$RequireRealGatewayBootstrap, + [switch]$RequireOperatorPairing, + [switch]$RequireWindowsNodePairing, + [switch]$ContinueOnFailure +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$runStamp = Get-Date -Format "yyyyMMdd-HHmmss" +$runRoot = Join-Path $OutputDir $runStamp +$commandsRoot = Join-Path $runRoot "commands" +$screenshotsRoot = Join-Path $runRoot "screenshots" +$summaryPath = Join-Path $runRoot "summary.json" +$summaryMarkdownPath = Join-Path $runRoot "summary.md" +$trayProject = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$runtimeIdentifier = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "win-arm64" } else { "win-x64" } +$trayExe = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.19041.0\$runtimeIdentifier\OpenClaw.Tray.WinUI.exe" +$cliProject = Join-Path $repoRoot "src\OpenClaw.Cli\OpenClaw.Cli.csproj" + +# Always isolate AppData under run root for non-Preflight scenarios so we never +# trample the operator's real Windows tray identity. +$validationAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:APPDATA } else { Join-Path $runRoot "isolated\appdata" } +$validationLocalAppDataRoot = if ($Scenario -eq "PreflightOnly") { $env:LOCALAPPDATA } else { Join-Path $runRoot "isolated\localappdata" } +$setupStatePath = Join-Path $validationLocalAppDataRoot "OpenClawTray\setup-state.json" +$settingsPath = Join-Path $validationAppDataRoot "settings.json" +$wslInstallLocation = Join-Path $runRoot "wsl\$DistroName" + +$script:summary = [ordered]@{ + script = "validate-wsl-gateway" + scenario = $Scenario + startedAt = (Get-Date).ToString("o") + finishedAt = $null + status = "Running" + validationStatus = "Running" + cleanupStatus = "NotStarted" + repository = $repoRoot.Path + outputDir = $runRoot + networkingMode = "LocalhostOnly" + activeDistroName = $DistroName + activeInstallLocation = $wslInstallLocation + selectedGatewayUrl = $GatewayUrl + pairingValidation = [ordered]@{ + gatewayImplementation = "Unknown" + bootstrapQrShape = "Unknown" + realUpstreamBootstrapHandoff = $false + operatorPaired = $false + windowsNodePaired = $false + } + setupPhases = @() + iterations = @() + steps = @() + error = $null +} + +function Add-Step { + param([string]$Name, [string]$Status, [string]$Message, [hashtable]$Data = @{}) + $script:summary.steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} + +function Test-IsOpenClawOwnedDistroName { + param([string]$Name) + return $Name -eq "OpenClawGateway" -or $Name.StartsWith("OpenClawGateway", [System.StringComparison]::Ordinal) +} + +function Assert-DestructiveSafety { + if ($Scenario -in @("FreshMachine", "Recreate") -and -not $ConfirmDestructiveClean) { + throw "-ConfirmDestructiveClean is required when -Scenario is $Scenario (will unregister WSL distro '$DistroName')." + } + if ($Scenario -in @("FreshMachine", "Recreate") -and -not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { + throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must start with 'OpenClawGateway'." + } +} + +function Get-SafeUriDisplay { + param([string]$Uri) + try { + $b = [System.UriBuilder]::new($Uri) + $b.Query = $null; $b.Fragment = $null + return $b.Uri.AbsoluteUri + } catch { + return "" + } +} + +function Write-Summary { + New-Item -ItemType Directory -Force -Path $runRoot | Out-Null + $script:summary.finishedAt = (Get-Date).ToString("o") + $script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 + + $lines = @( + "# OpenClaw WSL gateway validation", + "", + "- Scenario: $Scenario", + "- Status: $($script:summary.status)", + "- Validation: $($script:summary.validationStatus)", + "- Cleanup: $($script:summary.cleanupStatus)", + "- Networking mode: LocalhostOnly (loopback only)", + "- Started: $($script:summary.startedAt)", + "- Finished: $($script:summary.finishedAt)", + "- Output: $runRoot", + "", + "## Steps" + ) + foreach ($step in $script:summary.steps) { + $lines += "- $($step.status): $($step.name) - $($step.message)" + } + if ($script:summary.error) { + $lines += "", "## Error", $script:summary.error + $lines += "", "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." + } + $lines | Set-Content -LiteralPath $summaryMarkdownPath -Encoding UTF8 +} + +function Redact-SensitiveGatewayOutput { + param([string]$Content) + if ([string]::IsNullOrEmpty($Content)) { return $Content } + $r = $Content -replace '("(?:bootstrapToken|bootstrap_token|deviceToken|device_token|token|setupCode|setup_code|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1$2' + $r = $r -replace '(?i)((?:bootstrap|device|gateway|auth)[_-]?token\s*[:=]\s*)[^\s,"''}]+', '$1' + return $r +} + +function Read-TextFileWithRetry { + param([string]$Path, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Write-TextFileWithRetry { + param([string]$Path, [string]$Content, [int]$Attempts = 10, [int]$DelayMilliseconds = 200) + for ($i = 1; $i -le $Attempts; $i++) { + try { $Content | Set-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop ; return } + catch [System.IO.IOException] { if ($i -eq $Attempts) { throw } ; Start-Sleep -Milliseconds $DelayMilliseconds } + } +} + +function Copy-RedactedFileIfExists { + param([string]$SourcePath, [string]$DestinationPath) + if (-not (Test-Path -LiteralPath $SourcePath)) { return $false } + $content = Read-TextFileWithRetry -Path $SourcePath + Write-TextFileWithRetry -Path $DestinationPath -Content (Redact-SensitiveGatewayOutput $content) + return $true +} + +function Invoke-LoggedProcess { + param( + [string]$Name, + [string]$FilePath, + [string[]]$ArgumentList, + [string]$WorkingDirectory = $repoRoot.Path, + [hashtable]$Environment = @{}, + [switch]$IgnoreExitCode, + [switch]$SensitiveOutput + ) + + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + $safe = $Name -replace "[^a-zA-Z0-9_.-]", "-" + $stdout = Join-Path $commandsRoot "$safe.stdout.txt" + $stderr = Join-Path $commandsRoot "$safe.stderr.txt" + $saved = @{} + foreach ($k in $Environment.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$Environment[$k], "Process") + } + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } finally { + Pop-Location + foreach ($k in $Environment.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } + + if ($SensitiveOutput) { + foreach ($p in @($stdout, $stderr)) { + if (Test-Path -LiteralPath $p) { + $c = Read-TextFileWithRetry -Path $p -Attempts 20 -DelayMilliseconds 250 + Write-TextFileWithRetry -Path $p -Content (Redact-SensitiveGatewayOutput $c) -Attempts 20 -DelayMilliseconds 250 + } + } + } + + Add-Step $Name "Completed" "Command completed with exit code $exitCode." @{ + file = $FilePath; arguments = ($ArgumentList -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$Name failed with exit code $exitCode. See $stdout and $stderr." + } +} + +function Invoke-LoggedPowerShellScript { + param([string]$Name, [string]$ScriptPath, [string[]]$ArgumentList = @()) + $hostExe = if ($PSHOME -and (Test-Path (Join-Path $PSHOME "pwsh.exe"))) { Join-Path $PSHOME "pwsh.exe" } else { "powershell.exe" } + $args = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $ScriptPath) + $ArgumentList + Invoke-LoggedProcess -Name $Name -FilePath $hostExe -ArgumentList $args +} + +function Invoke-RepositoryValidation { + if ($NoBuild) { + Add-Step "repository-validation" "Skipped" "Skipped build and tests because -NoBuild was set." + return + } + Invoke-LoggedPowerShellScript "build" (Join-Path $repoRoot "build.ps1") + Invoke-LoggedProcess "test-shared" "dotnet" @("test", ".\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj", "--no-restore") + Invoke-LoggedProcess "test-tray" "dotnet" @("test", ".\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj", "--no-restore") +} + +function Invoke-Preflight { + Invoke-LoggedProcess "dotnet-info" "dotnet" @("--info") -IgnoreExitCode + Invoke-LoggedProcess "wsl-status" "wsl.exe" @("--status") -IgnoreExitCode + Invoke-LoggedProcess "wsl-list-before" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + + if (-not (Test-Path -LiteralPath $trayProject)) { throw "Tray project not found: $trayProject" } + if (-not (Test-Path -LiteralPath $cliProject)) { throw "CLI project not found: $cliProject" } + Add-Step "repo-layout" "Passed" "Required projects are present." + + Invoke-RelayPrototypeProbe +} + +function Invoke-RelayPrototypeProbe { + $probeUri = if (-not [string]::IsNullOrWhiteSpace($RelayProbeUri)) { $RelayProbeUri } else { [Environment]::GetEnvironmentVariable("OPENCLAW_RELAY_PROBE_URI", "Process") } + if ([string]::IsNullOrWhiteSpace($probeUri)) { + $msg = "No relay probe endpoint was supplied. Set -RelayProbeUri or OPENCLAW_RELAY_PROBE_URI." + if ($RequireRelayProbe) { throw "RelayProbeMissing: $msg" } + Add-Step "relay-prototype-probe" "NotAvailable" $msg + return + } + $relayPath = Join-Path $commandsRoot "relay-prototype-probe.txt" + New-Item -ItemType Directory -Force -Path $commandsRoot | Out-Null + try { + $r = Invoke-WebRequest -Uri $probeUri -TimeoutSec 15 -UseBasicParsing + $body = if ($null -ne $r.Content) { $r.Content } else { "" } + $body = $body -replace '(?i)(token=)[^&\s]+', '$1' + $body | Set-Content -LiteralPath $relayPath -Encoding UTF8 + Add-Step "relay-prototype-probe" "Passed" "Relay probe endpoint responded." @{ + uri = (Get-SafeUriDisplay $probeUri); statusCode = [int]$r.StatusCode; path = $relayPath + } + } catch { + throw "RelayProbeFailed: relay probe failed for $(Get-SafeUriDisplay $probeUri): $($_.Exception.Message)" + } +} + +function Get-LatestScreenshotPath { + if (-not (Test-Path -LiteralPath $screenshotsRoot)) { return $null } + $latest = Get-ChildItem -LiteralPath $screenshotsRoot -Filter "*.png" -File -Recurse | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -eq $latest) { return $null } + return $latest.FullName +} + +function Save-DiagnosticsSnapshot { + param([string]$Reason) + $diag = Join-Path $runRoot "diagnostics" + New-Item -ItemType Directory -Force -Path $diag | Out-Null + + if (Test-Path -LiteralPath $setupStatePath) { + Copy-RedactedFileIfExists -SourcePath $setupStatePath -DestinationPath (Join-Path $diag "setup-state.redacted.json") | Out-Null + } + if (Test-Path -LiteralPath $settingsPath) { + Copy-RedactedFileIfExists -SourcePath $settingsPath -DestinationPath (Join-Path $diag "settings.redacted.json") | Out-Null + } + $identityPath = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (Test-Path -LiteralPath $identityPath) { + Copy-RedactedFileIfExists -SourcePath $identityPath -DestinationPath (Join-Path $diag "device-key.shape.redacted.json") | Out-Null + } + + Add-Step "diagnostics-snapshot" "Completed" "Saved diagnostics snapshot for $Reason. See https://aka.ms/wsllogs for WSL networking/lifecycle logs." @{ + path = $diag + latestScreenshot = (Get-LatestScreenshotPath) + wslLogsHelp = "https://aka.ms/wsllogs" + } +} + +function Get-ValidationAppEnvironment { + return @{ + OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + } +} + +function Convert-SetupStatus { + param([object]$Status) + $v = [string]$Status + if ($v -match '^\d+$') { + # Aligned with LocalGatewaySetupStatus enum + $names = @("Pending", "Running", "RequiresAdmin", "RequiresRestart", "Blocked", + "FailedRetryable", "FailedTerminal", "Complete", "Cancelled") + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Convert-SetupPhase { + param([object]$Phase) + $v = [string]$Phase + if ($v -match '^\d+$') { + # Aligned with the clean LocalGatewaySetupPhase enum (worker / rootfs phases removed). + $names = @( + "NotStarted", "Preflight", "ElevationCheck", + "EnsureWslEnabled", "CreateWslInstance", "ConfigureWslInstance", + "InstallOpenClawCli", "PrepareGatewayConfig", "InstallGatewayService", + "StartGateway", "WaitForGateway", + "MintBootstrapToken", "PairOperator", + "CheckWindowsNodeReadiness", "PairWindowsTrayNode", + "VerifyEndToEnd", "Complete", "Failed", "Cancelled" + ) + $i = [int]$v + if ($i -ge 0 -and $i -lt $names.Count) { return $names[$i] } + } + return $v +} + +function Wait-ForUiAutomationElement { + param([string]$AutomationId, [int]$TimeoutSeconds) + Add-Type -AssemblyName UIAutomationClient + Add-Type -AssemblyName UIAutomationTypes + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::AutomationIdProperty, $AutomationId) + while ((Get-Date) -lt $deadline) { + $el = [System.Windows.Automation.AutomationElement]::RootElement.FindFirst( + [System.Windows.Automation.TreeScope]::Descendants, $cond) + if ($null -ne $el) { return $el } + Start-Sleep -Milliseconds 500 + } + return $null +} + +function Invoke-UiAutomationClick { + param([string]$AutomationId, [int]$TimeoutSeconds) + $el = Wait-ForUiAutomationElement -AutomationId $AutomationId -TimeoutSeconds $TimeoutSeconds + if ($null -ne $el) { + $p = $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + $p.Invoke() + Add-Step "ui-click-$AutomationId" "Completed" "Clicked UI element with AutomationId '$AutomationId'." + return + } + Save-DiagnosticsSnapshot -Reason "missing-ui-target-$AutomationId" + throw "UI element with AutomationId '$AutomationId' was not found within $TimeoutSeconds seconds." +} + +function Stop-ExistingTrayProcesses { + param([string]$Reason) + $repoPrefix = [string]$repoRoot.Path + $procs = Get-Process -Name "OpenClaw.Tray.WinUI" -ErrorAction SilentlyContinue | + Where-Object { + try { -not [string]::IsNullOrWhiteSpace($_.Path) -and $_.Path.StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase) } + catch { $false } + } + foreach ($p in $procs) { + $procId = $p.Id + try { + Stop-Process -Id $procId -Force -ErrorAction Stop + Add-Step "stop-existing-tray" "Completed" "Stopped existing repo tray process by PID before validation." @{ pid = $procId; reason = $Reason } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-existing-tray" "Skipped" "Repo tray process had already exited before cleanup." @{ pid = $procId; reason = $Reason } + } + } +} + +function Stop-WslKeepAliveProcesses { + $target = $DistroName + $procs = Get-CimInstance Win32_Process -Filter "Name = 'wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { + $_.CommandLine -and + $_.CommandLine.Contains($target, [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("sleep", [System.StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains("2147483647", [System.StringComparison]::OrdinalIgnoreCase) + } + foreach ($p in $procs) { + try { + Stop-Process -Id $p.ProcessId -Force -ErrorAction Stop + Add-Step "stop-wsl-keepalive" "Completed" "Stopped $target keepalive process by PID." @{ pid = $p.ProcessId; distroName = $target } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + Add-Step "stop-wsl-keepalive" "Skipped" "$target keepalive process had already exited." @{ pid = $p.ProcessId; distroName = $target } + } + } +} + +function Start-TrayForLocalSetup { + Stop-ExistingTrayProcesses -Reason "pre-launch" + + # Forked onboarding entry point is SetupWarning by default; we just force + # onboarding mode and let the script click "Set up locally". + $env = @{ + OPENCLAW_SKIP_UPDATE_CHECK = "1" + OPENCLAW_FORCE_ONBOARDING = "1" + OPENCLAW_WSL_DISTRO_NAME = $DistroName + OPENCLAW_WSL_INSTALL_LOCATION = $wslInstallLocation + OPENCLAW_WSL_ALLOW_EXISTING_DISTRO = if ($Scenario -eq "UpstreamInstall") { "1" } else { "0" } + OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot + OPENCLAW_TRAY_LOCALAPPDATA_DIR = $validationLocalAppDataRoot + OPENCLAW_VISUAL_TEST = "1" + OPENCLAW_VISUAL_TEST_DIR = $screenshotsRoot + } + + $saved = @{} + foreach ($k in $env.Keys) { + $saved[$k] = [Environment]::GetEnvironmentVariable($k, "Process") + [Environment]::SetEnvironmentVariable($k, [string]$env[$k], "Process") + } + + try { + New-Item -ItemType Directory -Force -Path $screenshotsRoot | Out-Null + if (-not (Test-Path -LiteralPath $trayExe)) { + throw "Built tray executable not found at $trayExe. Run build.ps1 first or omit -NoBuild." + } + $proc = Start-Process -FilePath $trayExe -WorkingDirectory $repoRoot -PassThru + Add-Step "launch-tray" "Completed" "Launched tray onboarding for WSL local setup." @{ + pid = $proc.Id; screenshots = $screenshotsRoot; file = $trayExe; runtimeIdentifier = $runtimeIdentifier + } + return $proc + } finally { + foreach ($k in $env.Keys) { + [Environment]::SetEnvironmentVariable($k, $saved[$k], "Process") + } + } +} + +function Wait-ForSetupCompletion { + param([int]$TimeoutSeconds) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastPhase = ""; $lastStatus = "" + while ((Get-Date) -lt $deadline) { + if (Test-Path -LiteralPath $setupStatePath) { + $text = Read-TextFileWithRetry -Path $setupStatePath + $state = $text | ConvertFrom-Json + $copy = Join-Path $runRoot "setup-state.json" + $text | Set-Content -LiteralPath $copy -Encoding UTF8 + + $phase = Convert-SetupPhase $state.Phase + $status = Convert-SetupStatus $state.Status + if ($phase -ne $lastPhase -or $status -ne $lastStatus) { + $lastPhase = $phase; $lastStatus = $status + $script:summary.setupPhases += [ordered]@{ + phase = $phase; status = $status; message = [string]$state.UserMessage; timestamp = (Get-Date).ToString("o") + } + Add-Step "setup-phase-$phase" $status ([string]$state.UserMessage) @{ phase = $phase; status = $status } + } + + if ($status -eq "Complete") { + if ($state.PSObject.Properties.Name -contains "GatewayUrl" -and -not [string]::IsNullOrWhiteSpace([string]$state.GatewayUrl)) { + $script:GatewayUrl = [string]$state.GatewayUrl + $script:summary.selectedGatewayUrl = $script:GatewayUrl + } + Add-Step "setup-state" "Passed" "Setup reached $status." @{ + status = $status; phase = $phase; path = $copy + gatewayUrl = (Get-SafeUriDisplay $script:GatewayUrl) + } + return + } + if ($status -in @("FailedRetryable", "FailedTerminal", "Blocked", "Cancelled")) { + Save-DiagnosticsSnapshot -Reason "setup-failed-$phase" + throw "Setup failed with status $status, phase $phase, code $($state.FailureCode): $($state.UserMessage). Diagnostics: https://aka.ms/wsllogs." + } + } + Start-Sleep -Seconds 2 + } + Save-DiagnosticsSnapshot -Reason "setup-timeout" + throw "Setup did not reach Complete within $TimeoutSeconds seconds. Diagnostics: https://aka.ms/wsllogs." +} + +function Invoke-TrayLocalSetup { + $proc = Start-TrayForLocalSetup + Start-Sleep -Seconds 5 + + # SetupWarningPage hosts the "Set up locally" primary button. + if ($null -eq (Wait-ForUiAutomationElement -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 60)) { + Save-DiagnosticsSnapshot -Reason "setup-local-button-not-found" + throw "UI automation target OnboardingSetupLocal was not found on SetupWarningPage." + } + Invoke-UiAutomationClick -AutomationId "OnboardingSetupLocal" -TimeoutSeconds 5 + + # LocalSetupProgressPage starts the engine on appearance; just wait for state. + Wait-ForSetupCompletion -TimeoutSeconds $TimeoutSeconds + return $proc +} + +function Stop-TrayProcess { + param([object]$Process) + if ($null -ne $Process) { + $procId = $Process.Id + $live = Get-Process -Id $procId -ErrorAction SilentlyContinue + if ($null -ne $live) { + Stop-Process -Id $procId -Force + Add-Step "stop-tray" "Completed" "Stopped tray process by PID after setup validation." @{ pid = $procId } + } else { + Add-Step "stop-tray" "Skipped" "Tray process had already exited before cleanup." @{ pid = $procId } + } + } + Stop-ExistingTrayProcesses -Reason "post-validation" + Stop-WslKeepAliveProcesses +} + +function Convert-GatewayUrlToHealthUri { + param([string]$Url) + $b = [System.UriBuilder]::new($Url) + if ($b.Scheme -eq "ws") { $b.Scheme = "http" } + elseif ($b.Scheme -eq "wss") { $b.Scheme = "https" } + $b.Path = ($b.Path.TrimEnd("/") + "/health") + return $b.Uri.AbsoluteUri +} + +function Save-LoopbackNetworkDiagnostics { + param([string]$Reason) + # Loopback only - no WSL IP, no `hostname -I`, no lan probes. + $safe = $Reason -replace "[^a-zA-Z0-9_.-]", "-" + $tcpPath = Join-Path $commandsRoot "network-$safe-windows-tcp-18789.json" + try { + $cs = @(Get-NetTCPConnection -LocalPort 18789 -ErrorAction Stop | ForEach-Object { + [ordered]@{ + localAddress = $_.LocalAddress; localPort = $_.LocalPort + state = $_.State.ToString(); owningProcess = $_.OwningProcess + } + }) + $cs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Completed" "Captured Windows TCP listener state for loopback gateway port." @{ path = $tcpPath } + } catch { + $_.Exception.Message | Set-Content -LiteralPath $tcpPath -Encoding UTF8 + Add-Step "network-$safe-windows-tcp" "Skipped" "Could not capture Windows TCP listener state. See https://aka.ms/wsllogs." @{ path = $tcpPath } + } +} + +function Save-RedactedSettings { + if (-not (Test-Path -LiteralPath $settingsPath)) { + Add-Step "settings-redacted" "Skipped" "Tray settings file was not found." + return + } + $copy = Join-Path $runRoot "settings.redacted.json" + $c = Read-TextFileWithRetry -Path $settingsPath + $c = $c -replace '("(?:Token|token|GatewayToken|BootstrapToken|bootstrapToken|bootstrap_token|NodeToken|nodeToken)"\s*:\s*")[^"]*(")', '$1$2' + $c | Set-Content -LiteralPath $copy -Encoding UTF8 + Add-Step "settings-redacted" "Completed" "Saved redacted tray settings." @{ path = $copy } +} + +function Test-SetupHistoryPhase { + param([string]$Phase) + if (-not (Test-Path -LiteralPath $setupStatePath)) { return $false } + $state = Read-TextFileWithRetry -Path $setupStatePath | ConvertFrom-Json + if (-not ($state.PSObject.Properties.Name -contains "History")) { return $false } + foreach ($e in @($state.History)) { + if ((Convert-SetupPhase $e.Phase) -eq $Phase -and (Convert-SetupStatus $e.Status) -in @("Running", "Complete")) { + return $true + } + } + return (Convert-SetupPhase $state.Phase) -eq $Phase +} + +function Save-RedactedDeviceIdentityShape { + $idp = Join-Path $validationAppDataRoot "OpenClawTray\device-key-ed25519.json" + if (-not (Test-Path -LiteralPath $idp)) { + Add-Step "device-identity" "Failed" "Device identity file was not found." @{ path = $idp } + return $false + } + $copy = Join-Path $runRoot "device-key.shape.redacted.json" + Copy-RedactedFileIfExists -SourcePath $idp -DestinationPath $copy | Out-Null + try { + $id = Get-Content -LiteralPath $idp -Raw | ConvertFrom-Json + $hasOperatorToken = ($id.PSObject.Properties.Name -contains "DeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.DeviceToken)) -or + ($id.PSObject.Properties.Name -contains "OperatorDeviceToken" -and -not [string]::IsNullOrWhiteSpace([string]$id.OperatorDeviceToken)) + Add-Step "device-identity" ($(if ($hasOperatorToken) { "Passed" } else { "Failed" })) "Checked stored device identity token shape." @{ + path = $copy; hasOperatorToken = $hasOperatorToken + } + return $hasOperatorToken + } catch { + Add-Step "device-identity" "Failed" "Device identity JSON could not be parsed." @{ path = $copy } + return $false + } +} + +function Test-JsonStringProperty { + param([object]$Json, [string[]]$Names) + foreach ($n in $Names) { + if ($Json.PSObject.Properties.Name -contains $n) { + $v = [string]$Json.$n + if (-not [string]::IsNullOrWhiteSpace($v)) { return $true } + } + } + return $false +} + +function Get-JsonStringProperty { + param([object]$Json, [string]$Name) + if ($Json -and $Json.PSObject.Properties.Name -contains $Name) { return [string]$Json.$Name } + return "" +} + +function Invoke-BootstrapHandoffProbe { + # Real upstream setup-code / bootstrap proof. + $stdout = Join-Path $commandsRoot "wsl-bootstrap-token.stdout.txt" + $stderr = Join-Path $commandsRoot "wsl-bootstrap-token.stderr.txt" + $args = @("-d", $DistroName, "--", "/opt/openclaw/bin/openclaw", "qr", "--json", "--url", $GatewayUrl) + & wsl.exe @args > $stdout 2> $stderr + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + $raw = if (Test-Path -LiteralPath $stdout) { Read-TextFileWithRetry -Path $stdout -Attempts 20 -DelayMilliseconds 250 } else { "" } + Write-TextFileWithRetry -Path $stdout -Content (Redact-SensitiveGatewayOutput $raw) -Attempts 20 -DelayMilliseconds 250 + + if ($exitCode -ne 0) { + Add-Step "wsl-bootstrap-token" "Failed" "Gateway QR command failed with exit code $exitCode." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr + } + throw "BootstrapTokenCommandFailed: openclaw qr --json failed. See $stdout and $stderr." + } + + $hasSetupCode = $false; $hasDirectToken = $false + try { + $qr = $raw | ConvertFrom-Json + $hasSetupCode = Test-JsonStringProperty $qr @("setupCode", "setup_code") + $hasDirectToken = Test-JsonStringProperty $qr @("bootstrapToken", "bootstrap_token", "token") + } catch { + throw "BootstrapTokenJsonInvalid: openclaw qr --json did not produce valid JSON: $($_.Exception.Message)" + } + + $shape = if ($hasSetupCode) { "UpstreamSetupCode" } elseif ($hasDirectToken) { "DirectBootstrapToken" } else { "Unknown" } + $script:summary.pairingValidation["bootstrapQrShape"] = $shape + $script:summary.pairingValidation["realUpstreamBootstrapHandoff"] = $hasSetupCode + + Add-Step "wsl-bootstrap-token" "Completed" "Gateway QR command completed; bootstrap shape is $shape." @{ + arguments = ($args -join " "); exitCode = $exitCode; stdout = $stdout; stderr = $stderr; bootstrapQrShape = $shape; realUpstreamBootstrapHandoff = $hasSetupCode + } + + if ($RequireRealGatewayBootstrap -and -not $hasSetupCode) { + throw "RealGatewayBootstrapRequired: expected upstream setupCode bootstrap handoff, but openclaw qr --json returned $shape." + } +} + +function Invoke-OperatorPairingProof { + if (-not $RequireOperatorPairing) { + Add-Step "operator-pairing-proof" "Skipped" "Operator pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairOperator")) { + Save-DiagnosticsSnapshot -Reason "operator-pair-phase-missing" + throw "OperatorPairingProofFailed: setup state did not record PairOperator." + } + if (-not (Save-RedactedDeviceIdentityShape)) { + Save-DiagnosticsSnapshot -Reason "operator-device-token-missing" + throw "OperatorPairingProofFailed: stored operator device token is missing." + } + Invoke-LoggedProcess "operator-stored-token-reconnect" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", + "--connect-timeout-ms", "15000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["operatorPaired"] = $true + Add-Step "operator-pairing-proof" "Passed" "Stored operator device token reconnect succeeded." +} + +function Invoke-WindowsNodePairingProof { + # Windows tray IS the node (per Mike). Confirm the PairWindowsTrayNode phase + # ran and that gateway node.list returns the tray node. + if (-not $RequireWindowsNodePairing) { + Add-Step "windows-node-pairing-proof" "Skipped" "Windows tray node pairing proof was not required." + return + } + if (-not (Test-SetupHistoryPhase -Phase "PairWindowsTrayNode")) { + Save-DiagnosticsSnapshot -Reason "windows-node-pair-phase-missing" + throw "WindowsNodePairingProofFailed: setup state did not record PairWindowsTrayNode." + } + Invoke-LoggedProcess "windows-node-list-proof" "dotnet" @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", "--require-stored-device-token", "--require-node", + "--connect-timeout-ms", "90000" + ) -Environment (Get-ValidationAppEnvironment) -SensitiveOutput + + $script:summary.pairingValidation["windowsNodePaired"] = $true + Add-Step "windows-node-pairing-proof" "Passed" "Gateway node.list returned the Windows tray node." +} + +function Invoke-SmokeChecks { + Invoke-LoggedProcess "wsl-list-after" "wsl.exe" @("--list", "--verbose") -IgnoreExitCode + Save-LoopbackNetworkDiagnostics -Reason "post-install" + + # Gateway in WSL via systemd user unit (UpstreamInstall layout). + Invoke-LoggedProcess "wsl-openclaw-version" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "--version") + Invoke-LoggedProcess "wsl-openclaw-config-validate" "wsl.exe" @( + "-d", $DistroName, "-u", "openclaw", "--", "/opt/openclaw/bin/openclaw", "config", "validate") + Invoke-LoggedProcess "wsl-gateway-journal" "wsl.exe" @( + "-d", $DistroName, "-u", "root", "--", "journalctl", "--user", "-u", "openclaw-gateway", + "--no-pager", "-n", "200") -IgnoreExitCode -SensitiveOutput + + # Loopback-only health probe. + $healthUri = Convert-GatewayUrlToHealthUri -Url $GatewayUrl + $healthPath = Join-Path $commandsRoot "gateway-health.json" + try { + $h = Invoke-RestMethod -Uri $healthUri -TimeoutSec 10 + $h | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $healthPath -Encoding UTF8 + if (-not $h.ok) { throw "Gateway health response did not contain ok=true." } + $gw = if ($h.PSObject.Properties.Name -contains "gateway") { $h.gateway } else { $null } + $version = Get-JsonStringProperty $gw "version" + $displayName = Get-JsonStringProperty $gw "displayName" + $isDev = $version -like "*-dev*" -or $displayName -like "Dev OpenClaw*" + $script:summary.pairingValidation["gatewayImplementation"] = if ($isDev) { "DevShim" } else { "ProductionCandidate" } + Add-Step "gateway-health" "Passed" "Gateway health endpoint returned ok=true." @{ uri = $healthUri; path = $healthPath } + } catch { + throw "Gateway health check failed for ${healthUri}: $($_.Exception.Message). Diagnostics: https://aka.ms/wsllogs." + } + + Invoke-BootstrapHandoffProbe + Save-RedactedSettings + Invoke-OperatorPairingProof + Invoke-WindowsNodePairingProof + + $args = @( + "run", "--project", $cliProject, "--", + "--probe-read", "--skip-chat", + "--message", "openclaw validation ping", + "--connect-timeout-ms", "15000" + ) + if ($RequireOperatorPairing) { $args += "--require-stored-device-token" } + Invoke-LoggedProcess "openclaw-cli-probe" "dotnet" $args -Environment (Get-ValidationAppEnvironment) -SensitiveOutput +} + +function Invoke-DistroUnregisterIfPresent { + param([string]$Reason) + Stop-WslKeepAliveProcesses + # Authoritative repair primitive: `wsl --unregister`. NEVER `wsl --shutdown`. + Invoke-LoggedProcess "wsl-unregister-$Reason" "wsl.exe" @("--unregister", $DistroName) -IgnoreExitCode + + if (Test-Path -LiteralPath $wslInstallLocation) { + try { + Remove-Item -LiteralPath $wslInstallLocation -Recurse -Force -ErrorAction Stop + Add-Step "remove-install-location-$Reason" "Completed" "Removed install location directory." @{ path = $wslInstallLocation } + } catch { + Add-Step "remove-install-location-$Reason" "Skipped" "Could not remove install location: $($_.Exception.Message)" @{ path = $wslInstallLocation } + } + } +} + +function Invoke-PreIterationCleanup { + param([int]$Index) + if ($Scenario -in @("FreshMachine", "Recreate")) { + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-pre" + # Wipe isolated AppData so identity store starts empty. + foreach ($p in @($validationAppDataRoot, $validationLocalAppDataRoot)) { + if (Test-Path -LiteralPath $p) { + try { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction Stop } catch { } + } + } + } else { + Stop-WslKeepAliveProcesses + } +} + +function Invoke-PostIterationCleanup { + param([int]$Index, [bool]$IterationFailed) + if ($Scenario -ne "Recreate") { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Post-iteration distro cleanup is only required in Recreate scenario." + return "Skipped" + } + if ($IterationFailed -and $KeepFailedDistro) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Keeping failed WSL distro for inspection (-KeepFailedDistro)." @{ distroName = $DistroName } + return "Skipped" + } + if (-not $IterationFailed -and -not $CleanupAfterSuccess) { + $script:summary.cleanupStatus = if ($script:summary.cleanupStatus -eq "Failed") { "Failed" } else { "Skipped" } + Add-Step "iteration-$Index-cleanup" "Skipped" "Leaving successful distro (-CleanupAfterSuccess:`$false)." @{ distroName = $DistroName } + return "Skipped" + } + try { + $script:summary.cleanupStatus = "Running" + Invoke-DistroUnregisterIfPresent -Reason "iteration-$Index-post" + $script:summary.cleanupStatus = "Passed" + Add-Step "iteration-$Index-cleanup" "Passed" "Cleaned recreated WSL distro after validation iteration." @{ distroName = $DistroName } + return "Passed" + } catch { + $script:summary.cleanupStatus = "Failed" + Add-Step "iteration-$Index-cleanup" "Failed" $_.Exception.Message + if (-not $ContinueOnCleanupFailure) { throw } + return "Failed" + } +} + +function New-IterationRecord { + param([int]$Index) + return [ordered]@{ + index = $Index + distroName = $DistroName + installLocation = $wslInstallLocation + validationStatus = "Running" + cleanupStatus = "NotStarted" + error = $null + cleanupError = $null + startedAt = (Get-Date).ToString("o") + finishedAt = $null + } +} + +function Invoke-ValidationIteration { + param([int]$Index) + $iteration = New-IterationRecord -Index $Index + $script:summary.iterations += $iteration + Add-Step "iteration-$Index" "Started" "Starting validation iteration $Index." + $trayProcess = $null + $iterationFailed = $false + + try { + Invoke-RepositoryValidation + Invoke-PreIterationCleanup -Index $Index + $trayProcess = Invoke-TrayLocalSetup + Invoke-SmokeChecks + + Add-Step "iteration-$Index" "Passed" "Validation iteration $Index passed." + $iteration.validationStatus = "Passed" + $script:summary.validationStatus = "Passed" + } catch { + $iterationFailed = $true + $iteration.validationStatus = "Failed" + $iteration.error = $_.Exception.Message + $script:summary.validationStatus = "Failed" + Save-DiagnosticsSnapshot -Reason "iteration-$Index-failed" + throw + } finally { + try { + Stop-TrayProcess -Process $trayProcess + $iteration.cleanupStatus = Invoke-PostIterationCleanup -Index $Index -IterationFailed $iterationFailed + } catch { + $iteration.cleanupStatus = "Failed" + $iteration.cleanupError = $_.Exception.Message + throw + } finally { + $iteration.finishedAt = (Get-Date).ToString("o") + } + } +} + +New-Item -ItemType Directory -Force -Path $runRoot, $commandsRoot, $screenshotsRoot | Out-Null + +$exitCode = 0 +try { + Assert-DestructiveSafety + Invoke-Preflight + + if ($Scenario -eq "PreflightOnly") { + Add-Step "scenario" "Passed" "Preflight completed." + $script:summary.validationStatus = "Passed" + $script:summary.cleanupStatus = "Skipped" + } elseif ($Scenario -eq "Recreate" -or $Iterations -gt 1) { + if ($Iterations -lt 1) { throw "-Iterations must be at least 1." } + for ($i = 1; $i -le $Iterations; $i++) { + try { Invoke-ValidationIteration -Index $i } + catch { + Add-Step "iteration-$i" "Failed" $_.Exception.Message + if (-not $ContinueOnFailure) { throw } + } + } + } else { + # UpstreamInstall or FreshMachine, single shot. + Invoke-ValidationIteration -Index 1 + } + + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Passed" } + if ($script:summary.cleanupStatus -in @("Running", "NotStarted")) { $script:summary.cleanupStatus = "Skipped" } + if ($script:summary.validationStatus -eq "Failed") { + $script:summary.status = "Failed"; $exitCode = 1 + } else { + $script:summary.status = if ($script:summary.cleanupStatus -eq "Failed") { "PassedWithCleanupFailure" } else { "Passed" } + } +} catch { + $script:summary.status = "Failed" + if ($script:summary.validationStatus -eq "Running") { $script:summary.validationStatus = "Failed" } + if ($script:summary.cleanupStatus -eq "Running") { $script:summary.cleanupStatus = "Failed" } + $script:summary.error = $_.Exception.Message + Add-Step "validation" "Failed" $_.Exception.Message + $exitCode = 1 +} finally { + Write-Summary +} + +Write-Host "Validation summary: $summaryPath" +if ($script:summary.status -eq "Failed") { + Write-Host "Diagnostics: see https://aka.ms/wsllogs for WSL networking/lifecycle logs." +} +exit $exitCode diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index 3fa66eff..148ac645 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -19,15 +21,25 @@ public class DeviceIdentity private PublicKey? _publicKey; private string? _deviceId; private string? _deviceToken; + private string[]? _deviceTokenScopes; + private string? _nodeDeviceToken; + private string[]? _nodeDeviceTokenScopes; private static readonly SignatureAlgorithm Ed25519Algorithm = SignatureAlgorithm.Ed25519; public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized"); public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized"); public string? DeviceToken => _deviceToken; + public IReadOnlyList? DeviceTokenScopes => _deviceTokenScopes; + public string? NodeDeviceToken => _nodeDeviceToken; + public IReadOnlyList? NodeDeviceTokenScopes => _nodeDeviceTokenScopes; - public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) + public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + TryReadStoredDeviceTokenForRole(dataPath, "operator", logger); + + public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) { + var tokenRole = ParseDeviceTokenRole(role); var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); if (!File.Exists(keyPath)) { @@ -37,7 +49,11 @@ public class DeviceIdentity try { using var doc = JsonDocument.Parse(File.ReadAllText(keyPath)); - if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) && + var tokenPropertyName = tokenRole == DeviceTokenRole.Node + ? nameof(DeviceKeyData.NodeDeviceToken) + : nameof(DeviceKeyData.DeviceToken); + + if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) && deviceToken.ValueKind == JsonValueKind.String) { var value = deviceToken.GetString(); @@ -62,6 +78,9 @@ public class DeviceIdentity public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) => !string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger)); + + public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) => + !string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger)); public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null) { @@ -103,6 +122,9 @@ private void LoadExisting() _publicKey = _privateKey.PublicKey; _deviceId = data.DeviceId; _deviceToken = data.DeviceToken; + _deviceTokenScopes = NormalizeScopes(data.DeviceTokenScopes); + _nodeDeviceToken = data.NodeDeviceToken; + _nodeDeviceTokenScopes = NormalizeScopes(data.NodeDeviceTokenScopes); _logger.Info($"Loaded Ed25519 device identity: {_deviceId?[..16]}..."); } @@ -310,11 +332,41 @@ public string BuildDebugPayload(string nonce, long signedAtMs, string clientId, /// Store the device token received after pairing approval /// public void StoreDeviceToken(string token) + { + StoreDeviceTokenCore(token, null); + } + + public void StoreDeviceTokenWithScopes(string token, IEnumerable? scopes) + { + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + public void StoreDeviceTokenForRole(string role, string token, IEnumerable? scopes = null) + { + var tokenRole = ParseDeviceTokenRole(role); + if (tokenRole == DeviceTokenRole.Node) + { + StoreNodeDeviceTokenCore(token, NormalizeScopes(scopes)); + return; + } + + StoreDeviceTokenCore(token, NormalizeScopes(scopes)); + } + + private static DeviceTokenRole ParseDeviceTokenRole(string role) => role switch + { + "operator" => DeviceTokenRole.Operator, + "node" => DeviceTokenRole.Node, + _ => throw new ArgumentOutOfRangeException(nameof(role), "Device token role must be 'operator' or 'node'.") + }; + + private void StoreDeviceTokenCore(string token, string[]? scopes) { if (string.IsNullOrWhiteSpace(token)) throw new ArgumentException("Device token cannot be empty.", nameof(token)); _deviceToken = token; + _deviceTokenScopes = scopes; // Update the key file with the token try @@ -326,6 +378,7 @@ public void StoreDeviceToken(string token) if (data != null) { data.DeviceToken = token; + data.DeviceTokenScopes = scopes; File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath); _logger.Info("Device token stored"); @@ -337,6 +390,48 @@ public void StoreDeviceToken(string token) _logger.Error($"Failed to store device token: {ex.Message}"); } } + + private void StoreNodeDeviceTokenCore(string token, string[]? scopes) + { + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Device token cannot be empty.", nameof(token)); + + _nodeDeviceToken = token; + _nodeDeviceTokenScopes = scopes; + + try + { + if (File.Exists(_keyPath)) + { + var json = File.ReadAllText(_keyPath); + var data = JsonSerializer.Deserialize(json); + if (data != null) + { + data.NodeDeviceToken = token; + data.NodeDeviceTokenScopes = scopes; + File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + _logger.Info("Node device token stored"); + } + } + } + catch (Exception ex) + { + _logger.Error($"Failed to store node device token: {ex.Message}"); + } + } + + private static string[]? NormalizeScopes(IEnumerable? scopes) + { + if (scopes == null) + return null; + + var normalized = scopes + .Where(scope => !string.IsNullOrWhiteSpace(scope)) + .Select(scope => scope.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return normalized.Length == 0 ? null : normalized; + } private static string Base64UrlEncode(byte[] data) { @@ -346,12 +441,21 @@ private static string Base64UrlEncode(byte[] data) .TrimEnd('='); } + private enum DeviceTokenRole + { + Operator, + Node + } + private class DeviceKeyData { public string? PrivateKeyBase64 { get; set; } public string? PublicKeyBase64 { get; set; } public string? DeviceId { get; set; } public string? DeviceToken { get; set; } + public string[]? DeviceTokenScopes { get; set; } + public string? NodeDeviceToken { get; set; } + public string[]? NodeDeviceTokenScopes { get; set; } public string? Algorithm { get; set; } public long CreatedAt { get; set; } } diff --git a/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs b/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs new file mode 100644 index 00000000..bfe1270f --- /dev/null +++ b/src/OpenClaw.Shared/LocalGatewayUrlClassifier.cs @@ -0,0 +1,25 @@ +using System; + +namespace OpenClaw.Shared; + +/// +/// Shared literal-host classifier for gateway URLs that point at the local machine. +/// +public static class LocalGatewayUrlClassifier +{ + public static bool IsLocalGatewayUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) return false; + + try + { + var uri = new Uri(url); + var host = uri.Host.ToLowerInvariant(); + return host is "localhost" or "127.0.0.1" or "::1" or "[::1]"; + } + catch + { + return false; + } + } +} diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index 0a3e402c..589914dd 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -18,6 +19,7 @@ public class OpenClawGatewayClient : WebSocketClientBase private const string OperatorRole = "operator"; private const string OperatorPlatform = "windows"; private const string OperatorDeviceFamily = "desktop"; + private static readonly Regex s_pairingRequestIdRegex = new("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled); private static readonly string[] s_operatorScopes = [ "operator.admin", @@ -53,6 +55,7 @@ private enum SignatureTokenMode private readonly object _pendingChatSendLock = new(); private readonly object _sessionsLock = new(); private readonly DeviceIdentity _deviceIdentity; + private readonly string _currentGatewayUrl; private string _mainSessionKey = "main"; private string? _operatorDeviceId; private string[] _grantedOperatorScopes = Array.Empty(); @@ -72,15 +75,22 @@ private enum SignatureTokenMode private bool _agentFileGetUnsupported; private bool _operatorReadScopeUnavailable; private bool _pairingRequiredAwaitingApproval; + private string? _pairingRequiredRequestId; private bool _authFailed; - private readonly bool _useBootstrapHandoffAuth; + private readonly bool _tokenIsBootstrapToken; + private readonly bool _bootstrapPairAsNode; /// True when the gateway reported "pairing required" for this device. public bool IsPairingRequired => _pairingRequiredAwaitingApproval; + /// Safe requestId returned in structured pairing-required details, when present. + public string? PairingRequiredRequestId => _pairingRequiredRequestId; + /// True when the device signature was rejected in all supported modes. public bool IsAuthFailed => _authFailed; + /// The gateway auth token used for this connection. + public string ConnectAuthToken => _connectAuthToken; private IReadOnlyList? _userRules; private bool _preferStructuredCategories = true; private readonly System.Collections.Concurrent.ConcurrentDictionary> _pendingWizardResponses = new(); @@ -178,21 +188,20 @@ protected override void OnDisposing() public IReadOnlyList GrantedOperatorScopes => _grantedOperatorScopes; public bool IsConnectedToGateway => IsConnected; - public OpenClawGatewayClient( - string gatewayUrl, - string token, - IOpenClawLogger? logger = null, - bool useBootstrapHandoffAuth = false) + public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null, bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false) : base(gatewayUrl, token, logger) { - _useBootstrapHandoffAuth = useBootstrapHandoffAuth; + _tokenIsBootstrapToken = tokenIsBootstrapToken; + _bootstrapPairAsNode = bootstrapPairAsNode; + _currentGatewayUrl = gatewayUrl; var dataPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); _deviceIdentity = new DeviceIdentity(dataPath, _logger); _deviceIdentity.Initialize(); - _connectAuthToken = _deviceIdentity.DeviceToken ?? _token; + _connectAuthToken = _deviceIdentity.DeviceToken ?? (_tokenIsBootstrapToken ? string.Empty : _token); } public async Task DisconnectAsync() @@ -290,6 +299,7 @@ public async Task SendWizardRequestAsync(string method, object? par if (!IsConnected) throw new InvalidOperationException("Gateway connection is not open"); + _logger.Info($"[GatewayClient] Sending frame: {method}"); var requestId = Guid.NewGuid().ToString(); var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingWizardResponses[requestId] = completion; @@ -604,13 +614,14 @@ private async Task SendConnectMessageAsync(string? nonce = null) { var requestId = Guid.NewGuid().ToString(); TrackPendingRequest(requestId, "connect"); - var requestedScopes = GetRequestedOperatorScopes(); + var role = GetConnectRole(); + var requestedScopes = GetRequestedScopes(role); var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var connectNonce = nonce ?? string.Empty; var signatureToken = _signatureTokenMode is SignatureTokenMode.V3EmptyToken or SignatureTokenMode.V2EmptyToken ? string.Empty - : _connectAuthToken; + : GetSignatureToken(); var signature = _signatureTokenMode is SignatureTokenMode.V2AuthToken or SignatureTokenMode.V2EmptyToken ? _deviceIdentity.SignConnectPayloadV2( @@ -618,7 +629,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) signedAt, OperatorClientId, OperatorClientMode, - OperatorRole, + role, requestedScopes, signatureToken) : _deviceIdentity.SignConnectPayloadV3( @@ -626,7 +637,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) signedAt, OperatorClientId, OperatorClientMode, - OperatorRole, + role, requestedScopes, signatureToken, OperatorPlatform, @@ -650,7 +661,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) mode = OperatorClientMode, displayName = OperatorClientDisplayName }, - role = OperatorRole, + role, scopes = requestedScopes, caps = Array.Empty(), commands = Array.Empty(), @@ -680,10 +691,35 @@ private async Task SendConnectMessageAsync(string? nonce = null) } } - private string[] GetRequestedOperatorScopes() => - _useBootstrapHandoffAuth && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) - ? s_operatorBootstrapScopes + private string GetConnectRole() + { + return _bootstrapPairAsNode && _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) + ? "node" + : OperatorRole; + } + + private string[] GetRequestedScopes(string role) + { + if (role == "node") + return []; + + if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + { + // Fresh-device ordering is intentional: + // 1. QR/setup-code bootstrap keeps bounded handoff scopes. + // 2. Standard token auth against the local loopback gateway we installed requests + // full operator scopes, including operator.admin, for the easy-button setup flow. + // 3. Remote/non-loopback fresh standard devices remain bounded and require manual approval. + if (!_tokenIsBootstrapToken && LocalGatewayUrlClassifier.IsLocalGatewayUrl(_currentGatewayUrl)) + return s_operatorScopes; + + return s_operatorBootstrapScopes; + } + + return _deviceIdentity.DeviceTokenScopes is { Count: > 0 } scopes + ? scopes.ToArray() : s_operatorScopes; + } /// /// Builds the auth payload for the connect handshake, matching the gateway's @@ -693,27 +729,34 @@ private string[] GetRequestedOperatorScopes() => /// private Dictionary BuildAuthPayload() { - var auth = new Dictionary { ["token"] = _connectAuthToken }; - - if (!_useBootstrapHandoffAuth) - { - return auth; - } + var auth = new Dictionary(); if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) { - // Paired device: send explicit device token for cleaner auth path auth["deviceToken"] = _deviceIdentity.DeviceToken; } - else + else if (_tokenIsBootstrapToken) { - // Fresh device: send bootstrap token for initial pairing + // Fresh QR/setup-code device: do not also send auth.token, which upstream treats + // as an explicit gateway token and therefore suppresses bootstrap pairing. auth["bootstrapToken"] = _token; } + else + { + auth["token"] = _connectAuthToken; + } return auth; } + private string GetSignatureToken() + { + if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + return _deviceIdentity.DeviceToken; + + return _tokenIsBootstrapToken ? _token : _connectAuthToken; + } + private async Task SendTrackedRequestAsync(string method, object? parameters = null) { if (!IsConnected) return; @@ -809,6 +852,13 @@ private void ClearPendingRequests() _pendingChatSendRequests.Clear(); } + + foreach (var completion in _pendingWizardResponses.Values) + { + completion.TrySetException(new OperationCanceledException("Gateway connection lost while waiting for wizard response")); + } + + _pendingWizardResponses.Clear(); } private void TrackPendingChatSend(string requestId, TaskCompletionSource completion) @@ -915,7 +965,8 @@ private void HandleResponse(JsonElement root) else if (root.TryGetProperty("payload", out var wizPayload)) { // Log the payload kind for debugging - _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizPayload.ToString()?.Substring(0, Math.Min(200, wizPayload.ToString()?.Length ?? 0))}"); + var wizardPayloadText = TokenSanitizer.Sanitize(wizPayload.ToString()); + _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizardPayloadText[..Math.Min(200, wizardPayloadText.Length)]}"); wizardCompletion.TrySetResult(wizPayload.Clone()); } else @@ -943,16 +994,33 @@ private void HandleResponse(JsonElement root) if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok") { _pairingRequiredAwaitingApproval = false; + _pairingRequiredRequestId = null; _authFailed = false; ResetReconnectAttempts(); _operatorDeviceId = TryGetHandshakeDeviceId(payload); _grantedOperatorScopes = TryGetHandshakeScopes(payload); _mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main"; PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload)); - var newDeviceToken = TryGetHandshakeDeviceToken(payload); + if (_bootstrapPairAsNode) + { + var nodeDeviceToken = TryGetHandshakeDeviceTokenCore(payload, "node", allowDirectDeviceTokenFallback: true); + if (!string.IsNullOrWhiteSpace(nodeDeviceToken)) + { + var nodeDeviceTokenScopes = TryGetHandshakeDeviceTokenScopesCore(payload, "node", allowDirectDeviceTokenFallback: true); + _deviceIdentity.StoreDeviceTokenForRole("node", nodeDeviceToken, nodeDeviceTokenScopes); + _logger.Info("Node device token stored for Windows tray node reconnect"); + } + } + + var newDeviceToken = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); if (!string.IsNullOrWhiteSpace(newDeviceToken)) { - _deviceIdentity.StoreDeviceToken(newDeviceToken); + var deviceTokenScopes = _bootstrapPairAsNode + ? TryGetHandshakeDeviceTokenScopesCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false) + : TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole: null); + _deviceIdentity.StoreDeviceTokenWithScopes(newDeviceToken, deviceTokenScopes); _connectAuthToken = newDeviceToken; _logger.Info("Operator device token stored for reconnect"); } @@ -1136,10 +1204,12 @@ private void HandleRequestError(string? method, JsonElement root) return; } + var pairingDetails = TryGetPairingConnectErrorDetails(root); if (method == "connect" && - message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)) + (pairingDetails.IsPairingRequired || message.Contains("pairing required", StringComparison.OrdinalIgnoreCase))) { _pairingRequiredAwaitingApproval = true; + _pairingRequiredRequestId = pairingDetails.RequestId; _logger.Warn("Pairing approval required for this device; auto-reconnect paused until manual reconnect or app restart"); RaiseStatusChanged(ConnectionStatus.Error); return; @@ -1273,6 +1343,48 @@ private static bool TryGetNodesPayload(JsonElement payload, out JsonElement node return null; } + private static PairingConnectErrorDetails TryGetPairingConnectErrorDetails(JsonElement root) + { + if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object) + return default; + + if (!TryGetPairingDetailsElement(error, out var details) || details.ValueKind != JsonValueKind.Object) + return default; + + var isPairingRequired = details.TryGetProperty("code", out var code) + && code.ValueKind == JsonValueKind.String + && string.Equals(code.GetString(), "PAIRING_REQUIRED", StringComparison.Ordinal); + var requestId = TryGetSafePairingRequestId(details); + return new PairingConnectErrorDetails(isPairingRequired, requestId); + } + + private static bool TryGetPairingDetailsElement(JsonElement error, out JsonElement details) + { + if (error.TryGetProperty("details", out details)) + return true; + + if (error.TryGetProperty("data", out var data) + && data.ValueKind == JsonValueKind.Object + && data.TryGetProperty("details", out details)) + { + return true; + } + + details = default; + return false; + } + + private static string? TryGetSafePairingRequestId(JsonElement details) + { + if (!details.TryGetProperty("requestId", out var requestId) || requestId.ValueKind != JsonValueKind.String) + return null; + + var value = requestId.GetString()?.Trim(); + return value is not null && s_pairingRequestIdRegex.IsMatch(value) ? value : null; + } + + private readonly record struct PairingConnectErrorDetails(bool IsPairingRequired, string? RequestId); + private static bool IsUnknownMethodError(string errorMessage) { return errorMessage.Contains("unknown method", StringComparison.OrdinalIgnoreCase); @@ -1326,25 +1438,38 @@ private static bool IsSessionCommandMethod(string method) private static string[] TryGetHandshakeScopes(JsonElement payload) { + if (payload.TryGetProperty("auth", out var authPayload) && + authPayload.ValueKind == JsonValueKind.Object && + authPayload.TryGetProperty("scopes", out var authScopes) && + authScopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(authScopes); + } + if (payload.TryGetProperty("scopes", out var scopesProp) && scopesProp.ValueKind == JsonValueKind.Array) { - var buffer = new string[scopesProp.GetArrayLength()]; - var count = 0; - foreach (var scope in scopesProp.EnumerateArray()) + return ReadStringArray(scopesProp); + } + + return []; + } + + private static string[] ReadStringArray(JsonElement array) + { + var buffer = new string[array.GetArrayLength()]; + var count = 0; + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) { - if (scope.ValueKind == JsonValueKind.String) - { - var value = scope.GetString(); - if (!string.IsNullOrWhiteSpace(value)) - buffer[count++] = value; - } + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + buffer[count++] = value; } - - return buffer[..count]; } - return []; + return buffer[..count]; } private static string? TryGetHandshakeMainSessionKey(JsonElement payload) @@ -1369,12 +1494,49 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) } private static string? TryGetHandshakeDeviceToken(JsonElement payload) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole: null); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) { if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) { return null; } + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) && + entry.TryGetProperty("deviceToken", out var roleToken) && + roleToken.ValueKind == JsonValueKind.String) + { + var roleTokenValue = roleToken.GetString(); + if (!string.IsNullOrWhiteSpace(roleTokenValue)) + return roleTokenValue; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + if (!authPayload.TryGetProperty("deviceToken", out var deviceToken) || deviceToken.ValueKind != JsonValueKind.String) { return null; @@ -1384,6 +1546,54 @@ private static string[] TryGetHandshakeScopes(JsonElement payload) return string.IsNullOrWhiteSpace(value) ? null : value; } + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole) + { + return TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole, allowDirectDeviceTokenFallback: true); + } + + private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback) + { + if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(preferredRole) && + authPayload.TryGetProperty("deviceTokens", out var deviceTokens) && + deviceTokens.ValueKind == JsonValueKind.Array) + { + foreach (var entry in deviceTokens.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + continue; + + if (entry.TryGetProperty("role", out var role) && + role.ValueKind == JsonValueKind.String && + string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase)) + { + return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array + ? ReadStringArray(roleScopes) + : []; + } + } + + if (!allowDirectDeviceTokenFallback) + { + return null; + } + } + + if (authPayload.TryGetProperty("deviceToken", out var deviceToken) && + deviceToken.ValueKind == JsonValueKind.String && + authPayload.TryGetProperty("scopes", out var scopes) && + scopes.ValueKind == JsonValueKind.Array) + { + return ReadStringArray(scopes); + } + + return null; + } + public string BuildMissingScopeFixCommands(string missingScope) { var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim(); diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index f0685f5b..823b9519 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -16,8 +16,14 @@ public class SettingsData public string? SshTunnelHost { get; set; } public int SshTunnelRemotePort { get; set; } = 18789; public int SshTunnelLocalPort { get; set; } = 18789; - public bool AutoStart { get; set; } + public bool AutoStart { get; set; } = true; public bool GlobalHotkeyEnabled { get; set; } = true; + /// + /// One-shot gate: set to true after the post-onboarding "first-run" bootstrap + /// kickoff message has been injected into the chat exactly once. Subsequent + /// chat-window launches skip injection. + /// + public bool HasInjectedFirstRunBootstrap { get; set; } public bool ShowNotifications { get; set; } = true; public string? NotificationSound { get; set; } public bool NotifyHealth { get; set; } = true; diff --git a/src/OpenClaw.Shared/TokenSanitizer.cs b/src/OpenClaw.Shared/TokenSanitizer.cs index 7e5026c0..e92d6d9c 100644 --- a/src/OpenClaw.Shared/TokenSanitizer.cs +++ b/src/OpenClaw.Shared/TokenSanitizer.cs @@ -12,6 +12,10 @@ public static class TokenSanitizer @"""(?[^""]*(?:token|secret|bearer|authorization)[^""]*)""\s*:\s*""(?[^""]+)""", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + private static readonly Regex BareGatewayHexTokenPattern = new( + @"(? $"\"{match.Groups["key"].Value}\":\"[REDACTED]\""); + sanitized = BareGatewayHexTokenPattern.Replace(sanitized, "[REDACTED_TOKEN]"); return LongBase64UrlPattern.Replace(sanitized, "[REDACTED_TOKEN]"); } } diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index c88b8242..5ba4c6e0 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -34,6 +34,10 @@ public class WindowsNodeClient : WebSocketClientBase // even after OnDisconnected clears _isPendingApproval. private volatile bool _pairingBlocked; private volatile bool _rateLimited; + // Bug 3: source-side idempotency for PairingStatusChanged. HandleHelloOk runs on every + // WS reconnect and re-fires PairingStatus.Paired even when nothing changed, causing a + // toast storm in the tray UI. Track the last emitted status and only fire on transitions. + private PairingStatus? _lastEmittedPairingStatus; private readonly string _gatewayToken; private readonly string? _bootstrapToken; @@ -61,7 +65,7 @@ public class WindowsNodeClient : WebSocketClientBase public bool IsPendingApproval => _isPendingApproval; /// True if device is paired via a stored token or an explicit gateway approval event. - public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); /// Device ID for display/approval (first 16 chars of full ID) public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16 @@ -78,7 +82,7 @@ public class WindowsNodeClient : WebSocketClientBase protected override string ClientRole => "node"; public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null) - : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger) + : base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger) { _gatewayToken = NormalizeOptionalCredential(token); _bootstrapToken = NormalizeOptionalCredential(bootstrapToken); @@ -102,8 +106,14 @@ private static string NormalizeOptionalCredential(string? credential) return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential; } - private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath) + private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger) { + var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger); + if (!string.IsNullOrEmpty(storedNodeToken)) + { + return storedNodeToken; + } + var gatewayToken = NormalizeOptionalCredential(token); if (!string.IsNullOrEmpty(gatewayToken)) { @@ -116,13 +126,26 @@ private static string ResolveRequiredCredential(string? token, string? bootstrap return bootstrap; } - var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath); - if (!string.IsNullOrEmpty(storedDeviceToken)) + throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); + } + + public static bool HasStoredNodeDeviceToken(string dataPath, IOpenClawLogger? logger = null) + { + return !string.IsNullOrWhiteSpace(TryLoadStoredNodeToken(dataPath, logger)); + } + + private static string? TryLoadStoredNodeToken(string dataPath, IOpenClawLogger? logger) + { + try { - return storedDeviceToken; + var identity = new DeviceIdentity(dataPath, logger); + identity.Initialize(); + return string.IsNullOrWhiteSpace(identity.NodeDeviceToken) ? null : identity.NodeDeviceToken; + } + catch + { + return null; } - - throw new ArgumentException("Token or bootstrap token is required.", nameof(token)); } /// @@ -190,7 +213,7 @@ protected override async Task ProcessMessageAsync(string json) try { // Log raw messages at debug level (visible in dbgview, not in log file noise) - _logger.Debug($"[NODE RX] {json}"); + _logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}"); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -286,7 +309,7 @@ private void HandlePairingRequestedEvent(JsonElement root, string? eventType) _logger.Info($"[NODE] Pairing requested for this device via {eventType}"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, $"Run: openclaw devices approve {ShortDeviceId}...")); @@ -318,7 +341,7 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve _pairingBlocked = false; // Allow reconnect after approval _pairingApprovedAwaitingReconnect = true; - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId, "Pairing approved; reconnecting to refresh node state.")); @@ -334,7 +357,7 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve _isPaired = false; _pairingApprovedAwaitingReconnect = false; - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Rejected, _deviceIdentity.DeviceId, null)); @@ -505,7 +528,7 @@ private async Task HandleConnectChallengeAsync(JsonElement root) private async Task SendNodeConnectAsync(string? nonce, long ts) { - var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken); + var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken); var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken); _logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})"); @@ -575,9 +598,9 @@ private string BuildNodeConnectMessage(string? nonce, long ts) private (Dictionary Auth, string TokenForSignature) BuildConnectAuth() { - if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { - return (new Dictionary { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken); + return (new Dictionary { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken); } if (!string.IsNullOrEmpty(_bootstrapToken)) @@ -634,8 +657,8 @@ private void HandleResponse(JsonElement root) _isPaired = true; _pairingApprovedAwaitingReconnect = false; _logger.Info("Received device token - we are now paired!"); - _deviceIdentity.StoreDeviceToken(deviceToken); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + _deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload)); + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId, wasWaiting ? "Pairing approved!" : null)); @@ -648,7 +671,7 @@ private void HandleResponse(JsonElement root) // Skip this block if we already fired PairingStatusChanged above via gotNewToken. if (!gotNewToken) { - if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken)) { if (reconnectingAfterApproval) { @@ -664,7 +687,7 @@ private void HandleResponse(JsonElement root) _pairingBlocked = true; _logger.Info("Not yet paired - check 'openclaw devices list' for pending approval"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, $"Run: openclaw devices approve {ShortDeviceId}...")); @@ -676,7 +699,7 @@ private void HandleResponse(JsonElement root) _isPaired = true; _pairingApprovedAwaitingReconnect = false; _logger.Info("Already paired with stored device token"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Paired, _deviceIdentity.DeviceId)); } @@ -686,6 +709,22 @@ private void HandleResponse(JsonElement root) } } + /// + /// Bug 3: source-side suppression of duplicate PairingStatusChanged events from + /// HandleHelloOk on WS reconnects. Only fire when the status differs from the last + /// emitted status (or when nothing has been emitted yet). + /// + private void EmitPairingStatusOnTransition(PairingStatusEventArgs args) + { + if (_lastEmittedPairingStatus == args.Status) + { + _logger.Info($"[NODE] Suppressing duplicate pairing status event: {args.Status} for {args.DeviceId}"); + return; + } + _lastEmittedPairingStatus = args.Status; + PairingStatusChanged?.Invoke(this, args); + } + private void HandleRequestError(JsonElement root) { var error = "Unknown error"; @@ -733,7 +772,7 @@ private void HandleRequestError(JsonElement root) : $"Run: openclaw devices approve {ShortDeviceId}..."; _logger.Info($"[NODE] Pairing required for this device; reason={pairingReason ?? "unknown"}, requestId={pairingRequestId ?? "none"}"); _logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}"); - PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs( + EmitPairingStatusOnTransition(new PairingStatusEventArgs( PairingStatus.Pending, _deviceIdentity.DeviceId, detail)); @@ -747,12 +786,12 @@ private void HandleRequestError(JsonElement root) error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase)) { _rateLimited = true; - _logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {error}"); + _logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {TokenSanitizer.Sanitize(error)}"); RaiseStatusChanged(ConnectionStatus.Error); return; } - _logger.Error($"Node registration failed: {error} (code: {errorCode})"); + _logger.Error($"Node registration failed: {TokenSanitizer.Sanitize(error)} (code: {errorCode})"); RaiseStatusChanged(ConnectionStatus.Error); } @@ -800,6 +839,27 @@ private static bool TryGetString(JsonElement element, string propertyName, out s value = prop.GetString(); return !string.IsNullOrWhiteSpace(value); } + + private static string[]? TryGetAuthScopes(JsonElement authPayload) + { + if (!authPayload.TryGetProperty("scopes", out var scopes) || scopes.ValueKind != JsonValueKind.Array) + { + return null; + } + + var values = new List(); + foreach (var item in scopes.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var value = item.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + values.Add(value); + } + } + + return values.Count == 0 ? null : values.Distinct(StringComparer.Ordinal).ToArray(); + } private async Task HandleRequestAsync(JsonElement root) { @@ -951,16 +1011,8 @@ private async Task SendErrorResponseAsync(string requestId, string error) } /// - /// Send a generic node-event to the gateway. Mirrors the Android - /// GatewaySession.sendNodeEvent wire shape: a JSON-RPC request with - /// method node.event and params { event, payloadJSON }, - /// where payloadJSON is the inner payload as a *string*, not a - /// nested object. The gateway's node-event dispatcher - /// (server-node-events.ts) then re-parses it. - /// - /// Returns false when not connected so callers can surface a status to the - /// renderer (e.g. clear a button-loading spinner with an error). Throws on - /// argument problems but swallows transport-layer errors as false. + /// Sends a node.event request with JSON payload. + /// Returns false when not connected or when the transport send fails. /// public async Task SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload) { @@ -968,9 +1020,6 @@ public async Task SendNodeEventAsync(string eventName, System.Text.Json.No if (payload is null) throw new ArgumentNullException(nameof(payload)); if (!_isConnected) return false; - // payloadJSON is a STRING containing JSON, matching the Android wire - // shape and the gateway's parser at server-node-events.ts:380 which - // does JSON.parse(evt.payloadJSON). var msg = new { type = "req", diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index dfdf2748..fa7cafad 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -9,6 +9,7 @@ using OpenClawTray.Services; using OpenClawTray.Windows; using OpenClawTray.Onboarding; +using OpenClawTray.Services.LocalGatewaySetup; using System; using System.Collections.Frozen; using System.Collections.Generic; @@ -37,9 +38,17 @@ public partial class App : Application private TrayIcon? _trayIcon; private OpenClawGatewayClient? _gatewayClient; + /// + /// Cached reference to the most recently constructed local-setup engine. Used by + /// to suppress the "copy pairing command" toast + /// during Phase 14 auto-pair (Bug #2, manual test 2026-05-05). Null when no local + /// setup has run in this app lifetime. + /// + private LocalGatewaySetupEngine? _localSetupEngine; /// The persistent gateway client. Used by the onboarding wizard for RPC calls. public OpenClawGatewayClient? GatewayClient => _gatewayClient; + internal SettingsManager Settings => _settings ?? throw new InvalidOperationException("Settings are not initialized."); /// /// Ensures the managed SSH tunnel is started using the current settings. @@ -47,6 +56,28 @@ public partial class App : Application /// public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings); + /// + /// Creates the WSL local gateway setup engine using the current tray settings. + /// Onboarding pages (Phase 5) call this to drive the local-WSL setup flow; + /// the engine pairs the operator + Windows tray node into the gateway it + /// installs, so we eagerly materialize the NodeService when needed. + /// + public LocalGatewaySetupEngine CreateLocalGatewaySetupEngine( + bool replaceExistingConfigurationConfirmed = false) + { + var settings = _settings ?? new SettingsManager(); + var nodeService = EnsureNodeServiceForLocalGatewaySetup(settings); + var engine = LocalGatewaySetupEngineFactory.CreateLocalOnly( + settings, + new AppLogger(), + nodeService, + replaceExistingConfigurationConfirmed: replaceExistingConfigurationConfirmed); + // Bug #2: cache so OnPairingStatusChanged can read engine.IsAutoPairingWindowsNode + // and suppress the "copy pairing command" toast during the Phase 14 blip. + _localSetupEngine = engine; + return engine; + } + /// /// Returns the HWND of the active onboarding window, or IntPtr.Zero if none. /// Used by onboarding pages that need to host file pickers / dialogs. @@ -120,6 +151,14 @@ public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => private QuickSendDialog? _quickSendDialog; private ChatWindow? _chatWindow; private string? _authFailureMessage; + + // Bug 3: per-device idempotency for "Node paired" toast. WindowsNodeClient.HandleHelloOk + // re-fires PairingStatusChanged(Paired) on every WS reconnect; we only want one toast + // per device per session. (Source-side suppression also exists in WindowsNodeClient as + // defense-in-depth.) + private readonly HashSet _shownPairedToasts = new(StringComparer.Ordinal); + private readonly Dictionary _recentToastKeys = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan ToastDedupeWindow = TimeSpan.FromSeconds(30); // Node service (optional, enabled in settings) private NodeService? _nodeService; @@ -137,6 +176,14 @@ public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray"); + // Operator/node identity store (DeviceIdentity). Lives at %APPDATA%\OpenClawTray + // by convention so it follows the user across machines via roaming profile. + // OPENCLAW_TRAY_APPDATA_DIR isolates a test/E2E identity store the same way + // OPENCLAW_TRAY_DATA_DIR isolates the per-machine data directory. + private static readonly string IdentityDataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); private static readonly string CrashLogPath = Path.Combine(DataPath, "crash.log"); private static readonly string RunMarkerPath = Path.Combine(DataPath, "run.marker"); @@ -346,17 +393,31 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) _sshTunnelService = new SshTunnelService(new AppLogger()); _sshTunnelService.TunnelExited += OnSshTunnelExited; - // First-run check (also supports forced onboarding for testing) - if (RequiresSetup(_settings) || - Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1") - { - await ShowOnboardingAsync(); - } - - // Initialize tray icon (window-less pattern from WinUIEx) + // Initialize tray icon FIRST (window-less pattern from WinUIEx). + // The tray is application chrome and must always survive any failure + // in the onboarding wizard. OnLaunched is async void, so a synchronous + // throw inside the OnboardingWindow constructor would otherwise + // propagate through `await ShowOnboardingAsync()` and abort OnLaunched + // before the tray ever initializes. InitializeTrayIcon(); ShowSurfaceImprovementsTipIfNeeded(); + // First-run check (also supports forced onboarding for testing). + // Wrapped in try/catch so a wizard construction failure cannot tear + // down the tray; user can retry via the Setup Guide menu item. + try + { + if (RequiresSetup(_settings) || + Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1") + { + await ShowOnboardingAsync(); + } + } + catch (Exception ex) + { + Logger.Error($"Onboarding failed during launch (tray remains available): {ex}"); + } + // Initialize connections — always create operator client for UI data, // additionally create node service for gateway node mode or local MCP. InitializeGatewayClient(); @@ -366,9 +427,10 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) } // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) - if (_settings != null && !string.IsNullOrWhiteSpace(_settings.GetEffectiveGatewayUrl())) + if (_settings != null && + TryResolveChatCredentials(out var prewarmUrl, out var prewarmToken, out _)) { - _chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token); + _chatWindow = new ChatWindow(prewarmUrl, prewarmToken); // Window is created but hidden — WebView2 initializes in the background } @@ -436,14 +498,26 @@ private void OnTrayIconSelected(TrayIcon sender, TrayIconEventArgs e) ShowChatWindow(); } - private void ShowChatWindow() + internal void ShowChatWindow() { if (_settings == null) return; + if (!TryResolveChatCredentials(out var url, out var token, out var credentialSource)) + { + Logger.Warn("[ChatWindow] Gateway URL or credential not configured; cannot open quick chat"); + return; + } + + Logger.Info($"[ChatWindow] Quick-chat credentials resolved from {credentialSource}"); if (_chatWindow == null) { - _chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token); + _chatWindow = new ChatWindow(url, token); } + // Bug 2: cached ChatWindow may have been pre-warmed with empty/stale credentials + // (built before pairing completed). Refresh on every tray click so quick-chat + // follows the same resolver path as the companion-app operator client. + _chatWindow.RefreshCredentials(url, token); + // Toggle: if visible, hide; if hidden, show near tray if (_chatWindow.Visible) { @@ -451,8 +525,29 @@ private void ShowChatWindow() } else { - _chatWindow.ShowNearTrayAnimated(); + // Bug 1: When called from the wizard's close handler, OnboardingWindow.Close() + // steals focus on the same UI tick, deactivating ChatWindow → its + // OnWindowActivated auto-hides it immediately. Defer the show to a later + // dispatcher tick (Low priority) so the close + focus-loss cascade settles + // before we make the chat window visible. + var window = _chatWindow; + var dispatcher = _dispatcherQueue; + if (dispatcher != null) + { + dispatcher.TryEnqueue( + Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, + () => + { + try { window.ShowNearTrayAnimated(); } + catch (Exception ex) { Logger.Warn($"ShowChatWindow deferred show failed: {ex.Message}"); } + }); + } + else + { + window.ShowNearTrayAnimated(); + } } + } private VoiceOverlayWindow? _voiceOverlayWindow; @@ -1082,6 +1177,16 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) menu.AddMenuItem("Companion", "🦞", "companion"); menu.AddMenuItem(LocalizationHelper.GetString("Menu_QuickSend"), "📤", "quicksend"); + // Setup Guide / Reconfigure entry (PR #274 must-fix #6) — label flips + // based on whether prior config exists. Click dispatches "setup" which + // invokes the existing ShowOnboardingAsync handler (case in OnTrayMenuAction). + var setupMenuLabel = _settings != null + && new OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard(_settings, IdentityDataPath) + .HasExistingConfiguration() + ? LocalizationHelper.GetString("Menu_Reconfigure") + : LocalizationHelper.GetString("Menu_SetupGuide"); + menu.AddMenuItem(setupMenuLabel, "🧭", "setup"); + // ── Exit ── menu.AddSeparator(); menu.AddMenuItem(LocalizationHelper.GetString("Menu_Exit"), "❌", "exit"); @@ -1542,22 +1647,30 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) return; } - // Need either a regular token or a bootstrap token to connect - var effectiveToken = _settings.Token; - if (string.IsNullOrWhiteSpace(effectiveToken)) - { - if (useBootstrapHandoffAuth && !string.IsNullOrWhiteSpace(_settings.BootstrapToken)) - { - // Bootstrap-only flow (setup code / QR): use bootstrap token for initial pairing - effectiveToken = _settings.BootstrapToken; - } - else - { - Logger.Info("Gateway token not configured — skipping operator client initialization"); - return; - } + // Bug #4 (Wizard hung at "Authenticating"): broaden credential resolution + // beyond settings.Token so a paired operator whose only credential is + // BootstrapToken or a stored DeviceIdentity DeviceToken still gets a + // client. Mirrors the prototype's resolver shape (openclaw-windows-node + // App.xaml.cs:1244-1298). Logic lives in GatewayCredentialResolver so + // Tray tests can cover all branches without booting WinUI. + var identityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); + var credential = OpenClawTray.Services.GatewayCredentialResolver.Resolve( + _settings.Token, + _settings.BootstrapToken, + identityPath, + msg => Logger.Warn(msg)); + if (credential == null) + { + Logger.Info("Gateway token not configured — skipping operator client initialization"); + return; } + // Caller's useBootstrapHandoffAuth hint is preserved as an OR so existing + // call sites that put a bootstrap value into settings.Token + pass true + // continue to send auth.bootstrapToken (OpenClawGatewayClient.cs:556-565). + var tokenIsBootstrapToken = credential.IsBootstrapToken || useBootstrapHandoffAuth; + Logger.Info($"Gateway credential resolved from {credential.Source} (bootstrap={tokenIsBootstrapToken})"); + // Unsubscribe from old client if exists UnsubscribeGatewayEvents(); _gatewayClient?.Dispose(); @@ -1565,9 +1678,9 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) _gatewayClient = new OpenClawGatewayClient( gatewayUrl, - effectiveToken, + credential.Token, new AppLogger(), - useBootstrapHandoffAuth); + tokenIsBootstrapToken); _gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null); _gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories); _gatewayClient.StatusChanged += OnConnectionStatusChanged; @@ -1642,7 +1755,7 @@ private void InitializeNodeService() if (!enableNode && !enableMcp) return; // Gateway connection requires auth (operator token, bootstrap token, or stored device token); MCP doesn't. - var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, DataPath); + var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, IdentityDataPath); if (enableNode && !canRunGateway && !enableMcp) { @@ -1665,7 +1778,8 @@ private void InitializeNodeService() DataPath, () => _keepAliveWindow?.Content as FrameworkElement, _settings, - enableMcpServer: enableMcp); + enableMcpServer: enableMcp, + identityDataPath: IdentityDataPath); _nodeService.StatusChanged += OnNodeStatusChanged; _nodeService.NotificationRequested += OnNodeNotificationRequested; _nodeService.PairingStatusChanged += OnPairingStatusChanged; @@ -1694,6 +1808,40 @@ private void InitializeNodeService() } } + private NodeService? EnsureNodeServiceForLocalGatewaySetup(SettingsManager settings) + { + if (_nodeService != null) + return _nodeService; + + if (_dispatcherQueue == null) + return null; + + try + { + _nodeService = new NodeService( + new AppLogger(), + _dispatcherQueue, + DataPath, + () => _keepAliveWindow?.Content as FrameworkElement, + settings, + enableMcpServer: settings.EnableMcpServer, + identityDataPath: IdentityDataPath); + _nodeService.StatusChanged += OnNodeStatusChanged; + _nodeService.NotificationRequested += OnNodeNotificationRequested; + _nodeService.PairingStatusChanged += OnPairingStatusChanged; + _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; + _nodeService.InvokeCompleted += OnNodeInvokeCompleted; + _nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated; + return _nodeService; + } + catch (Exception ex) + { + Logger.Error($"Failed to initialize node service for local gateway setup: {ex}"); + _nodeService = null; + return null; + } + } + private void WireAppCapabilityHandlers() { var app = _nodeService?.AppCapability; @@ -1831,7 +1979,7 @@ private void WireAppCapabilityHandlers() private static bool RequiresSetup(SettingsManager settings) { - return StartupSetupState.RequiresSetup(settings, DataPath); + return StartupSetupState.RequiresSetup(settings, IdentityDataPath); } private bool ShouldInitializeNodeService() @@ -1857,13 +2005,23 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) SyncHubNodeState(); // Don't show "connected" toast if waiting for pairing - we'll show pairing status instead - if (status == ConnectionStatus.Connected && _nodeService?.IsPaired == true) + var nodeService = _nodeService; + if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true) { + var deviceId = nodeService.FullDeviceId; + if (HasRecentToast("node-paired", deviceId)) + { + Logger.Info($"[ToastDeduper] Suppressed node-connected toast after node-paired deviceId={deviceId}"); + return; + } + try { ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_NodeModeActive")) - .AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail"))); + .AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail")), + "node-connected", + deviceId); } catch { /* ignore */ } } @@ -1901,21 +2059,48 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu { if (args.Status == OpenClaw.Shared.PairingStatus.Pending) { + // Bug #2 (manual test 2026-05-05): suppress the "copy pairing command" + // toast while the local-setup engine is mid-Phase-14 node-role PairAsync. + // The loopback gateway parks the role-upgrade as Pending for ~100ms before + // SettingsWindowsTrayNodeProvisioner's pending-approver auto-approves it; + // the user never needs to copy the command in that window. Manual + // ConnectionPage pairings call ShowPairingPendingNotification directly + // (bypassing this event handler), so the suppression scope is exactly + // the autopair window. + if (LocalGatewaySetupEngine.ShouldSuppressPairingPendingNotification(_localSetupEngine, args.Status)) + { + Logger.Info($"Suppressing pairing-pending toast: autopair Phase 14 in progress for {args.DeviceId}"); + return; + } ShowPairingPendingNotification(args.DeviceId); } else if (args.Status == OpenClaw.Shared.PairingStatus.Paired) { - AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); - ShowToast(new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_NodePaired")) - .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail"))); + // Bug 3: idempotency guard — only show "Node paired" toast/activity once + // per device per session. WS reconnects re-fire Paired; suppress duplicates. + var deviceKey = args.DeviceId ?? string.Empty; + if (_shownPairedToasts.Add(deviceKey)) + { + AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); + ShowToast(new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_NodePaired")) + .AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")), + "node-paired", + args.DeviceId); + } + else + { + Logger.Info($"Suppressing duplicate Paired toast for device {deviceKey}"); + } } else if (args.Status == OpenClaw.Shared.PairingStatus.Rejected) { AddRecentActivity("Node pairing rejected", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId, details: args.Message ?? LocalizationHelper.GetString("Toast_PairingRejectedDetail")); ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_PairingRejected")) - .AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail"))); + .AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail")), + "node-pairing-rejected", + args.DeviceId); } } catch { /* ignore */ } @@ -1961,7 +2146,9 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm .AddButton(new ToastButton() .SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand")) .AddArgument("action", "copy_pairing_command") - .AddArgument("command", command))); + .AddArgument("command", command)), + "node-pairing-pending", + deviceId); } private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args) @@ -2619,7 +2806,7 @@ private string BuildTrayTooltip() #region Window Management - private void ShowHub(string? navigateTo = null, bool activate = true) + internal void ShowHub(string? navigateTo = null, bool activate = true) { if (_hubWindow == null || _hubWindow.IsClosed) { @@ -2890,7 +3077,9 @@ private void ShowQuickSend(string? prefillMessage = null) } Logger.Info("Showing QuickSend dialog"); - var dialog = new QuickSendDialog(_gatewayClient, prefillMessage); + // Bug #3: pass a Func that resolves the live _gatewayClient on + // every Send so post-pair / restart / reinit swaps are observed. + var dialog = new QuickSendDialog(() => _gatewayClient, prefillMessage); dialog.Closed += (s, e) => { if (ReferenceEquals(_quickSendDialog, dialog)) @@ -3426,7 +3615,7 @@ private async Task ShowOnboardingAsync() try { _onboardingWindow.Activate(); return; } catch { _onboardingWindow = null; } } - _onboardingWindow = new OnboardingWindow(_settings); + _onboardingWindow = new OnboardingWindow(_settings, IdentityDataPath); _onboardingWindow.OnboardingCompleted += (s, e) => { Logger.Info("Onboarding completed"); @@ -3492,8 +3681,11 @@ private void ShowSurfaceImprovementsTipIfNeeded() #endregion - private void ShowToast(ToastContentBuilder builder) + private void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null) { + if (!ShouldShowToast(toastTag, deviceId)) + return; + var sound = _settings?.NotificationSound; if (string.Equals(sound, "None", StringComparison.OrdinalIgnoreCase)) { @@ -3506,6 +3698,78 @@ private void ShowToast(ToastContentBuilder builder) builder.Show(); } + private bool ShouldShowToast(string? toastTag, string? deviceId) + { + if (string.IsNullOrWhiteSpace(toastTag)) + return true; + + var normalizedDeviceId = NormalizeToastDeviceId(deviceId); + var dedupeKey = BuildToastKey(toastTag, normalizedDeviceId); + var now = DateTime.UtcNow; + + foreach (var staleKey in _recentToastKeys + .Where(pair => now - pair.Value >= ToastDedupeWindow) + .Select(pair => pair.Key) + .ToArray()) + { + _recentToastKeys.Remove(staleKey); + } + + if (_recentToastKeys.TryGetValue(dedupeKey, out var lastShown) && + now - lastShown < ToastDedupeWindow) + { + Logger.Info($"[ToastDeduper] Suppressed duplicate toast tag={toastTag} deviceId={normalizedDeviceId}"); + return false; + } + + _recentToastKeys[dedupeKey] = now; + Logger.Info($"[ToastDeduper] Showing toast tag={toastTag} deviceId={normalizedDeviceId}"); + return true; + } + + private bool HasRecentToast(string toastTag, string? deviceId) + { + var normalizedDeviceId = NormalizeToastDeviceId(deviceId); + return _recentToastKeys.TryGetValue(BuildToastKey(toastTag, normalizedDeviceId), out var lastShown) && + DateTime.UtcNow - lastShown < ToastDedupeWindow; + } + + private static string NormalizeToastDeviceId(string? deviceId) => + string.IsNullOrWhiteSpace(deviceId) ? "global" : deviceId.Trim(); + + private static string BuildToastKey(string toastTag, string normalizedDeviceId) => + $"{toastTag.Trim()}:{normalizedDeviceId}"; + + private bool TryResolveChatCredentials( + out string gatewayUrl, + out string token, + out string credentialSource) + { + gatewayUrl = string.Empty; + token = string.Empty; + credentialSource = "none"; + + if (_settings == null) + return false; + + gatewayUrl = _settings.GetEffectiveGatewayUrl(); + if (string.IsNullOrWhiteSpace(gatewayUrl)) + return false; + + var identityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); + var credential = OpenClawTray.Services.GatewayCredentialResolver.Resolve( + _settings.Token, + _settings.BootstrapToken, + identityPath, + msg => Logger.Warn(msg)); + if (credential == null) + return false; + + token = credential.Token; + credentialSource = credential.Source; + return true; + } + #region Actions private void OpenDashboard(string? path = null) diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs new file mode 100644 index 00000000..4b2e206f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs @@ -0,0 +1,259 @@ +using OpenClaw.Shared; +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Dialogs; + +// Bug #3 (manual test 2026-05-05): QuickSendDialog used to capture the App's +// gateway client at constructor time into a readonly field. After autopair (or +// any other path that swapped App._gatewayClient — SSH tunnel restart, manual +// ConnectionPage re-pair, onboarding completion), the dialog kept sending into +// the stale instance which still reported NOT_PAIRED, triggering the +// "copy pair command to clipboard" remediation toast against a perfectly +// paired live client. +// +// This file extracts the per-Send logic into a pure, UI-free coordinator that: +// 1. Resolves the live gateway client from a Func<> provider on every Send. +// 2. Defines explicit behavior for null / disposed / swap-window cases. +// 3. Returns a discriminated outcome the dialog renders. +// +// RubberDucky closure conditions #1 (scope), #2 (lifetime contract) and #3 +// (genuine-unpaired regression test) are all satisfied by tests over this +// coordinator (see tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs). + +/// +/// Minimal gateway surface QuickSend needs. Wrapping the real +/// behind this interface keeps +/// testable without spinning up a real +/// WebSocket client. +/// +public interface IQuickSendGateway +{ + bool IsConnectedToGateway { get; } + Task ConnectAsync(); + Task SendChatMessageAsync(string message); + string BuildPairingApprovalFixCommands(); + string BuildMissingScopeFixCommands(string missingScope); +} + +/// +/// Adapter that exposes the live through +/// for the production wiring. +/// +public sealed class OpenClawGatewayClientAdapter : IQuickSendGateway +{ + private readonly OpenClawGatewayClient _client; + + public OpenClawGatewayClientAdapter(OpenClawGatewayClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public bool IsConnectedToGateway => _client.IsConnectedToGateway; + public Task ConnectAsync() => _client.ConnectAsync(); + public Task SendChatMessageAsync(string message) => _client.SendChatMessageAsync(message); + public string BuildPairingApprovalFixCommands() => _client.BuildPairingApprovalFixCommands(); + public string BuildMissingScopeFixCommands(string missingScope) => _client.BuildMissingScopeFixCommands(missingScope); +} + +/// +/// Discriminated outcome of a single Send attempt. The dialog renders the +/// outcome; the coordinator never touches UI. +/// +public abstract record QuickSendOutcome +{ + /// Message accepted by the gateway. + public sealed record Sent : QuickSendOutcome; + + /// + /// Gateway client provider returned null (or a previously-disposed + /// instance was detected) — the App is mid-swap (init, restart, autopair + /// reinit). DO NOT show the clipboard-pairing remediation; show a + /// "still initializing" message and let the user retry. + /// + public sealed record GatewayInitializing(string Message) : QuickSendOutcome; + + /// + /// Live current client genuinely reports NOT_PAIRED. Clipboard remediation + /// MUST still fire — this is the path Mike explicitly does not want + /// suppressed. + /// + public sealed record PairingRequired(string Commands) : QuickSendOutcome; + + /// Live current client is missing a required operator scope. + public sealed record MissingScope(string Scope, string Commands) : QuickSendOutcome; + + /// Any other failure (timeout, transport, dispose race, etc.). + public sealed record Failed(string ErrorMessage) : QuickSendOutcome; +} + +/// +/// Pure (no UI, no static state) per-Send orchestrator. The dialog passes a +/// that reads App._gatewayClient on every Send +/// so a swap underneath the dialog is observed before remediation decisions +/// are made. +/// +public sealed class QuickSendCoordinator +{ + /// + /// Provider/lifetime contract — see Bug #3 plan §3 and RubberDucky + /// closure condition #2: + /// + /// (a) Provider returns null => GatewayInitializing (no clipboard toast). + /// Reason: App is between Dispose() and the next assignment of + /// _gatewayClient (SSH tunnel restart, onboarding swap), or the field + /// has not yet been initialized. + /// (b) Provider returns a previously-disposed instance => SendChatMessageAsync + /// throws "Gateway connection is not open" or ObjectDisposedException; + /// coordinator catches and returns Failed (NOT clipboard). + /// (c) Provider returns a live client that genuinely reports NOT_PAIRED => + /// PairingRequired (clipboard toast STILL fires — built from the + /// resolved current client, never a captured stale one). + /// + private readonly Func _provider; + private readonly int _connectTimeoutMs; + private readonly int _providerRetryDelayMs; + private readonly Func _delayAsync; + + public QuickSendCoordinator( + Func provider, + int connectTimeoutMs = 3000, + int providerRetryDelayMs = 100, + Func? delayAsync = null) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _connectTimeoutMs = connectTimeoutMs; + _providerRetryDelayMs = providerRetryDelayMs; + _delayAsync = delayAsync ?? Task.Delay; + } + + public async Task SendAsync(string message, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(message)) + { + return new QuickSendOutcome.Failed("Message is empty."); + } + + // Resolve live client. If the App is mid-swap (e.g., between Dispose + // and the next InitializeGatewayClient assignment), the provider + // returns null briefly. Retry once after a short delay to absorb the + // window without surfacing a spurious "initializing" message. + var client = ResolveClient(); + if (client == null) + { + await _delayAsync(_providerRetryDelayMs).ConfigureAwait(false); + client = ResolveClient(); + } + + if (client == null) + { + return new QuickSendOutcome.GatewayInitializing( + "Gateway is still initializing. Please try again in a moment."); + } + + try + { + if (!await EnsureConnectedAsync(client, cancellationToken).ConfigureAwait(false)) + { + return new QuickSendOutcome.Failed("Gateway connection is not open"); + } + + await client.SendChatMessageAsync(message).ConfigureAwait(false); + return new QuickSendOutcome.Sent(); + } + catch (Exception ex) + { + return ClassifyFailure(client, ex); + } + } + + private IQuickSendGateway? ResolveClient() + { + try + { + return _provider(); + } + catch + { + // Provider is `() => _gatewayClient` — the field read itself + // can't throw, but defensive belt-and-braces against future + // provider implementations. + return null; + } + } + + private async Task EnsureConnectedAsync(IQuickSendGateway client, CancellationToken cancellationToken) + { + if (client.IsConnectedToGateway) return true; + + try + { + await client.ConnectAsync().ConfigureAwait(false); + } + catch + { + // Connect errors surface via the subsequent send. + } + + var deadline = Environment.TickCount64 + _connectTimeoutMs; + while (Environment.TickCount64 < deadline) + { + if (cancellationToken.IsCancellationRequested) return false; + if (client.IsConnectedToGateway) return true; + await _delayAsync(120).ConfigureAwait(false); + } + + return client.IsConnectedToGateway; + } + + private static QuickSendOutcome ClassifyFailure(IQuickSendGateway client, Exception ex) + { + // ObjectDisposedException happens when the resolved client was + // disposed mid-send (case (b) of the lifetime contract). Surface as + // a clean Failed — never as the clipboard pairing remediation. + if (ex is ObjectDisposedException) + { + return new QuickSendOutcome.Failed( + "Gateway client was reset mid-send. Please try again."); + } + + var msg = ex.Message; + if (IsPairingRequired(msg)) + { + // Built from the live current client (resolved in this call), not + // any captured stale snapshot — closes Bug #3 root cause. + var commands = client.BuildPairingApprovalFixCommands(); + return new QuickSendOutcome.PairingRequired(commands); + } + + if (TryExtractMissingScope(msg, out var scope)) + { + var commands = client.BuildMissingScopeFixCommands(scope); + return new QuickSendOutcome.MissingScope(scope, commands); + } + + return new QuickSendOutcome.Failed(msg); + } + + internal static bool IsPairingRequired(string? message) + { + if (string.IsNullOrWhiteSpace(message)) return false; + return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase) + || message.Contains("not paired", StringComparison.OrdinalIgnoreCase) + || message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase); + } + + internal static bool TryExtractMissingScope(string? message, out string scope) + { + scope = string.Empty; + if (string.IsNullOrWhiteSpace(message)) return false; + + var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase); + if (!match.Success) return false; + + scope = match.Groups[1].Value; + return !string.IsNullOrWhiteSpace(scope); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs index 33d02c8c..d4117c89 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs @@ -9,7 +9,6 @@ using System; using System.Runtime.InteropServices; using System.Threading.Tasks; -using System.Text.RegularExpressions; using WinUIEx; namespace OpenClawTray.Dialogs; @@ -19,7 +18,12 @@ namespace OpenClawTray.Dialogs; /// public sealed class QuickSendDialog : WindowEx { - private readonly OpenClawGatewayClient _client; + // Bug #3 (manual test 2026-05-05): resolve the live App._gatewayClient + // on every Send via this provider instead of capturing a single instance + // at construction time. This survives autopair / SSH-tunnel-restart / + // manual-pair / onboarding-completion swaps under the dialog. + private readonly Func _clientProvider; + private readonly QuickSendCoordinator _coordinator; private readonly TextBox _messageTextBox; private readonly TextBox _errorDetailsTextBox; private readonly Button _sendButton; @@ -52,9 +56,15 @@ private static extern bool SetWindowPos( private const uint SWP_NOSIZE = 0x0001; private const uint SWP_SHOWWINDOW = 0x0040; - public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = null) + public QuickSendDialog(Func clientProvider, string? prefillMessage = null) { - _client = client; + _clientProvider = clientProvider ?? throw new ArgumentNullException(nameof(clientProvider)); + _coordinator = new QuickSendCoordinator(() => + { + var live = _clientProvider(); + return live == null ? null : new OpenClawGatewayClientAdapter(live); + }); + // Window setup Title = LocalizationHelper.GetString("WindowTitle_QuickSend"); @@ -244,57 +254,70 @@ private async Task SendMessageAsync() _messageTextBox.IsEnabled = false; ShowDetails(LocalizationHelper.GetString("QuickSend_Sending")); + QuickSendOutcome outcome; try { - if (!await EnsureGatewayConnectedAsync()) - { - throw new InvalidOperationException("Gateway connection is not open"); - } - - await _client.SendChatMessageAsync(message); - Logger.Info($"[QuickSend] Message sent ({message.Length} chars)"); - new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("QuickSend_ToastTitle")) - .AddText(LocalizationHelper.GetString("QuickSend_ToastBody")) - .Show(); - Close(); + outcome = await _coordinator.SendAsync(message); } catch (Exception ex) { - Logger.Error($"Quick send failed: {ex.Message}"); - if (IsPairingRequired(ex.Message)) - { - var commands = _client.BuildPairingApprovalFixCommands(); - CopyTextToClipboard(commands); + // Coordinator catches/classifies all expected failures; this is + // a defensive guard against unexpected programmer errors. + Logger.Error($"Quick send coordinator threw: {ex.Message}"); + outcome = new QuickSendOutcome.Failed(ex.Message); + } + + switch (outcome) + { + case QuickSendOutcome.Sent: + Logger.Info($"[QuickSend] Message sent ({message.Length} chars)"); + new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("QuickSend_ToastTitle")) + .AddText(LocalizationHelper.GetString("QuickSend_ToastBody")) + .Show(); + Close(); + return; - ShowErrorDetails($"Pairing approval required\n\n{commands}"); + case QuickSendOutcome.GatewayInitializing init: + // Bug #3: provider returned null (App is mid-swap). Do NOT + // copy any pair-command remediation to clipboard — show a + // simple "try again" message instead. + Logger.Warn($"[QuickSend] {init.Message}"); + ShowErrorDetails(init.Message); + break; + + case QuickSendOutcome.PairingRequired pr: + // Genuine NOT_PAIRED on the live current client — clipboard + // remediation MUST still fire (Mike explicitly does not want + // this case suppressed; RubberDucky closure condition #3). + CopyTextToClipboard(pr.Commands); + ShowErrorDetails($"Pairing approval required\n\n{pr.Commands}"); new ToastContentBuilder() .AddText("Quick Send device approval required") .AddText("Gateway reported pairing required. Approval guidance copied to clipboard.") .Show(); - Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{commands}"); - } - else if (TryExtractMissingScope(ex.Message, out var missingScope)) - { - var commands = _client.BuildMissingScopeFixCommands(missingScope); - CopyTextToClipboard(commands); + Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{pr.Commands}"); + break; - ShowErrorDetails($"Missing scope: {missingScope}\n\n{commands}"); + case QuickSendOutcome.MissingScope ms: + CopyTextToClipboard(ms.Commands); + ShowErrorDetails($"Missing scope: {ms.Scope}\n\n{ms.Commands}"); new ToastContentBuilder() .AddText("Quick Send permission required") - .AddText($"Missing scope '{missingScope}'. Identity + remediation guidance copied to clipboard.") + .AddText($"Missing scope '{ms.Scope}'. Identity + remediation guidance copied to clipboard.") .Show(); - Logger.Warn($"[QuickSend] Missing scope '{missingScope}'. Commands copied to clipboard.\n{commands}"); - } - else - { - ShowErrorDetails(ex.Message); - } + Logger.Warn($"[QuickSend] Missing scope '{ms.Scope}'. Commands copied to clipboard.\n{ms.Commands}"); + break; - _sendButton.IsEnabled = true; - _messageTextBox.IsEnabled = true; - _isSending = false; + case QuickSendOutcome.Failed f: + Logger.Error($"Quick send failed: {f.ErrorMessage}"); + ShowErrorDetails(f.ErrorMessage); + break; } + + _sendButton.IsEnabled = true; + _messageTextBox.IsEnabled = true; + _isSending = false; } private void ShowErrorDetails(string details) @@ -318,36 +341,6 @@ private void ShowDetails(string details) this.SetWindowSize(500, 320 + TitleBarHeight); } - private static bool TryExtractMissingScope(string? message, out string scope) - { - scope = string.Empty; - if (string.IsNullOrWhiteSpace(message)) - { - return false; - } - - var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase); - if (!match.Success) - { - return false; - } - - scope = match.Groups[1].Value; - return !string.IsNullOrWhiteSpace(scope); - } - - private static bool IsPairingRequired(string? message) - { - if (string.IsNullOrWhiteSpace(message)) - { - return false; - } - - return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase) - || message.Contains("not paired", StringComparison.OrdinalIgnoreCase) - || message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase); - } - private static void CopyTextToClipboard(string text) { var data = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); @@ -394,36 +387,6 @@ private async Task RetryFocusMessageInputAsync() } } - private async Task EnsureGatewayConnectedAsync(int timeoutMs = 3000) - { - if (_client.IsConnectedToGateway) - { - return true; - } - - try - { - await _client.ConnectAsync(); - } - catch - { - // Connect errors are handled by the send flow. - } - - var started = Environment.TickCount64; - while (Environment.TickCount64 - started < timeoutMs) - { - if (_client.IsConnectedToGateway) - { - return true; - } - - await Task.Delay(120); - } - - return _client.IsConnectedToGateway; - } - public void FocusMessageInput() { _messageTextBox.Focus(FocusState.Programmatic); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs index 131643d8..fc2909ed 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs @@ -4,6 +4,7 @@ using OpenClawTray.Onboarding.Services; using OpenClawTray.Onboarding.Pages; using OpenClawTray.Onboarding.Widgets; +using OpenClawTray.Services; using static OpenClawTray.FunctionalUI.Factories; using Microsoft.UI.Xaml; @@ -20,14 +21,14 @@ public sealed class OnboardingApp : Component public override Element Render() { // Seed navigation + page index from Props.CurrentRoute (used by visual tests via - // OPENCLAW_ONBOARDING_START_ROUTE; defaults to Welcome on normal launches). + // OPENCLAW_ONBOARDING_START_ROUTE; defaults to SetupWarning on normal launches). var pagesInit = Props.GetPageOrder(); var initialIdx = Math.Max(0, Array.IndexOf(pagesInit, Props.CurrentRoute)); var nav = UseNavigation(pagesInit[initialIdx]); var (pageIndex, setPageIndex) = UseState(initialIdx); var pages = Props.GetPageOrder(); - // Clamp pageIndex if page order changed (e.g., node mode toggled) + // Clamp pageIndex if page order changed (e.g., node mode toggled, SetupPath changed). if (pageIndex >= pages.Length) { setPageIndex(pages.Length - 1); @@ -35,12 +36,19 @@ public override Element Render() void GoNext() { - if (pageIndex < pages.Length - 1) + // Re-derive pages on each call so SetupPath changes (Local vs Advanced) take effect. + var current = Props.GetPageOrder(); + if (pageIndex < current.Length - 1) { + Logger.Info($"[OnboardingApp] Advancing pageIndex {pageIndex}\u2192{pageIndex + 1}, next route={current[pageIndex + 1]}"); setPageIndex(pageIndex + 1); - nav.Navigate(pages[pageIndex + 1]); + nav.Navigate(current[pageIndex + 1]); Props.NotifyPageChanged(); - Props.NotifyRouteChanged(pages[pageIndex + 1]); + Props.NotifyRouteChanged(current[pageIndex + 1]); + } + else + { + Logger.Info($"[OnboardingApp] AdvanceRequested no-op: at last page (pageIndex={pageIndex}, total={current.Length})"); } } @@ -55,7 +63,70 @@ void GoBack() } } + // Subscribe to programmatic advance requests (SetupWarningPage buttons, + // LocalSetupProgressPage auto-advance after success). + UseEffect(() => + { + EventHandler handler = (_, _) => + { + var current = Props.GetPageOrder(); + Logger.Info($"[OnboardingApp] AdvanceRequested handler entered; current Props.CurrentRoute={Props.CurrentRoute}, computed pageIndex={pageIndex}, total pages={current.Length}"); + GoNext(); + }; + Props.AdvanceRequested += handler; + return () => Props.AdvanceRequested -= handler; + }, pageIndex); + + // Re-render when a page pushes a new nav-bar Next button state + // (LocalSetupProgressPage uses this to map engine status → button). + var (navBarTick, setNavBarTick) = UseState(0); + UseEffect(() => + { + EventHandler handler = (_, _) => setNavBarTick(navBarTick + 1); + Props.NavBarStateChanged += handler; + return () => Props.NavBarStateChanged -= handler; + }, navBarTick); + var isLastPage = pageIndex >= pages.Length - 1; + var currentRoute = pages[pageIndex]; + // Compute Next button visibility/disabled per page contract. + // - SetupWarning: visible, disabled until SetupPath chosen (legacy). + // - LocalSetupProgress: defer to Props.NextButtonState (set by the page in + // response to engine state changes; see Phase 5 Next/Back-button policy). + // - All other routes: visible, enabled (legacy default). + bool nextHidden = false; + bool nextDisabled; + if (currentRoute == OnboardingRoute.SetupWarning) + { + nextDisabled = Props.SetupPath == null; + } + else if (currentRoute == OnboardingRoute.LocalSetupProgress) + { + switch (Props.NextButtonState) + { + case OnboardingNextButtonState.Hidden: + nextHidden = true; + nextDisabled = true; + break; + case OnboardingNextButtonState.VisibleDisabled: + nextDisabled = true; + break; + case OnboardingNextButtonState.VisibleEnabled: + nextDisabled = false; + break; + case OnboardingNextButtonState.Default: + default: + // Conservative default before the page has pushed a state: + // visible+disabled (treat as Running/Idle equivalent — never + // let the user advance past a not-yet-complete local setup). + nextDisabled = true; + break; + } + } + else + { + nextDisabled = false; + } // VStack for functional UI content (icon + pages only). // The nav bar is rendered natively in OnboardingWindow for reliable bottom pinning. @@ -67,7 +138,8 @@ void GoBack() // Page content — fixed height prevents nav bar from jumping between pages (NavigationHost(nav, route => route switch { - OnboardingRoute.Welcome => Component(), + OnboardingRoute.SetupWarning => Component(Props), + OnboardingRoute.LocalSetupProgress => Component(Props), OnboardingRoute.Connection => Component(Props), OnboardingRoute.Ready => Component(Props), OnboardingRoute.Wizard => Component(Props), @@ -94,9 +166,11 @@ void GoBack() : Helpers.LocalizationHelper.GetString("Onboarding_Next"), isLastPage ? Props.Complete : GoNext) .Width(100) + .Disabled(nextDisabled) .Set(b => { Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingNext"); + b.Visibility = nextHidden ? Visibility.Collapsed : Visibility.Visible; b.Resources["ButtonBackground"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( Microsoft.UI.ColorHelper.FromArgb(255, 211, 47, 47)); // #D32F2F b.Resources["ButtonBackgroundPointerOver"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 9252942f..7ce32492 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -32,6 +32,7 @@ public sealed class OnboardingWindow : WindowEx private readonly FunctionalHostControl _host; private readonly string? _visualTestDir; private readonly DispatcherQueue _dispatcherQueue; + private readonly string? _identityDataPath; private int _captureIndex; // WebView2 overlay for Chat page @@ -44,16 +45,30 @@ public sealed class OnboardingWindow : WindowEx private bool _chatWebViewInitialized; private readonly OnboardingState _state; private bool _stateDisposed; + // Single-fire guard so the X button (Closed) and the Finish button (state.Complete → + // OnOnboardingFinished → Close → Closed) don't both dispatch completion. Both paths + // route through OnWizardComplete which no-ops after the first call. + private bool _completionDispatched; - public OnboardingWindow(SettingsManager settings) + public OnboardingWindow(SettingsManager settings, string? identityDataPath = null) { _settings = settings; + _identityDataPath = identityDataPath; _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _visualTestDir = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") == "1" ? ValidateTestDir(Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR") ?? Path.Combine(Path.GetTempPath(), "openclaw-visual-test")) : null; + // Optional override for visual tests: render the onboarding UI in a specific locale + // (e.g. "fr-FR", "zh-CN") regardless of system language. Must be set BEFORE the first + // LocalizationHelper.GetString call so the resource context picks it up. + var testLocale = Environment.GetEnvironmentVariable("OPENCLAW_TEST_LOCALE"); + if (!string.IsNullOrWhiteSpace(testLocale)) + { + LocalizationHelper.SetLanguageOverride(testLocale); + } + Title = LocalizationHelper.GetString("Onboarding_Title"); ExtendsContentIntoTitleBar = true; this.SetWindowSize(720, 900); @@ -71,14 +86,36 @@ public OnboardingWindow(SettingsManager settings) _state.Finished += OnOnboardingFinished; _state.RouteChanged += OnRouteChanged; + // Construct the existing-config guard and apply returning-user defaults. + // When existing config is detected, default SetupPath to Advanced so the + // user lands on the SetupWarning page with Next enabled (→ Connection page) + // rather than the local setup path. The warn-and-confirm gate on + // SetupWarningPage protects the "Set up locally" button. + if (identityDataPath != null) + { + _state.ExistingConfigGuard = new OnboardingExistingConfigGuard(settings, identityDataPath); + if (_state.ExistingConfigGuard.HasExistingConfiguration()) + _state.SetupPath = SetupPath.Advanced; + } + // Optional override for visual tests / engineering: jump straight to a route. // Accepts the OnboardingRoute enum name (e.g., "Connection"). var startRoute = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_ROUTE"); if (!string.IsNullOrWhiteSpace(startRoute) && Enum.TryParse(startRoute, ignoreCase: true, out var parsed)) { + // Ensure SetupPath is consistent with the requested route so GetPageOrder + // produces the expected step indicator. Defaults can be overridden below. + if (parsed == OnboardingRoute.LocalSetupProgress) _state.SetupPath = SetupPath.Local; + else if (parsed == OnboardingRoute.Connection) _state.SetupPath = SetupPath.Advanced; _state.CurrentRoute = parsed; } + var startSetupPath = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_SETUP_PATH"); + if (!string.IsNullOrWhiteSpace(startSetupPath) && + Enum.TryParse(startSetupPath, ignoreCase: true, out var parsedPath)) + { + _state.SetupPath = parsedPath; + } // Optional override for visual tests: pre-select a connection mode (Local/Wsl/Remote/Ssh/Later). var startMode = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_MODE"); if (!string.IsNullOrWhiteSpace(startMode) && @@ -486,108 +523,19 @@ private void ShowChatError(string message) /// /// Auto-sends the bootstrap kickoff message after the web chat loads. - /// Waits for the WebSocket to connect, then injects the message via JS. - /// Matches macOS's maybeKickoffOnboardingChat behavior. + /// Delegates to so the same gated + /// kickoff fires from both the (legacy) onboarding chat overlay and from + /// post-wizard HubWindow chat navigation — guarded by + /// . /// private async Task SendBootstrapMessageAsync() { if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return; _bootstrapSent = true; - const string bootstrapMessage = - "Hi! I just installed OpenClaw and you're my brand-new agent. " + - "Please start the first-run ritual from BOOTSTRAP.md, ask one question at a time, " + - "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + - "ask what matters to me and how you should be. Then guide me through choosing " + - "how we should talk (web-only, WhatsApp, or Telegram)."; - - try - { - // Wait for the web UI to initialize its WebSocket connection - await Task.Delay(3000); - - // Inject JS that finds the chat input and sends the bootstrap message. - // The Lit-based UI uses shadow DOM, so we traverse through custom elements. - // SECURITY: Use JsonSerializer to safely encode the message as a JS string literal, - // preventing XSS via template expression injection (${...}), quotes, or backslashes. - var safeMsg = System.Text.Json.JsonSerializer.Serialize(bootstrapMessage); - var js = $$""" - (function() { - const msg = {{safeMsg}}; - - // Strategy 1: Find textarea/input in the page (may be in shadow DOM) - function findInput(root) { - const inputs = root.querySelectorAll('textarea, input[type="text"]'); - for (const input of inputs) { - if (input.offsetParent !== null || input.offsetHeight > 0) return input; - } - // Search shadow DOMs - const elements = root.querySelectorAll('*'); - for (const el of elements) { - if (el.shadowRoot) { - const found = findInput(el.shadowRoot); - if (found) return found; - } - } - return null; - } - - function findButton(root) { - // Look for send buttons - const buttons = root.querySelectorAll('button'); - for (const btn of buttons) { - const text = (btn.textContent || '').toLowerCase(); - const label = (btn.getAttribute('aria-label') || '').toLowerCase(); - if (text.includes('send') || label.includes('send') || - btn.querySelector('svg') && btn.closest('form')) { - return btn; - } - } - const elements = root.querySelectorAll('*'); - for (const el of elements) { - if (el.shadowRoot) { - const found = findButton(el.shadowRoot); - if (found) return found; - } - } - return null; - } - - const input = findInput(document); - if (input) { - // Set value and dispatch events to trigger Lit's data binding - input.value = msg; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); - - // Try to find and click the send button - setTimeout(() => { - const btn = findButton(document); - if (btn) { - btn.click(); - console.log('[OpenClaw] Bootstrap message sent via button click'); - } else { - // Try Enter key as fallback - input.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true - })); - console.log('[OpenClaw] Bootstrap message sent via Enter key'); - } - }, 200); - } else { - console.warn('[OpenClaw] Could not find chat input for bootstrap'); - } - })(); - """; - - await _chatWebView.CoreWebView2.ExecuteScriptAsync(js); - Logger.Info("[OnboardingChat] Bootstrap message injection executed"); - } - catch (Exception ex) - { - Logger.Warn($"[OnboardingChat] Bootstrap injection failed: {ex.Message}"); - // Not fatal — user can type manually - } + await BootstrapMessageInjector.InjectAsync( + script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(), + _settings); } /// @@ -636,15 +584,17 @@ public async Task CaptureCurrentPageAsync() private void OnOnboardingFinished(object? sender, EventArgs e) { - _settings.Save(); - Completed = true; - _state.GatewayClient = null; - OnboardingCompleted?.Invoke(this, EventArgs.Empty); + OnWizardComplete(); Close(); } private void OnClosed(object sender, WindowEventArgs args) { + // X button path: also runs OnWizardComplete (idempotent via _completionDispatched) + // so a user who clicks the title-bar X on the Ready page still gets the chat-window + // launch when a model has been configured, matching the Finish-button behavior. + OnWizardComplete(); + if (_stateDisposed) return; _stateDisposed = true; _state.Finished -= OnOnboardingFinished; @@ -656,6 +606,82 @@ private void OnClosed(object sender, WindowEventArgs args) _state.Dispose(); } + /// + /// Unified completion handler invoked from both the Finish button (via + /// ) and the title-bar X button (via + /// ). Idempotent — guarded by . + /// + /// If the user is closing from the Ready page and setup no longer requires + /// credentials, launches the main tray hub window on the chat tab. + /// This intentionally does not depend on WizardLifecycleState == "complete": the + /// gateway wizard can stop on a later channel step even after credentials/model + /// setup succeeded, but Finish on Ready still runs this handler. + /// + private void OnWizardComplete() + { + if (_completionDispatched) return; + _completionDispatched = true; + + var finishedFromReady = _state.CurrentRoute == OnboardingRoute.Ready; + + _settings.Save(); + Completed = true; + _state.GatewayClient = null; + + // Materialize the persisted AutoStart preference into the OS-level Run-key. + // ReadyPage applies the toggle on each change, but a user who never touches + // it should still get the default (true) registered. Idempotent. + try + { + AutoStartManager.SetAutoStart(_settings.AutoStart); + } + catch (Exception ex) + { + Logger.Warn($"[Onboarding] Failed to apply AutoStart={_settings.AutoStart}: {ex.Message}"); + } + + OnboardingCompleted?.Invoke(this, EventArgs.Empty); + + var dataPath = _identityDataPath ?? SettingsManager.SettingsDirectoryPath; + var setupStillRequired = StartupSetupState.RequiresSetup(_settings, dataPath); + if (finishedFromReady && !setupStillRequired) + { + Logger.Info("[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab"); + ShowHubChatAfterWizardClose(); + } + else + { + Logger.Info($"[OnboardingWindow] OnWizardComplete skipping chat launch; route={_state.CurrentRoute}, setupStillRequired={setupStillRequired}"); + } + } + + private void ShowHubChatAfterWizardClose() + { + void ShowHubChat() + { + try + { + var app = Microsoft.UI.Xaml.Application.Current as App; + if (app == null) + { + Logger.Warn("[OnboardingWindow] ShowHub chat after Finish failed: App unavailable"); + return; + } + + app.ShowHub("chat"); + } + catch (Exception ex) + { + Logger.Warn($"[OnboardingWindow] ShowHub chat after Finish failed: {ex.Message}"); + } + } + + if (!_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, ShowHubChat)) + { + ShowHubChat(); + } + } + /// /// SECURITY: Validate visual test directory path to prevent directory traversal. /// Returns null if the path is suspicious. diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs new file mode 100644 index 00000000..3c6c073b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/LocalSetupProgressPage.cs @@ -0,0 +1,417 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; +using OpenClawTray.Services.LocalGatewaySetup; +using static OpenClawTray.FunctionalUI.Factories; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 1 of the Local fork (Phase 5). +/// +/// Drives via , +/// surfaces a small whitelist of user-meaningful stages, and auto-advances after a +/// 1-second pause once is reached. +/// On a Try again button restarts +/// the engine; on we surface the +/// message with an aka.ms/wsllogs hint and leave the user to back out. +/// +/// Layout contract (Mattingly Phase 5): +/// +/// Grid +/// Rows: Auto (title), Auto (subtitle), 1* (scrollable stages), Auto (error/retry) +/// Columns: 1* +/// Row 0: TextBlock — 22pt bold, centered +/// Row 1: TextBlock — 13pt, 0.65 opacity, wrapping, centered +/// Row 2: ScrollView wrapping VStack of per-stage Grid rows +/// Per stage: Grid columns Auto / 1* / Auto = icon | label | spinner-or-checkmark +/// States: Pending (0.4 opacity) / Active (spinner) / Complete (✅) / Failed (❌, red) +/// Row 3: Error/retry Grid (collapsed unless Failed*) — error TextBlock | Try again Button +/// +/// Hidden phases that emit subtitle only (per Mike's decision): ElevationCheck, +/// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd. +/// +public sealed class LocalSetupProgressPage : Component +{ + // Engine lives across page navigations so back/forward doesn't cancel an in-flight setup. + private static LocalGatewaySetupEngine? s_engine; + private static Task? s_runTask; + private static bool s_advanceFiredForCompletion; + + /// + /// Immutable snapshot captured per + /// invocation. Records have value-equality, so storing a fresh snapshot in + /// UseState on every event reliably triggers a re-render — unlike the + /// previous code which stored the live + /// reference (the engine mutates the same instance in place; reference-equal + /// previous/next values caused UseState to swallow every update past + /// the first, leaving the page stuck on stage 1 forever — Bug 2 / e2e drive). + /// + private sealed record RenderSnapshot( + LocalGatewaySetupPhase Phase, + LocalGatewaySetupStatus Status, + LocalGatewaySetupPhase LastRunningPhase, + string? UserMessage, + string? FailureCode); + + private static RenderSnapshot Capture(LocalGatewaySetupState st) + { + var lastRunning = LocalGatewaySetupPhase.NotStarted; + for (int i = st.History.Count - 1; i >= 0; i--) + { + var rec = st.History[i]; + if (rec.Phase != LocalGatewaySetupPhase.Failed + && rec.Phase != LocalGatewaySetupPhase.Cancelled + && rec.Phase != LocalGatewaySetupPhase.NotStarted) + { + lastRunning = rec.Phase; + break; + } + } + // While running, the last-running phase IS the current phase. + if (st.Status == LocalGatewaySetupStatus.Running + && st.Phase != LocalGatewaySetupPhase.Failed + && st.Phase != LocalGatewaySetupPhase.Cancelled + && st.Phase != LocalGatewaySetupPhase.NotStarted) + { + lastRunning = st.Phase; + } + return new RenderSnapshot(st.Phase, st.Status, lastRunning, st.UserMessage, st.FailureCode); + } + + public override Element Render() + { + var (snapshot, setSnapshot) = UseState(null); + var (retryCount, setRetryCount) = UseState(0); + var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + var advanceRef = Props; // capture for closure + + // Visual-test override: render a synthetic state so screenshot capture doesn't + // kick off a real WSL install on the test machine. + var visualState = TryReadVisualTestState(); + + UseEffect(() => + { + if (visualState != null) + { + setSnapshot(Capture(visualState)); + return () => { }; + } + + // Defense-in-depth: block local setup if existing config detected and + // replacement was not explicitly confirmed via the SetupWarningPage + // warn-and-confirm flow. Primary gate is SetupWarningPage; this catches + // env-override (OPENCLAW_ONBOARDING_START_ROUTE=LocalSetupProgress) and + // any future callers that bypass SetupWarningPage. + if (!Props.ReplaceExistingConfigurationConfirmed + && Props.ExistingConfigGuard?.HasExistingConfiguration() == true) + { + var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + failState.Block( + "existing_config_gate", + "Existing configuration detected. Use Advanced Setup to reconnect, or confirm replacement on the previous page.", + retryable: false, + detail: null); + setSnapshot(Capture(failState)); + return () => { }; + } + + if (s_engine == null) + { + try + { + var app = (App)Application.Current; + s_engine = app.CreateLocalGatewaySetupEngine(Props.ReplaceExistingConfigurationConfirmed); + } + catch (Exception ex) + { + var failState = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + failState.Block("engine_construct_failed", ex.Message, retryable: false, detail: ex.ToString()); + setSnapshot(Capture(failState)); + return () => { }; + } + } + + void Handler(LocalGatewaySetupState st) + { + // Capture an immutable RenderSnapshot OFF the dispatcher so the + // values reflect the engine's state at the moment of the event, + // not whatever the engine has further mutated to by the time the + // dispatcher dequeues us. + var snap = Capture(st); + dispatcher?.TryEnqueue(() => + { + setSnapshot(snap); + + if (snap.Status == LocalGatewaySetupStatus.Complete && !s_advanceFiredForCompletion) + { + s_advanceFiredForCompletion = true; + // Bug #1 (manual test 2026-05-05) sister fix: the next route in the + // Local easy-setup flow is Wizard, which calls wizard.start RPC over + // App.GatewayClient ?? Props.GatewayClient. App startup only initializes + // the operator GatewayClient when EnableNodeMode==false (App.xaml.cs:385); + // PairAsync flips it to true mid-onboarding, so without an explicit + // re-init here the WizardPage will sit in "loading" for 30s then save + // an "offline" state. Eagerly (re)initialize the gateway client now — + // operator credentials saved by Phase 12 (_settings.Token) drive auth. + try + { + var appForSeed = (App)Application.Current; + if (appForSeed.GatewayClient == null || !appForSeed.GatewayClient.IsConnectedToGateway) + appForSeed.ReinitializeGatewayClient(); + advanceRef.GatewayClient = appForSeed.GatewayClient; + } + catch (Exception ex) + { + Logger.Warn($"[LocalSetupProgress] Seeding GatewayClient before advance failed: {ex.Message}"); + } + + // 1-second pause on success per Mike's decision. Tap-to-skip: + // user can tap the (now visible+enabled) Next button to advance + // immediately; gate this timer on still being on LocalSetupProgress + // so an early tap doesn't over-advance a later page. + const int delayMs = 1000; + Logger.Info($"[LocalSetupProgress] Status=Complete observed; scheduling RequestAdvance after {delayMs}ms"); + Task.Delay(TimeSpan.FromMilliseconds(delayMs)).ContinueWith(_ => + { + Logger.Info("[LocalSetupProgress] Delay elapsed; dispatching RequestAdvance"); + var enqueued = dispatcher.TryEnqueue(() => + { + Logger.Info("[LocalSetupProgress] Dispatched lambda entered; checking guard"); + if (advanceRef.CurrentRoute == OnboardingRoute.LocalSetupProgress) + { + Logger.Info("[LocalSetupProgress] Guard passed"); + Logger.Info("[LocalSetupProgress] Calling state.RequestAdvance()"); + advanceRef.RequestAdvance(); + } + else + { + Logger.Info($"[LocalSetupProgress] Guard skipped: CurrentRoute={advanceRef.CurrentRoute}"); + } + }); + Logger.Info($"[LocalSetupProgress] TryEnqueue returned {enqueued}"); + }, + TaskScheduler.Default); + } + }); + } + + s_engine.StateChanged += Handler; + + if (s_runTask == null || s_runTask.IsCompleted || retryCount > 0) + { + s_advanceFiredForCompletion = false; + s_runTask = s_engine.RunLocalOnlyAsync(); + } + + return () => + { + if (s_engine != null) + s_engine.StateChanged -= Handler; + }; + }, retryCount); + + var phase = snapshot?.Phase ?? LocalGatewaySetupPhase.NotStarted; + var status = snapshot?.Status ?? LocalGatewaySetupStatus.Pending; + var lastRunningPhase = snapshot?.LastRunningPhase ?? LocalGatewaySetupPhase.NotStarted; + var subtitle = !string.IsNullOrWhiteSpace(snapshot?.UserMessage) + ? snapshot!.UserMessage! + : LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle"); + + // Push the nav-bar Next button state for this snapshot. Mapping (Phase 5 final policy): + // Idle/Pending (engine not started) → Hidden + // Running / RequiresAdmin / RequiresRestart / Blocked → VisibleDisabled + // Complete → VisibleEnabled (1s before auto-advance; tap to skip) + // FailedRetryable / FailedTerminal → VisibleDisabled (in-page Try Again or Back-out) + // Cancelled → VisibleDisabled + // Back is always enabled by the OnboardingApp default (pageIndex > 0). + Props.SetNextButtonState(LocalSetupProgressPolicy.MapStatusToNextButtonState(snapshot != null, status)); + + var stageRows = LocalSetupProgressStageMap.VisibleStages + .Select(stage => RenderStage(LocalizationHelper.GetString(stage.LabelKey), stage.Phases, phase, status, lastRunningPhase)) + .ToArray(); + + var isFailed = LocalSetupProgressStageMap.ShouldShowErrorRow(status); + var canRetry = LocalSetupProgressStageMap.ShouldShowRetryButton(status); + + Element errorRow; + if (isFailed) + { + var msg = snapshot?.UserMessage ?? LocalizationHelper.GetString("Onboarding_LocalSetup_TerminalFailure"); + if (status == LocalGatewaySetupStatus.FailedTerminal) + msg += "\n" + LocalizationHelper.GetString("Onboarding_LocalSetup_DiagnosticsHint"); + + var children = new System.Collections.Generic.List + { + TextBlock(msg) + .FontSize(12) + .Opacity(0.85) + .TextWrapping() + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 0) + }; + if (canRetry) + { + children.Add( + Button(LocalizationHelper.GetString("Onboarding_LocalSetup_Retry"), () => setRetryCount(retryCount + 1)) + .MinWidth(120) + .HAlign(HorizontalAlignment.Right) + .VAlign(VerticalAlignment.Center) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingLocalSetupRetry")) + .Grid(row: 0, column: 1) + ); + } + errorRow = Border( + Grid(["1*", "Auto"], ["Auto"], children.ToArray()) + .Padding(12, 10, 12, 10) + ) + .CornerRadius(8) + .BackgroundResource("SystemFillColorCriticalBackgroundBrush") + .Margin(0, 12, 0, 0); + } + else + { + errorRow = TextBlock("").Height(0); // collapsed + } + + return Grid( + columns: ["1*"], + rows: ["Auto", "Auto", "1*", "Auto"], + + TextBlock(LocalizationHelper.GetString("Onboarding_LocalSetup_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Grid(row: 0, column: 0), + + TextBlock(subtitle) + .FontSize(13) + .Opacity(0.65) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Margin(0, 6, 0, 12) + .Grid(row: 1, column: 0), + + ScrollView( + VStack(8, stageRows) + .Padding(8, 4, 8, 4) + ) + .Grid(row: 2, column: 0), + + errorRow.Grid(row: 3, column: 0) + ) + .HAlign(HorizontalAlignment.Stretch) + .VAlign(VerticalAlignment.Stretch) + .MaxWidth(520) + .Padding(0, 8, 0, 0); + } + + private static Element RenderStage(string label, LocalGatewaySetupPhase[] stagePhases, LocalGatewaySetupPhase currentPhase, LocalGatewaySetupStatus currentStatus, LocalGatewaySetupPhase lastRunningPhase) + { + var stageState = LocalSetupProgressStageMap.ComputeStageState(stagePhases, currentPhase, currentStatus, lastRunningPhase); + string icon; + Element trailing; + double opacity; + switch (stageState) + { + case LocalSetupProgressStageMap.StageState.Complete: + icon = "✅"; + trailing = TextBlock("").Width(20); + opacity = 1.0; + break; + case LocalSetupProgressStageMap.StageState.Active: + icon = "•"; + trailing = ProgressRing().Width(18).Height(18); + opacity = 1.0; + break; + case LocalSetupProgressStageMap.StageState.Failed: + icon = "❌"; + trailing = TextBlock("").Width(20); + opacity = 1.0; + break; + case LocalSetupProgressStageMap.StageState.Pending: + default: + icon = "○"; + trailing = TextBlock("").Width(20); + opacity = 0.4; + break; + } + + var labelBlock = TextBlock(label) + .FontSize(13) + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 1); + + if (stageState == LocalSetupProgressStageMap.StageState.Failed) + labelBlock = labelBlock.Set(t => t.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.IndianRed)); + + return Grid( + columns: ["Auto", "1*", "Auto"], + rows: ["Auto"], + + TextBlock(icon) + .FontSize(14) + .Margin(0, 0, 10, 0) + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 0), + + labelBlock, + + trailing.Grid(row: 0, column: 2) + ) + .Opacity(opacity) + .Padding(4, 4, 4, 4); + } + + /// + /// Visual-test hook: when OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_LOCAL_SETUP is set, + /// render a synthetic state without starting the real WSL setup engine. Accepted values: + /// "active:<phase>" (e.g. "active:CreateWslInstance"), + /// "complete", + /// "retryable:<message>", + /// "terminal:<message>". + /// + private static LocalGatewaySetupState? TryReadVisualTestState() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") != "1") return null; + var raw = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_LOCAL_SETUP"); + if (string.IsNullOrWhiteSpace(raw)) return null; + + var state = LocalGatewaySetupState.Create(new LocalGatewaySetupOptions()); + var parts = raw.Split(':', 2); + var kind = parts[0].Trim().ToLowerInvariant(); + var arg = parts.Length > 1 ? parts[1] : ""; + + switch (kind) + { + case "active": + if (Enum.TryParse(arg, ignoreCase: true, out var p)) + { + state.StartPhase(p, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleIdle")); + } + break; + case "complete": + state.CompletePhase(LocalGatewaySetupPhase.Complete, LocalizationHelper.GetString("Onboarding_LocalSetup_SubtitleSuccess")); + break; + case "retryable": + // Walk the engine partway so RenderSnapshot.LastRunningPhase pins + // the failure marker on a stage instead of stage 0. + state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, ""); + state.Block("visual_test_retryable", string.IsNullOrWhiteSpace(arg) ? "Setup hit a snag." : arg, retryable: true); + break; + case "terminal": + state.StartPhase(LocalGatewaySetupPhase.MintBootstrapToken, ""); + state.Block("visual_test_terminal", string.IsNullOrWhiteSpace(arg) ? "Setup cannot continue." : arg, retryable: false); + break; + } + return state; + } +} + diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs index 45dbbe7e..3e6c3d22 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs @@ -2,6 +2,7 @@ using OpenClawTray.FunctionalUI.Core; using OpenClawTray.Helpers; using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; using static OpenClawTray.FunctionalUI.Factories; using Microsoft.UI.Xaml; @@ -16,7 +17,16 @@ public sealed class ReadyPage : Component { public override Element Render() { - var (launchAtLogin, setLaunchAtLogin) = UseState(false); + // Safety-default the rendered switch to ON, then sync from persisted settings + // on mount (SettingsManager defaults AutoStart=true for fresh users). The mount + // sync also materializes the Run-key even if the user never touches the switch. + var (launchAtLogin, setLaunchAtLogin) = UseState(true); + UseEffect(() => + { + var persisted = Props.Settings.AutoStart; + setLaunchAtLogin(persisted); + ApplyLaunchAtLogin(persisted); + }, Props.Settings.AutoStart); return ScrollView( VStack(12, @@ -49,7 +59,11 @@ public override Element Render() // Launch at Login toggle HStack(8, - ToggleSwitch(launchAtLogin, v => setLaunchAtLogin(v)), + ToggleSwitch(launchAtLogin, v => + { + setLaunchAtLogin(v); + ApplyLaunchAtLogin(v); + }), TextBlock(LocalizationHelper.GetString("Onboarding_Ready_LaunchAtLogin")) .FontSize(13) .VAlign(VerticalAlignment.Center) @@ -61,6 +75,24 @@ public override Element Render() ).HorizontalScrollMode(Microsoft.UI.Xaml.Controls.ScrollMode.Disabled); } + private void ApplyLaunchAtLogin(bool enabled) + { + Props.Settings.AutoStart = enabled; + // Persist immediately so a user who toggles and then closes the wizard via + // the X button still gets their preference saved (OnboardingState.Complete() + // also saves on Finish — this is belt-and-braces). + Props.Settings.Save(); + + try + { + AutoStartManager.SetAutoStart(enabled); + } + catch (System.Exception ex) + { + Logger.Warn($"[ReadyPage] Failed to apply autostart={enabled}: {ex.Message}"); + } + } + private Element ModeInfoCard() { if (Props.Settings.EnableNodeMode) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs new file mode 100644 index 00000000..c8a3cad4 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs @@ -0,0 +1,190 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 0 of the forked Phase-5 onboarding flow. +/// +/// Layout contract (Mattingly Phase 5 + PR #274 must-fix #6): +/// +/// Grid +/// Rows: Auto (title), 1* (body+spacer), Auto (primary or warning section), Auto (hyperlink) +/// Columns: 1* +/// HAlign Center / VAlign Center / MaxWidth 460 +/// Row 0: TextBlock title — bold 22pt, centered +/// Row 1: TextBlock body — 14pt, 0.65 opacity, wrapping; security notice folded in +/// Row 2: [no existing config] Button "Set up locally" — accent fill, MinWidth 200, Height 44, centered +/// [existing config] VStack: ⚠️ heading + body + "Replace my setup" (accent) + "Keep my setup" (hyperlink) +/// Row 3: Button "Advanced setup" styled as TextBlockButton (hyperlink), 8px top margin (always visible) +/// +/// When existing config is detected ( +/// returns HasExistingConfiguration=true), the warn-and-confirm section replaces row 2 +/// immediately on page load. The user must explicitly click "Replace my setup" before +/// the local setup path can advance. "Advanced setup" is always available in row 3. +/// +public sealed class SetupWarningPage : Component +{ + public override Element Render() + { + var guard = Props.ExistingConfigGuard; + var hasExisting = guard?.HasExistingConfiguration() == true; + + // Initialize warn-confirm state to true when existing config detected so the + // warning is visible immediately on page load (Mike's directive: initial page + // MUST show warning when existing gateway is paired). + var (confirmingReplace, setConfirmingReplace) = UseState(hasExisting); + + string titleText = LocalizationHelper.GetString("Onboarding_SetupWarning_Title"); + string bodyText = LocalizationHelper.GetString("Onboarding_SetupWarning_Body"); + + void ChooseLocal() + { + if (guard?.HasExistingConfiguration() == true) + { + // Show warn-and-confirm section in-place. + setConfirmingReplace(true); + } + else + { + Props.SetupPath = Onboarding.Services.SetupPath.Local; + Props.Mode = ConnectionMode.Local; + Props.RequestAdvance(); + } + } + + void ConfirmReplace() + { + Props.ReplaceExistingConfigurationConfirmed = true; + Props.SetupPath = Onboarding.Services.SetupPath.Local; + Props.Mode = ConnectionMode.Local; + Props.RequestAdvance(); + } + + void CancelReplace() + { + setConfirmingReplace(false); + } + + void ChooseAdvanced() + { + Props.SetupPath = Onboarding.Services.SetupPath.Advanced; + Props.RequestAdvance(); + } + + // Row 2: either the local setup button or the warn-and-confirm section. + Element row2; + if (confirmingReplace) + { + var summary = guard?.GetSummary(); + var replaceBody = LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceBody"); + + // Append dynamic lost-items detail (Mike Q2: list specifically what is lost). + var lostItems = new System.Collections.Generic.List(); + if (summary?.HasToken == true) lostItems.Add("gateway token"); + if (summary?.HasOperatorDeviceToken == true || summary?.HasNodeDeviceToken == true) lostItems.Add("device pairing"); + if (summary?.HasNonDefaultGatewayUrl == true) lostItems.Add("current gateway URL"); + if (summary?.HasBootstrapToken == true) lostItems.Add("bootstrap token"); + if (lostItems.Count > 0) + replaceBody += $" This will overwrite: {string.Join(", ", lostItems)}."; + + row2 = VStack(8, + TextBlock(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceHeading")) + .FontSize(15) + .FontWeight(new global::Windows.UI.Text.FontWeight(600)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping(), + + TextBlock(replaceBody) + .FontSize(13) + .Opacity(0.75) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Margin(0, 4, 0, 8), + + Button(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceConfirm"), ConfirmReplace) + .MinWidth(200) + .Height(44) + .HAlign(HorizontalAlignment.Center) + .Set(b => + { + b.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingReplaceConfirm"); + }), + + Button(LocalizationHelper.GetString("Onboarding_SetupWarning_ReplaceCancel"), CancelReplace) + .HAlign(HorizontalAlignment.Center) + .Set(b => + { + if (Application.Current.Resources.TryGetValue("TextBlockButtonStyle", out var hyperStyle) && + hyperStyle is Style s) + { + b.Style = s; + } + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingReplaceCancel"); + }) + ) + .HAlign(HorizontalAlignment.Center) + .Grid(row: 2, column: 0); + } + else + { + row2 = Button(LocalizationHelper.GetString("Onboarding_SetupWarning_SetupLocally"), ChooseLocal) + .MinWidth(200) + .Height(44) + .HAlign(HorizontalAlignment.Center) + .Set(b => + { + b.Style = (Style)Application.Current.Resources["AccentButtonStyle"]; + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupLocal"); + }) + .Grid(row: 2, column: 0); + } + + return Grid( + columns: ["1*"], + rows: ["Auto", "1*", "Auto", "Auto"], + + TextBlock(titleText) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Grid(row: 0, column: 0), + + TextBlock(bodyText) + .FontSize(14) + .Opacity(0.65) + .HAlign(HorizontalAlignment.Center) + .VAlign(VerticalAlignment.Top) + .TextWrapping() + .Margin(0, 12, 0, 12) + .Grid(row: 1, column: 0), + + row2, + + Button(LocalizationHelper.GetString("Onboarding_SetupWarning_Advanced"), ChooseAdvanced) + .HAlign(HorizontalAlignment.Center) + .Margin(0, 8, 0, 0) + .Set(b => + { + if (Application.Current.Resources.TryGetValue("TextBlockButtonStyle", out var hyperStyle) && + hyperStyle is Style s) + { + b.Style = s; + } + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingSetupAdvanced"); + }) + .Grid(row: 3, column: 0) + ) + .HAlign(HorizontalAlignment.Center) + .VAlign(VerticalAlignment.Center) + .MaxWidth(460) + .Padding(0, 8, 0, 0); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs deleted file mode 100644 index dcc9d7bc..00000000 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs +++ /dev/null @@ -1,78 +0,0 @@ -using OpenClawTray.FunctionalUI; -using OpenClawTray.FunctionalUI.Core; -using OpenClawTray.Helpers; -using static OpenClawTray.FunctionalUI.Factories; -using Microsoft.UI.Xaml; - -namespace OpenClawTray.Onboarding.Pages; - -/// -/// Page 0: Welcome & Security Notice. -/// Matches macOS welcomePage() — title, subtitle, security warning card. -/// -public sealed class WelcomePage : Component -{ - public override Element Render() - { - return VStack(10, - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Title")) - .FontSize(22) - .FontWeight(new global::Windows.UI.Text.FontWeight(700)) - .HAlign(HorizontalAlignment.Center) - .TextWrapping(), - - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Subtitle")) - .FontSize(14) - .Opacity(0.6) - .HAlign(HorizontalAlignment.Center) - .TextWrapping(), - - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_GetConnected")) - .FontSize(13) - .Opacity(0.5) - .HAlign(HorizontalAlignment.Center) - .TextWrapping() - .Margin(0, 4, 0, 0), - - // Combined security notice + trust card - Border( - VStack(8, - HStack(6, - TextBlock("⚠️").FontSize(14), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityTitle")) - .FontSize(13) - .FontWeight(new global::Windows.UI.Text.FontWeight(600)) - ), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityBody")) - .FontSize(12) - .Opacity(0.85) - .TextWrapping(), - TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_TrustTitle")) - .FontSize(13) - .FontWeight(new global::Windows.UI.Text.FontWeight(600)) - .Margin(0, 4, 0, 0), - BulletItem("Onboarding_Welcome_Trust_Commands", "Run commands on your computer"), - BulletItem("Onboarding_Welcome_Trust_Files", "Read and write files"), - BulletItem("Onboarding_Welcome_Trust_Screen", "Capture screenshots") - ).Padding(14) - ) - .CornerRadius(8) - .BackgroundResource("SystemFillColorCautionBackgroundBrush") - .Margin(0, 12, 0, 0) - ) - .HAlign(HorizontalAlignment.Center) - .VAlign(VerticalAlignment.Center) - .MaxWidth(460) - .Padding(0, 8, 0, 0); - } - - private static Element BulletItem(string key, string fallback) - { - var text = LocalizationHelper.GetString(key); - if (text == key) text = fallback; - return HStack(6, - TextBlock("•").FontSize(12).Opacity(0.6), - TextBlock(text).FontSize(12).Opacity(0.7) - ); - } -} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs index a13c0eed..fe027512 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs @@ -1,4 +1,4 @@ -using System; +using OpenClaw.Shared; namespace OpenClawTray.Onboarding.Services; @@ -10,18 +10,5 @@ public static class LocalGatewayApprover /// /// Checks if the gateway URL points to localhost. /// - public static bool IsLocalGateway(string gatewayUrl) - { - if (string.IsNullOrWhiteSpace(gatewayUrl)) return false; - try - { - var uri = new Uri(gatewayUrl); - var host = uri.Host.ToLowerInvariant(); - return host is "localhost" or "127.0.0.1" or "::1" or "[::1]"; - } - catch - { - return false; - } - } + public static bool IsLocalGateway(string gatewayUrl) => LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl); } diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs new file mode 100644 index 00000000..b99c45db --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressPolicy.cs @@ -0,0 +1,50 @@ +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Pure mapping helpers for LocalSetupProgressPage nav-bar policy +/// (Phase 5 final). Lives in the Services namespace (no WinUI / FunctionalUI +/// dependencies) so unit tests in OpenClaw.Tray.Tests can import it +/// directly via the project's selective <Compile Include> list. +/// +public static class LocalSetupProgressPolicy +{ + /// + /// Maps a snapshot to the nav-bar + /// Next button state per the Phase 5 final Next/Back-button policy. + /// + /// Mapping: + /// null / Pending → Hidden (engine not started; Idle) + /// Running → VisibleDisabled (engine progressing) + /// Complete → VisibleEnabled (1s pre-auto-advance; tap to skip) + /// FailedRetryable → VisibleDisabled (in-page Try Again is the action) + /// FailedTerminal → VisibleDisabled (force Back-out; no advancing past broken gateway) + /// RequiresAdmin / RequiresRestart / Blocked / Cancelled → VisibleDisabled + /// + /// Back is always enabled by the OnboardingApp default (pageIndex > 0 + /// on LocalSetupProgress because SetupWarning is page 0). + /// + public static OnboardingNextButtonState MapStatusToNextButtonState(LocalGatewaySetupState? snapshot, LocalGatewaySetupStatus status) + => MapStatusToNextButtonState(snapshot != null, status); + + /// + /// Snapshot-free overload used by the page after Bug 2 (e2e drive 2026-05-04). + /// The page now stores an immutable RenderSnapshot record (value equality) + /// instead of holding the live reference, + /// so it passes hasSnapshot + status directly. The original + /// reference-typed overload is preserved for back-compat with existing tests. + /// + public static OnboardingNextButtonState MapStatusToNextButtonState(bool hasSnapshot, LocalGatewaySetupStatus status) + { + if (!hasSnapshot) + return OnboardingNextButtonState.Hidden; + + return status switch + { + LocalGatewaySetupStatus.Pending => OnboardingNextButtonState.Hidden, + LocalGatewaySetupStatus.Complete => OnboardingNextButtonState.VisibleEnabled, + _ => OnboardingNextButtonState.VisibleDisabled, + }; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs new file mode 100644 index 00000000..f2c50591 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Pure helpers for LocalSetupProgressPage's stage-list rendering +/// (Phase 5). Lives in the Services namespace (no WinUI / FunctionalUI +/// dependencies) so unit tests in OpenClaw.Tray.Tests can import +/// it directly via the project's selective <Compile Include> list. +/// +/// Exists to fix Bug 2 from the e2e drive (2026-05-04) — the page render +/// previously inlined this logic AND took a reference-typed snapshot, which +/// hid two distinct defects: +/// 1. The engine raises +/// with the same mutating instance, +/// so reference-equality in UseState suppressed re-renders. +/// 2. The stage-state computation depended on 's +/// ordinal, but on failure the engine pins Phase = Failed (the highest +/// ordinal), losing the position of the last running phase. This helper +/// threads lastRunningPhase explicitly so failure rendering is +/// stable across the engine's full phase set. +/// +public static class LocalSetupProgressStageMap +{ + public enum StageState + { + Pending, + Active, + Complete, + Failed, + } + + public sealed record VisibleStage(string LabelKey, LocalGatewaySetupPhase[] Phases); + + /// + /// Whitelist of user-meaningful stages. Hidden phases (e.g. ElevationCheck, + /// PairOperator, CheckWindowsNodeReadiness, PairWindowsTrayNode, VerifyEndToEnd) + /// fold into a neighbouring visible stage or surface only as the subtitle line. + /// + public static readonly IReadOnlyList VisibleStages = new VisibleStage[] + { + new("Onboarding_LocalSetup_Phase_Preflight", new[] { LocalGatewaySetupPhase.Preflight, LocalGatewaySetupPhase.EnsureWslEnabled, LocalGatewaySetupPhase.ElevationCheck }), + new("Onboarding_LocalSetup_Phase_CreateInstance", new[] { LocalGatewaySetupPhase.CreateWslInstance }), + new("Onboarding_LocalSetup_Phase_Configure", new[] { LocalGatewaySetupPhase.ConfigureWslInstance }), + new("Onboarding_LocalSetup_Phase_InstallCli", new[] { LocalGatewaySetupPhase.InstallOpenClawCli }), + new("Onboarding_LocalSetup_Phase_PrepareConfig", new[] { LocalGatewaySetupPhase.PrepareGatewayConfig, LocalGatewaySetupPhase.InstallGatewayService }), + new("Onboarding_LocalSetup_Phase_StartGateway", new[] { LocalGatewaySetupPhase.StartGateway, LocalGatewaySetupPhase.WaitForGateway }), + new("Onboarding_LocalSetup_Phase_MintToken", new[] { LocalGatewaySetupPhase.MintBootstrapToken, LocalGatewaySetupPhase.PairOperator, LocalGatewaySetupPhase.CheckWindowsNodeReadiness, LocalGatewaySetupPhase.PairWindowsTrayNode, LocalGatewaySetupPhase.VerifyEndToEnd }), + }; + + /// + /// Compute the visual state for a single visible stage given the current + /// engine phase, status, and (when failed) the last running phase prior + /// to failure (read from ). + /// + public static StageState ComputeStageState( + LocalGatewaySetupPhase[] stagePhases, + LocalGatewaySetupPhase currentPhase, + LocalGatewaySetupStatus currentStatus, + LocalGatewaySetupPhase lastRunningPhase) + { + if (currentStatus == LocalGatewaySetupStatus.Complete) + return StageState.Complete; + + var stageOrdinals = stagePhases.Select(p => (int)p).ToArray(); + var minOrdinalInStage = stageOrdinals.Min(); + var maxOrdinalInStage = stageOrdinals.Max(); + + if (currentStatus == LocalGatewaySetupStatus.FailedRetryable + || currentStatus == LocalGatewaySetupStatus.FailedTerminal + || currentPhase == LocalGatewaySetupPhase.Failed) + { + // Use the last running phase to pin the failure marker on the + // stage where the engine actually broke. + var lastOrdinal = (int)lastRunningPhase; + if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage) + return StageState.Failed; + if (lastOrdinal > maxOrdinalInStage) + return StageState.Complete; + return StageState.Pending; + } + + if (currentStatus == LocalGatewaySetupStatus.Cancelled) + { + var lastOrdinal = (int)lastRunningPhase; + if (lastOrdinal > maxOrdinalInStage) return StageState.Complete; + if (lastOrdinal >= minOrdinalInStage && lastOrdinal <= maxOrdinalInStage) return StageState.Pending; + return StageState.Pending; + } + + var currentOrdinal = (int)currentPhase; + if (currentOrdinal > maxOrdinalInStage) + return StageState.Complete; + if (currentOrdinal >= minOrdinalInStage && currentOrdinal <= maxOrdinalInStage) + return StageState.Active; + return StageState.Pending; + } + + /// + /// Find the index of the visible stage that should be highlighted Active + /// (or Failed) for the given engine phase. Returns -1 when no visible + /// stage covers the phase (e.g. + /// or ). + /// + public static int IndexOfStageForPhase(LocalGatewaySetupPhase phase) + { + for (int i = 0; i < VisibleStages.Count; i++) + { + if (VisibleStages[i].Phases.Contains(phase)) + return i; + } + return -1; + } + + /// + /// True when the page should render the inline error / retry row + /// (FailedRetryable or FailedTerminal). All other statuses collapse it. + /// + public static bool ShouldShowErrorRow(LocalGatewaySetupStatus status) + => status == LocalGatewaySetupStatus.FailedRetryable + || status == LocalGatewaySetupStatus.FailedTerminal; + + /// + /// True when the inline error row should expose a Try Again button — + /// only on FailedRetryable. FailedTerminal forces Back-out. + /// + public static bool ShouldShowRetryButton(LocalGatewaySetupStatus status) + => status == LocalGatewaySetupStatus.FailedRetryable; +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs new file mode 100644 index 00000000..763e6758 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using OpenClaw.Shared; +using OpenClawTray.Services; +using OpenClawTray.Services.LocalGatewaySetup; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Detects whether an existing OpenClaw configuration is present in tray settings, +/// device identity, or setup-state storage. +/// Used to gate the local easy-button setup flow so returning users receive an +/// explicit warn-and-confirm dialog before potentially overwriting their credentials. +/// +public sealed class OnboardingExistingConfigGuard +{ + private const string DefaultGatewayUrl = "ws://localhost:18789"; + private readonly SettingsManager _settings; + private readonly string _identityDataPath; + private readonly string _setupStatePath; + + public OnboardingExistingConfigGuard( + SettingsManager settings, + string identityDataPath, + string? setupStatePath = null) + { + _settings = settings; + _identityDataPath = identityDataPath; + _setupStatePath = setupStatePath ?? Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", + "setup-state.json"); + } + + /// + /// Returns true if any existing configuration is detected (sync, cheap). + /// Checks in-memory settings, device-key-ed25519.json, and setup-state.json. + /// Does NOT probe WSL distros (async-only path). + /// + public bool HasExistingConfiguration() => GetSummary().HasAny; + + /// + /// Returns a detailed breakdown of which configuration components exist. + /// Sync — reads settings (in-memory), device-key files, and setup-state.json. + /// + public ExistingConfigurationSummary GetSummary() + { + return new ExistingConfigurationSummary( + HasToken: !string.IsNullOrWhiteSpace(_settings.Token), + HasBootstrapToken: !string.IsNullOrWhiteSpace(_settings.BootstrapToken), + HasNonDefaultGatewayUrl: !string.IsNullOrWhiteSpace(_settings.GatewayUrl) + && !string.Equals(_settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase), + HasOperatorDeviceToken: DeviceIdentity.HasStoredDeviceToken(_identityDataPath), + HasNodeDeviceToken: DeviceIdentity.HasStoredDeviceTokenForRole(_identityDataPath, "node"), + HasCompletedOrRunningSetupState: ReadSetupStateIsActive(_setupStatePath), + HasWslDistro: false); + } + + /// + /// Async-enriched summary that also probes WSL for the OpenClawGateway distro. + /// + public async Task GetSummaryAsync( + IWslCommandRunner? wsl = null, + CancellationToken ct = default) + { + var sync = GetSummary(); + var hasDistro = false; + if (wsl != null) + { + try + { + var result = await wsl.RunAsync(["--list", "--verbose"], ct); + hasDistro = result.StandardOutput.Contains("OpenClawGateway", StringComparison.OrdinalIgnoreCase); + } + catch + { + // Best-effort — distro probe failure does not block the gate. + } + } + return sync with { HasWslDistro = hasDistro }; + } + + private static bool ReadSetupStateIsActive(string statePath) + { + if (!File.Exists(statePath)) + return false; + try + { + var json = File.ReadAllText(statePath); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("Phase", out var phaseEl)) + { + var phaseName = phaseEl.GetString(); + // Active (returns true) if phase is NOT in the safe-to-restart set + return phaseName is not (null or "NotStarted" or "Failed" or "Cancelled"); + } + } + catch + { + // Best-effort — malformed state file does not block the gate. + } + return false; + } +} + +/// +/// Breakdown of which existing configuration components were found. +/// +public sealed record ExistingConfigurationSummary( + bool HasToken, + bool HasBootstrapToken, + bool HasNonDefaultGatewayUrl, + bool HasOperatorDeviceToken, + bool HasNodeDeviceToken, + bool HasCompletedOrRunningSetupState, + bool HasWslDistro) +{ + /// True if any configuration component exists. + public bool HasAny => + HasToken || HasBootstrapToken || HasNonDefaultGatewayUrl + || HasOperatorDeviceToken || HasNodeDeviceToken + || HasCompletedOrRunningSetupState || HasWslDistro; +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs index 8bc1f595..cc23b5d8 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -16,7 +16,7 @@ public sealed class OnboardingState : IDisposable /// /// The currently displayed route. Updated by OnboardingApp on navigation. /// - public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.Welcome; + public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.SetupWarning; /// /// Raised when the current route changes to or from the Chat page. @@ -31,6 +31,55 @@ public sealed class OnboardingState : IDisposable /// public ConnectionMode Mode { get; set; } = ConnectionMode.Local; + /// + /// Forked-onboarding setup path (Phase 5). Null until the user picks a path + /// on . While null, the nav-bar + /// "Next" button is disabled on the SetupWarning page. + /// + public SetupPath? SetupPath { get; set; } + + /// + /// Raised by pages that want to advance the OnboardingApp programmatically + /// (e.g., the SetupWarning page's "Set up locally" / "Advanced setup" buttons, + /// the LocalSetupProgress page on auto-advance after success). + /// + public event EventHandler? AdvanceRequested; + + public void RequestAdvance() + { + var subs = AdvanceRequested?.GetInvocationList().Length ?? 0; + OpenClawTray.Services.Logger.Info($"[OnboardingState] RequestAdvance invoked; subscriber count = {subs}"); + AdvanceRequested?.Invoke(this, EventArgs.Empty); + OpenClawTray.Services.Logger.Info("[OnboardingState] AdvanceRequested invoked; returned"); + } + + /// + /// Per-page nav-bar Next button state override. Pages that want fine-grained + /// control over the nav-bar Next button (Hidden / Visible+Disabled / + /// Visible+Enabled) push a value here and raise ; + /// consults this for routes that opt in (currently + /// only ) and falls back to its + /// legacy logic everywhere else. + /// + public OnboardingNextButtonState NextButtonState { get; private set; } = OnboardingNextButtonState.Default; + + /// + /// Raised when changes so + /// can re-render the nav bar. + /// + public event EventHandler? NavBarStateChanged; + + /// + /// Sets and raises + /// if the value actually changed. + /// + public void SetNextButtonState(OnboardingNextButtonState state) + { + if (NextButtonState == state) return; + NextButtonState = state; + NavBarStateChanged?.Invoke(this, EventArgs.Empty); + } + /// /// Whether the onboarding chat page should be shown. /// @@ -62,38 +111,69 @@ public sealed class OnboardingState : IDisposable /// Wizard error message if in error state. public string? WizardError { get; set; } + /// + /// Guard that detects existing tray configuration. + /// Set by after construction. + /// Null when not available (startup auto-onboarding or env-override paths). + /// + public OnboardingExistingConfigGuard? ExistingConfigGuard { get; set; } + + /// + /// Set to true by warn-and-confirm flow + /// before advancing to the local setup path. Required by + /// defense-in-depth guard and the + /// fail-closed check. + /// + public bool ReplaceExistingConfigurationConfirmed { get; set; } + public OnboardingState(SettingsManager settings) { Settings = settings; } /// - /// Returns the page order based on the selected mode and chat preference, - /// matching the macOS onboarding flow. + /// Returns the page order for the forked Phase-5 onboarding flow. + /// SetupWarning is page 0 in every flow; the user's choice on that page + /// () determines whether page 1 is the local-setup + /// progress page or the legacy advanced Connection page. /// public OnboardingRoute[] GetPageOrder() { - // Node mode: skip Wizard and Chat — node clients can't use operator RPCs - if (Settings.EnableNodeMode) + // Treat null SetupPath as Local for page-count purposes; the nav-bar + // Next button is disabled on SetupWarning until the user picks a path. + var path = SetupPath ?? Onboarding.Services.SetupPath.Local; + + // Node mode: skip Wizard and Chat — remote-node clients can't use operator RPCs. + // Exception (Bug #1, manual test 2026-05-05): Local easy-setup pairs the tray + // as BOTH operator (Phase 12) AND node (Phase 14) on the loopback gateway it + // just stood up. Even though PairAsync flips EnableNodeMode=true mid-onboarding + // (LocalGatewaySetup.cs:2147), the tray still has operator credentials and the + // Wizard hop's wizard.start RPC works. Only skip Wizard for explicit Advanced + // remote-node deployments. + if (Settings.EnableNodeMode && path != Onboarding.Services.SetupPath.Local) { - return Mode switch - { - ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Remote or ConnectionMode.Ssh => - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], - _ => // Later or unknown - [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], - }; + return [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready]; } - return (Mode, ShowChat) switch + if (path == Onboarding.Services.SetupPath.Local) { - // Local-style flows (Local, WSL, SSH tunnel) all run wizard locally - (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], - (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready], - (ConnectionMode.Remote, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], - (ConnectionMode.Remote, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], - (ConnectionMode.Later, _) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], - _ => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + // Local setup always runs the wizard locally after the gateway is up. + // The WebView2 chat-preview step was removed per UX update (PR #274 follow-up): + // post-Permissions we go straight to Ready, then optionally launch the Hub + // chat tab from OnboardingWindow.OnWizardComplete based on whether the + // wizard reached its "complete" lifecycle state (i.e. user picked a model). + return [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready]; + } + + // Advanced path: keep the legacy ConnectionMode-aware ordering. + // ShowChat (the in-wizard WebView2 chat preview) is intentionally not consulted + // anymore — the preview step has been removed from every flow. + return Mode switch + { + ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready], + ConnectionMode.Remote => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], + ConnectionMode.Later => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready], + _ => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready], }; } @@ -130,10 +210,41 @@ public enum ConnectionMode public enum OnboardingRoute { - Welcome, + SetupWarning, + LocalSetupProgress, Connection, Wizard, Permissions, Chat, Ready, } + +/// +/// Forked-onboarding setup path picked on . +/// +public enum SetupPath +{ + /// User chose "Set up locally" — run the WSL gateway setup engine. + Local, + /// User chose "Advanced setup" — fall through to the legacy ConnectionPage. + Advanced, +} + +/// +/// Per-page nav-bar Next button state override (Phase 5 final). Pages set this on +/// to opt out of the default +/// "always visible+enabled (Disabled only on SetupWarning until path chosen)" +/// behavior. consults this for routes that opt in +/// (currently only ). +/// +public enum OnboardingNextButtonState +{ + /// Use legacy nav-bar logic — visible+enabled unless route-specific defaults apply. + Default, + /// Next button collapsed entirely (e.g., LocalSetupProgress Idle state). + Hidden, + /// Next button visible but disabled (e.g., LocalSetupProgress Running / FailedRetryable / FailedTerminal). + VisibleDisabled, + /// Next button visible and enabled (e.g., LocalSetupProgress Complete during the 1s pre-auto-advance window). + VisibleEnabled, +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardFlowController.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardFlowController.cs new file mode 100644 index 00000000..2c3ebc9b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardFlowController.cs @@ -0,0 +1,215 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Testable, UI-free recovery rules for gateway-backed onboarding wizard flows. +/// +public interface IWizardGateway +{ + bool IsConnectedToGateway { get; } + event EventHandler? StatusChanged; + Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000); +} + +public sealed class OpenClawWizardGatewayAdapter : IWizardGateway +{ + private readonly OpenClawGatewayClient _client; + + public OpenClawWizardGatewayAdapter(OpenClawGatewayClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public bool IsConnectedToGateway => _client.IsConnectedToGateway; + + public event EventHandler? StatusChanged + { + add => _client.StatusChanged += value; + remove => _client.StatusChanged -= value; + } + + public Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) => + _client.SendWizardRequestAsync(method, parameters, timeoutMs); +} + +/// +/// Mutable recovery guard stored by reference in FunctionalUI state. Do not replace this +/// with UseState<bool>: render closures must observe current fields synchronously. +/// +public sealed class WizardRecoveryGuardState +{ + private int _restartAttempted; + private long _connectionLossEpoch; + + public bool HasRestartedForCurrentLostSession => Volatile.Read(ref _restartAttempted) != 0; + public long ConnectionLossEpoch => Interlocked.Read(ref _connectionLossEpoch); + + public void ObserveConnectionStatus(ConnectionStatus status) + { + if (status is ConnectionStatus.Disconnected or ConnectionStatus.Connecting or ConnectionStatus.Error) + { + Interlocked.Increment(ref _connectionLossEpoch); + } + } + + public bool TryMarkRestartAttempted() => Interlocked.CompareExchange(ref _restartAttempted, 1, 0) == 0; + + public void ResetAfterSuccessfulStart() => Volatile.Write(ref _restartAttempted, 0); + + public void ResetForManualRestart() => Volatile.Write(ref _restartAttempted, 0); +} + +public readonly record struct WizardRequestContext(long ConnectionLossEpoch); + +public enum WizardRecoveryKind +{ + NotEligible, + AlreadyAttempted, + Recovered, + Failed +} + +public sealed record WizardRecoveryResult(WizardRecoveryKind Kind, JsonElement? Payload = null, Exception? Exception = null) +{ + public static WizardRecoveryResult NotEligible { get; } = new(WizardRecoveryKind.NotEligible); + public static WizardRecoveryResult AlreadyAttempted { get; } = new(WizardRecoveryKind.AlreadyAttempted); + public static WizardRecoveryResult Recovered(JsonElement payload) => new(WizardRecoveryKind.Recovered, payload); + public static WizardRecoveryResult Failed(Exception exception) => new(WizardRecoveryKind.Failed, null, exception); +} + +public static class WizardFlowController +{ + public const string RecoveryFailureMessage = "Setup couldn't continue. Restart wizard to try again."; + public const string SlowStepRetryMessage = "Setup is taking longer than expected. Retry?"; + + public static WizardRequestContext CaptureRequestContext(WizardRecoveryGuardState guard) => + new(guard.ConnectionLossEpoch); + + public static bool IsStartPayload(JsonElement payload) => + payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("sessionId", out _); + + public static bool ShouldRecover(Exception exception, IWizardGateway? client, WizardRecoveryGuardState guard, WizardRequestContext requestContext) + { + if (exception is OperationCanceledException) + { + return true; + } + + if (exception is InvalidOperationException invalidOperation) + { + return invalidOperation.Message.Contains("wizard not found", StringComparison.OrdinalIgnoreCase) + || invalidOperation.Message.Contains("wizard not running", StringComparison.OrdinalIgnoreCase); + } + + if (exception is TimeoutException) + { + return client?.IsConnectedToGateway != true + || guard.ConnectionLossEpoch != requestContext.ConnectionLossEpoch; + } + + return false; + } + + public static async Task RestartWizardAsync( + WizardRecoveryGuardState guard, + Action clearWizardSessionState, + Func> startWizardAsync) + { + guard.ResetForManualRestart(); + clearWizardSessionState(); + return await startWizardAsync(); + } + + public static async Task TryRecoverAsync( + Exception exception, + IWizardGateway? client, + WizardRecoveryGuardState guard, + WizardRequestContext requestContext, + Func> startWizardAsync) + { + if (!ShouldRecover(exception, client, guard, requestContext)) + { + return WizardRecoveryResult.NotEligible; + } + + if (!guard.TryMarkRestartAttempted()) + { + return WizardRecoveryResult.AlreadyAttempted; + } + + try + { + var payload = await startWizardAsync(); + return WizardRecoveryResult.Recovered(payload); + } + catch (Exception ex) + { + return WizardRecoveryResult.Failed(ex); + } + } + + /// + /// Waits up to poll intervals for the gateway to + /// (re-)connect. Returns true if connected at exit, false on timeout. The + /// delegate is injected so unit tests can run instantly. + /// Pass to abort polling early (e.g., on app shutdown + /// or page navigation away); throws if cancelled. + /// + public static async Task WaitForConnectionAsync( + IWizardGateway? client, + int maxPollCount = 30, + Func? delayAsync = null, + CancellationToken cancellationToken = default) + { + delayAsync ??= () => Task.Delay(1000, cancellationToken); + for (int poll = 0; poll < maxPollCount && client?.IsConnectedToGateway != true; poll++) + { + cancellationToken.ThrowIfCancellationRequested(); + await delayAsync(); + } + return client?.IsConnectedToGateway == true; + } + + /// + /// Attempts to resume a live wizard session via wizard.next (no answer) before + /// falling back to wizard.start. Caller must NOT clear WizardSessionId before calling. + /// Call first so IsConnectedToGateway is true + /// when this method runs. + /// + public static async Task<(bool Resumed, JsonElement Payload)> TryResumeWithSessionAsync( + string? sessionId, + IWizardGateway? client, + Func> sendWizardNextNoAnswerAsync, + Func> fallbackStartWizardAsync) + { + if (!string.IsNullOrEmpty(sessionId) && client?.IsConnectedToGateway == true) + { + try + { + Logger.Info($"[WizardFlow] TryResume: wizard.next(no answer) sessionId={sessionId}"); + var stepPayload = await sendWizardNextNoAnswerAsync(sessionId); + Logger.Info("[WizardFlow] TryResume: resume succeeded"); + return (true, stepPayload); + } + catch (InvalidOperationException ex) when ( + ex.Message.Contains("wizard not found", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("wizard not running", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("session not found", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warn($"[WizardFlow] TryResume: session not found ({ex.Message}) → fallback wizard.start"); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.Warn($"[WizardFlow] TryResume: unexpected error ({ex.GetType().Name}: {ex.Message}) → fallback wizard.start"); + } + } + var startPayload = await fallbackStartWizardAsync(); + return (false, startPayload); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepSelection.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepSelection.cs new file mode 100644 index 00000000..8a0a4fcb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepSelection.cs @@ -0,0 +1,49 @@ +namespace OpenClawTray.Onboarding.Services; + +public static class WizardStepSelection +{ + public static bool RequiresSelection(string stepType) => stepType is "select" or "multiselect"; + + public static int SelectedIndex(string stepInput, IReadOnlyList optionValues) + { + for (var i = 0; i < optionValues.Count; i++) + { + if (optionValues[i] == stepInput) + return i; + } + + return -1; + } + + public static bool HasValidSelection(string stepType, string stepInput, IReadOnlyCollection optionValues) + { + if (stepType == "select") + return optionValues.Contains(stepInput); + + if (stepType == "multiselect") + { + var selected = SplitMultiSelectValues(stepInput); + return selected.Length > 0 && selected.All(optionValues.Contains); + } + + return true; + } + + public static bool ShouldDisableContinue(string stepType, string stepInput, IReadOnlyCollection optionValues) => + RequiresSelection(stepType) && !HasValidSelection(stepType, stepInput, optionValues); + + public static bool TryBuildAnswerValue(string stepType, string stepInput, IReadOnlyCollection optionValues, out string answerValue) + { + if (RequiresSelection(stepType) && !HasValidSelection(stepType, stepInput, optionValues)) + { + answerValue = ""; + return false; + } + + answerValue = string.IsNullOrEmpty(stepInput) ? "true" : stepInput; + return true; + } + + private static string[] SplitMultiSelectValues(string stepInput) => + stepInput.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 901661bd..9d67516a 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -49,6 +49,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 901652ed..1cd09640 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -97,6 +97,8 @@ private async Task InitializeWebViewAsync(SettingsManager settings) document.head.appendChild(style); })(); "); + BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(); + _ = BootstrapMessageInjector.InjectAsync(exec, ((App)Application.Current).Settings, initialDelayMs: 500); } else if (e.WebErrorStatus == CoreWebView2WebErrorStatus.ConnectionAborted || e.WebErrorStatus == CoreWebView2WebErrorStatus.CannotConnect || diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 1e0c641c..dd5d36da 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -28,8 +28,12 @@ public static void SetAutoStart(bool enable) { try { - using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, true); - if (key == null) return; + using var key = Registry.CurrentUser.CreateSubKey(RegistryKey, true); + if (key == null) + { + Logger.Warn($"Auto-start registry key unavailable: HKCU\\{RegistryKey}"); + return; + } if (enable) { diff --git a/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs new file mode 100644 index 00000000..c9a8c762 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs @@ -0,0 +1,238 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Services; + +/// +/// Injects the first-run "BOOTSTRAP.md" kickoff message into the chat WebView2 +/// exactly once after onboarding completes. +/// +/// Originally lived inline on and +/// was wired to the (now-removed) in-wizard chat preview page. PR #274 dropped that +/// page from the page order, leaving the kickoff dead. This shared service can be +/// invoked from any chat first-show site (onboarding chat overlay, HubWindow chat +/// page, etc.). +/// +/// Gating: is the +/// persistent one-shot flag. Once set, no further injection is performed. +/// +/// This service deliberately does NOT depend on Microsoft.Web.WebView2 +/// directly — callers pass in a delegate that +/// wraps CoreWebView2.ExecuteScriptAsync. This keeps the gate logic +/// testable without bringing WebView2 into the test project. +/// +public static class BootstrapMessageInjector +{ + /// + /// Delegate matching CoreWebView2.ExecuteScriptAsync(string). + /// Returns the JSON-serialized result of the script (unused here). + /// + public delegate Task ScriptExecutor(string script); + + /// + /// The kickoff message sent to the agent on first run. Mirrors the macOS + /// behavior (maybeKickoffOnboardingChat) and references BOOTSTRAP.md. + /// + public const string Message = + "Hi! I just installed OpenClaw and you're my brand-new agent. " + + "Please start the first-run ritual from BOOTSTRAP.md, ask one question at a time, " + + "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + + "ask what matters to me and how you should be. Then guide me through choosing " + + "how we should talk (web-only, WhatsApp, or Telegram)."; + + /// + /// Builds the JS payload that locates the chat input (traversing shadow DOMs + /// so it works against the Lit-based gateway chat UI), injects the message, + /// and tries to send it via the visible Send button — falling back to an + /// Enter keypress. The message is encoded via JsonSerializer to prevent + /// JS template/string injection. + /// + public static string BuildInjectionScript(string message) + { + var safeMsg = JsonSerializer.Serialize(message); + return $$""" + (function() { + const msg = {{safeMsg}}; + const seen = new Set(); + + function walk(root, visit) { + if (!root || seen.has(root)) return null; + seen.add(root); + const found = visit(root); + if (found) return found; + const elements = root.querySelectorAll ? root.querySelectorAll('*') : []; + for (const el of elements) { + if (el.shadowRoot) { + const nested = walk(el.shadowRoot, visit); + if (nested) return nested; + } + } + return null; + } + + function isVisible(el) { + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + } + + function isUsableInput(el) { + return isVisible(el) && !el.disabled && !el.readOnly; + } + + function findInput(root) { + return walk(root, r => { + const inputs = r.querySelectorAll( + 'textarea, input[type="text"], input:not([type]), [contenteditable="true"], [role="textbox"]'); + return Array.from(inputs).find(isUsableInput) || null; + }); + } + + function findButton(root) { + seen.clear(); + return walk(root, r => { + const buttons = r.querySelectorAll('button:not([disabled]), [role="button"]:not([aria-disabled="true"])'); + return Array.from(buttons).find(btn => { + if (!isVisible(btn)) return false; + const text = (btn.textContent || '').toLowerCase(); + const label = (btn.getAttribute('aria-label') || '').toLowerCase(); + const title = (btn.getAttribute('title') || '').toLowerCase(); + return text.includes('send') || label.includes('send') || title.includes('send') || + text === '➤' || text === '↑' || btn.closest('form'); + }) || null; + }); + } + + function setNativeValue(el, value) { + if (el.isContentEditable) { + el.textContent = value; + return; + } + const proto = el.tagName === 'TEXTAREA' + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; + setter ? setter.call(el, value) : (el.value = value); + } + + const inputCount = document.querySelectorAll('input,textarea,button,[contenteditable="true"],[role="textbox"]').length; + console.log('[OpenClaw] Bootstrap probe controls=' + inputCount); + seen.clear(); + const input = findInput(document); + if (!input) { + console.warn('[OpenClaw] Could not find chat input for bootstrap'); + return 'no-input'; + } + + input.focus(); + setNativeValue(input, msg); + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: msg })); + input.dispatchEvent(new Event('change', { bubbles: true })); + + const btn = findButton(document); + if (btn) { + btn.click(); + console.log('[OpenClaw] Bootstrap message sent via button click'); + return 'sent'; + } + + const form = input.closest && input.closest('form'); + if (form?.requestSubmit) { + form.requestSubmit(); + console.log('[OpenClaw] Bootstrap message sent via form submit'); + return 'sent'; + } + + input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true + })); + console.log('[OpenClaw] Bootstrap message sent via Enter key'); + return 'sent'; + })(); + """; + } + + /// + /// Should the gate allow an injection right now? Returns true only if the + /// settings flag has not yet been set. + /// + public static bool ShouldInject(SettingsManager settings) + { + if (settings is null) return false; + return !settings.HasInjectedFirstRunBootstrap; + } + + /// + /// Marks the gate as consumed and persists. Public so the rare external + /// caller (e.g. a test, or a code path that injects via a different + /// mechanism) can flip the gate without going through . + /// + public static void MarkInjected(SettingsManager settings) + { + if (settings is null) return; + if (settings.HasInjectedFirstRunBootstrap) return; + settings.HasInjectedFirstRunBootstrap = true; + settings.Save(); + } + + /// + /// Attempts to inject the bootstrap kickoff via + /// (typically a delegate wrapping CoreWebView2.ExecuteScriptAsync). + /// Returns true if the script ran successfully; the gate flag is flipped on + /// any successful execution to prevent spam. Returns false if the gate is + /// already consumed, the executor is null, or the call threw. + /// + public static async Task InjectAsync( + ScriptExecutor? executor, + SettingsManager settings, + int initialDelayMs = 3000, + CancellationToken cancellationToken = default) + { + if (settings is null) return false; + if (!ShouldInject(settings)) return false; + if (executor is null) return false; + + try + { + if (initialDelayMs > 0) + { + await Task.Delay(initialDelayMs, cancellationToken).ConfigureAwait(true); + } + + var js = BuildInjectionScript(Message); + var result = await executor(js).ConfigureAwait(true); + var status = TryParseScriptResult(result); + if (string.Equals(status, "sent", StringComparison.Ordinal)) + { + MarkInjected(settings); + Logger.Info("[BootstrapMessageInjector] Bootstrap message injection sent"); + return true; + } + + Logger.Warn($"[BootstrapMessageInjector] Bootstrap injection did not send; status={status ?? result ?? ""}"); + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) + { + Logger.Warn($"[BootstrapMessageInjector] Bootstrap injection failed: {ex.Message}"); + return false; + } + } + + private static string? TryParseScriptResult(string? result) + { + if (string.IsNullOrWhiteSpace(result)) return result; + try + { + return JsonSerializer.Deserialize(result); + } + catch (JsonException) + { + return result; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayCredentialResolver.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayCredentialResolver.cs new file mode 100644 index 00000000..7d5e777b --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayCredentialResolver.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace OpenClawTray.Services; + +/// +/// Resolved gateway credential plus metadata about which source was used. +/// +public sealed record GatewayCredential(string Token, bool IsBootstrapToken, string Source); + +/// +/// Static, WinUI-free resolver that picks the gateway credential App should use +/// when constructing OpenClawGatewayClient. Mirrors the prototype's +/// resolver shape (App.xaml.cs:1244-1298 in openclaw-windows-node) so a paired +/// operator whose only credential lives in DeviceIdentity (Bug #4) still gets a +/// client constructed at startup. +/// +/// Resolution order (Bug #4 / RubberDucky CONDITIONAL AGREE closure conditions): +/// 1. settings.Token -> use as-is, IsBootstrapToken = false +/// 2. settings.BootstrapToken-> use as bootstrap handoff, IsBootstrapToken = true +/// 3. DeviceIdentity DeviceToken from device-key-ed25519.json -> IsBootstrapToken = false +/// 4. none of the above -> null (caller logs + skips client init) +/// +public static class GatewayCredentialResolver +{ + public const string SourceSettingsToken = "settings.Token"; + public const string SourceSettingsBootstrap = "settings.BootstrapToken"; + public const string SourceDeviceIdentity = "deviceIdentity.DeviceToken"; + + public static GatewayCredential? Resolve( + string? settingsToken, + string? settingsBootstrapToken, + string? deviceIdentityPath, + Action? warn = null) + { + if (!string.IsNullOrWhiteSpace(settingsToken)) + { + return new GatewayCredential(settingsToken!, false, SourceSettingsToken); + } + + if (!string.IsNullOrWhiteSpace(settingsBootstrapToken)) + { + return new GatewayCredential(settingsBootstrapToken!, true, SourceSettingsBootstrap); + } + + if (!string.IsNullOrWhiteSpace(deviceIdentityPath) && File.Exists(deviceIdentityPath)) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(deviceIdentityPath!)); + if (doc.RootElement.TryGetProperty("DeviceToken", out var tokenElement)) + { + var stored = tokenElement.GetString(); + if (!string.IsNullOrWhiteSpace(stored)) + { + return new GatewayCredential(stored!, false, SourceDeviceIdentity); + } + } + } + catch (Exception ex) + { + warn?.Invoke($"Failed to inspect stored gateway device token: {ex.Message}"); + } + } + + return null; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs new file mode 100644 index 00000000..28b272c7 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs @@ -0,0 +1,2997 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClawTray.Onboarding.Services; +#if !OPENCLAW_TRAY_TESTS +using OpenClawTray.Services; +#endif + +namespace OpenClawTray.Services.LocalGatewaySetup; + +public enum LocalGatewaySetupPhase +{ + NotStarted, + Preflight, + ElevationCheck, + EnsureWslEnabled, + CreateWslInstance, + ConfigureWslInstance, + InstallOpenClawCli, + PrepareGatewayConfig, + InstallGatewayService, + StartGateway, + WaitForGateway, + MintBootstrapToken, + PairOperator, + CheckWindowsNodeReadiness, + PairWindowsTrayNode, + VerifyEndToEnd, + Complete, + Failed, + Cancelled +} + +public enum LocalGatewaySetupStatus +{ + Pending, + Running, + RequiresAdmin, + RequiresRestart, + Blocked, + FailedRetryable, + FailedTerminal, + Complete, + Cancelled +} + +public enum LocalGatewaySetupSeverity +{ + Info, + Warning, + Blocking +} + +public sealed record LocalGatewaySetupOptions +{ + public string DistroName { get; init; } = "OpenClawGateway"; + public string GatewayUrl { get; init; } = "ws://localhost:18789"; + public int GatewayPort { get; init; } = 18789; + public string GatewayServiceName { get; init; } = "openclaw-gateway"; + public string BaseDistroName { get; init; } = "Ubuntu-24.04"; + public string? InstanceInstallLocation { get; init; } + public string OpenClawInstallPrefix { get; init; } = "/opt/openclaw"; + public string OpenClawInstallVersion { get; init; } = "latest"; + public string OpenClawInstallMethod { get; init; } = "npm"; + public string OpenClawInstallerUrl { get; init; } = "https://openclaw.ai/install-cli.sh"; + public bool AllowExistingDistro { get; init; } + public bool EnableWindowsTrayNodeByDefault { get; init; } = true; +} + +public interface ILocalGatewaySetupEnvironment +{ + string? GetVariable(string name); +} + +public sealed class ProcessLocalGatewaySetupEnvironment : ILocalGatewaySetupEnvironment +{ + public string? GetVariable(string name) => Environment.GetEnvironmentVariable(name); +} + +public sealed record LocalGatewaySetupRuntimeConfiguration( + string? DistroName, + string? InstanceInstallLocation, + bool AllowExistingDistro) +{ + public const string DistroNameVariable = "OPENCLAW_WSL_DISTRO_NAME"; + public const string InstanceInstallLocationVariable = "OPENCLAW_WSL_INSTALL_LOCATION"; + public const string AllowExistingDistroVariable = "OPENCLAW_WSL_ALLOW_EXISTING_DISTRO"; + + public static LocalGatewaySetupRuntimeConfiguration FromEnvironment(ILocalGatewaySetupEnvironment? environment = null) + { + environment ??= new ProcessLocalGatewaySetupEnvironment(); + return new LocalGatewaySetupRuntimeConfiguration( +#if DEBUG || OPENCLAW_TRAY_TESTS + NullIfWhiteSpace(environment.GetVariable(DistroNameVariable)), +#else + null, +#endif + NullIfWhiteSpace(environment.GetVariable(InstanceInstallLocationVariable)), + IsTruthy(environment.GetVariable(AllowExistingDistroVariable))); + } + + private static bool IsTruthy(string? value) + { + return value is not null + && (value.Equals("1", StringComparison.OrdinalIgnoreCase) + || value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase)); + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} + +public sealed record LocalGatewaySetupIssue( + string Code, + string Message, + LocalGatewaySetupSeverity Severity, + string? Detail = null); + +public sealed record LocalGatewaySetupPhaseRecord( + LocalGatewaySetupPhase Phase, + LocalGatewaySetupStatus Status, + DateTimeOffset StartedAtUtc, + DateTimeOffset? FinishedAtUtc = null, + string? Message = null); + +public sealed class LocalGatewaySetupState +{ + public int SchemaVersion { get; set; } = 1; + public string RunId { get; set; } = Guid.NewGuid().ToString("N"); + public LocalGatewaySetupPhase Phase { get; set; } = LocalGatewaySetupPhase.NotStarted; + public LocalGatewaySetupStatus Status { get; set; } = LocalGatewaySetupStatus.Pending; + public string DistroName { get; set; } = "OpenClawGateway"; + public string GatewayUrl { get; set; } = "ws://localhost:18789"; + public bool IsLocalOnly { get; set; } + public string? FailureCode { get; set; } + public string? UserMessage { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List Issues { get; set; } = new(); + public List History { get; set; } = new(); + + public static LocalGatewaySetupState Create(LocalGatewaySetupOptions options) + { + return new LocalGatewaySetupState + { + DistroName = options.DistroName, + GatewayUrl = LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options) + }; + } + + public void StartPhase(LocalGatewaySetupPhase phase, string? message = null) + { + Phase = phase; + Status = LocalGatewaySetupStatus.Running; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + History.Add(new LocalGatewaySetupPhaseRecord(phase, Status, UpdatedAtUtc, Message: message)); + } + + public void CompletePhase(LocalGatewaySetupPhase phase, string? message = null) + { + Phase = phase; + Status = phase == LocalGatewaySetupPhase.Complete + ? LocalGatewaySetupStatus.Complete + : LocalGatewaySetupStatus.Running; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + + var index = History.FindLastIndex(x => x.Phase == phase && x.FinishedAtUtc is null); + if (index >= 0) + { + var record = History[index]; + History[index] = record with { Status = Status, FinishedAtUtc = UpdatedAtUtc, Message = message ?? record.Message }; + } + } + + public void Block(string code, string message, bool retryable = false, string? detail = null) + { + Phase = LocalGatewaySetupPhase.Failed; + Status = retryable ? LocalGatewaySetupStatus.FailedRetryable : LocalGatewaySetupStatus.FailedTerminal; + FailureCode = code; + UserMessage = message; + UpdatedAtUtc = DateTimeOffset.UtcNow; + Issues.Add(new LocalGatewaySetupIssue(code, message, retryable ? LocalGatewaySetupSeverity.Warning : LocalGatewaySetupSeverity.Blocking, detail)); + } +} + +public interface ILocalGatewaySetupStateStore +{ + Task LoadAsync(CancellationToken cancellationToken = default); + Task SaveAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewaySetupStateStore : ILocalGatewaySetupStateStore +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = true }; + private readonly string _statePath; + + public LocalGatewaySetupStateStore(string? statePath = null) + { + _statePath = statePath ?? Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", + "setup-state.json"); + } + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_statePath)) + return null; + + await using var stream = File.OpenRead(_statePath); + return await JsonSerializer.DeserializeAsync(stream, s_jsonOptions, cancellationToken); + } + + public async Task SaveAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var directory = Path.GetDirectoryName(_statePath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await using var stream = File.Create(_statePath); + await JsonSerializer.SerializeAsync(stream, state, s_jsonOptions, cancellationToken); + } +} + +public sealed record WslCommandResult(int ExitCode, string StandardOutput, string StandardError) +{ + public bool Success => ExitCode == 0; +} + +public sealed record WslDistroInfo(string Name, string State, int Version); +public sealed record WslStatusInfo(int? DefaultVersion, string? WslVersion, string? KernelVersion); + +public interface IWslCommandRunner +{ + Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null); + Task> ListDistrosAsync(CancellationToken cancellationToken = default); + Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default); + Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default); + Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null); +} + +public sealed class WslExeCommandRunner : IWslCommandRunner +{ + private readonly IOpenClawLogger _logger; + private readonly TimeSpan _defaultTimeout; + private readonly TimeSpan _streamDrainTimeout; + + public WslExeCommandRunner(IOpenClawLogger? logger = null, TimeSpan? defaultTimeout = null, TimeSpan? streamDrainTimeout = null) + { + _logger = logger ?? NullLogger.Instance; + _defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(30); + _streamDrainTimeout = streamDrainTimeout ?? TimeSpan.FromSeconds(5); + } + + public async Task> ListDistrosAsync(CancellationToken cancellationToken = default) + { + var result = await RunAsync(["--list", "--verbose"], cancellationToken); + return result.Success ? ParseDistroList(result.StandardOutput) : []; + } + + public Task RunAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null) => + RunProcessAsync("wsl.exe", arguments, cancellationToken, environment); + + public Task RunInDistroAsync(string name, IReadOnlyList command, CancellationToken cancellationToken = default, IReadOnlyDictionary? environment = null) + { + var args = new List { "-d", name, "--" }; + args.AddRange(command); + return RunAsync(args, cancellationToken, environment); + } + + public Task TerminateDistroAsync(string name, CancellationToken cancellationToken = default) => + RunAsync(["--terminate", name], cancellationToken); + + public Task UnregisterDistroAsync(string name, CancellationToken cancellationToken = default) => + RunAsync(["--unregister", name], cancellationToken); + + public static IReadOnlyList ParseDistroList(string output) + { + var distros = new List(); + foreach (var rawLine in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Replace("\0", string.Empty).Trim(); + if (line.Length == 0 || line.StartsWith("NAME", StringComparison.OrdinalIgnoreCase)) + continue; + + if (line[0] == '*') + line = line[1..].TrimStart(); + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3) + continue; + + if (!int.TryParse(parts[^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var version)) + continue; + + var state = parts[^2]; + var name = string.Join(" ", parts.Take(parts.Length - 2)); + if (!string.IsNullOrWhiteSpace(name)) + distros.Add(new WslDistroInfo(name, state, version)); + } + + return distros; + } + + public static WslStatusInfo ParseStatus(string output) + { + int? defaultVersion = null; + string? wslVersion = null; + string? kernelVersion = null; + + foreach (var rawLine in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Replace("\0", string.Empty).Trim(); + var separator = line.IndexOf(':'); + if (separator <= 0) + continue; + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + if (key.Equals("Default Version", StringComparison.OrdinalIgnoreCase) + && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDefaultVersion)) + { + defaultVersion = parsedDefaultVersion; + } + else if (key.Equals("WSL version", StringComparison.OrdinalIgnoreCase)) + { + wslVersion = value; + } + else if (key.Equals("Kernel version", StringComparison.OrdinalIgnoreCase)) + { + kernelVersion = value; + } + } + + return new WslStatusInfo(defaultVersion, wslVersion, kernelVersion); + } + + private async Task RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, IReadOnlyDictionary? environment) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var argument in arguments) + psi.ArgumentList.Add(argument); + + ApplyEnvironment(psi, environment); + + _logger.Info($"[WSL] {fileName} {string.Join(" ", arguments.Select(RedactArgument))}"); + + using var process = new Process { StartInfo = psi }; + try + { + process.Start(); + } + catch (Exception ex) + { + return new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}"); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultTimeout); + bool timedOut = false; + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + timedOut = true; + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.Warn($"[WSL] Failed to kill timed-out process: {ex.Message}"); + } + } + + // Drain stdout/stderr with a bounded post-exit timeout. wsl.exe routinely spawns + // descendants (wslhost.exe, distro init processes) that inherit our redirected + // pipe handles. Even after wsl.exe itself has exited, ReadToEndAsync can hang + // indefinitely waiting for EOF — observed as the "checking system" wizard hang + // during PR #274 smoke testing where the gateway distro held the pipes open for + // hours. WaitForExitAsync only governs process exit, not stream drain, so we + // need an explicit drain bound here. + var stdout = await DrainAsync(stdoutTask, _streamDrainTimeout, _logger, isStderr: false); + var stderr = await DrainAsync(stderrTask, _streamDrainTimeout, _logger, isStderr: true); + + if (timedOut) + return new WslCommandResult(-1, stdout, "wsl.exe timed out"); + + return new WslCommandResult(process.ExitCode, stdout, stderr); + } + + internal static async Task DrainAsync(Task readTask, TimeSpan drainTimeout, IOpenClawLogger logger, bool isStderr) + { + try + { + if (readTask.IsCompleted) + return await readTask; + + var winner = await Task.WhenAny(readTask, Task.Delay(drainTimeout)); + if (winner == readTask) + return await readTask; + + logger.Warn($"[WSL] Stream drain timed out after {(int)drainTimeout.TotalSeconds}s ({(isStderr ? "stderr" : "stdout")}); descendant process likely still owns the pipe handle. Returning partial output."); + return string.Empty; + } + catch (OperationCanceledException) + { + return string.Empty; + } + catch (Exception ex) + { + logger.Warn($"[WSL] Stream drain failed ({(isStderr ? "stderr" : "stdout")}): {ex.Message}"); + return string.Empty; + } + } + + private static void ApplyEnvironment(ProcessStartInfo psi, IReadOnlyDictionary? environment) + { + if (environment is null || environment.Count == 0) + return; + + var inherited = psi.Environment.ToDictionary(pair => pair.Key, pair => pair.Value ?? string.Empty, StringComparer.OrdinalIgnoreCase); + foreach (var pair in BuildProcessEnvironment(inherited, environment)) + psi.Environment[pair.Key] = pair.Value; + } + + public static Dictionary BuildProcessEnvironment( + IReadOnlyDictionary inheritedEnvironment, + IReadOnlyDictionary? environment) + { + var result = new Dictionary(inheritedEnvironment, StringComparer.OrdinalIgnoreCase); + if (environment is null || environment.Count == 0) + return result; + + foreach (var pair in environment) + result[pair.Key] = pair.Value; + + if (environment.ContainsKey(SharedGatewayTokenEnvironment.VariableName)) + AppendWslEnvPassthrough(result, SharedGatewayTokenEnvironment.VariableName + "/u"); + if (environment.ContainsKey(OpenClawGatewayTokenEnvironment.VariableName)) + AppendWslEnvPassthrough(result, OpenClawGatewayTokenEnvironment.VariableName + "/u"); + + return result; + } + + private static void AppendWslEnvPassthrough(IDictionary environment, string entry) + { + environment.TryGetValue("WSLENV", out var existing); + var parts = string.IsNullOrWhiteSpace(existing) + ? [] + : existing.Split(':', StringSplitOptions.RemoveEmptyEntries); + if (parts.Any(part => part.Equals(entry, StringComparison.OrdinalIgnoreCase))) + return; + + environment["WSLENV"] = string.IsNullOrWhiteSpace(existing) ? entry : existing + ":" + entry; + } + + private static string RedactArgument(string argument) => + SecretRedactor.Redact(argument.Contains("token", StringComparison.OrdinalIgnoreCase) + || argument.Contains("private", StringComparison.OrdinalIgnoreCase) + || argument.Contains("setupCode", StringComparison.OrdinalIgnoreCase) + ? "" + : argument); +} + +public sealed record LocalGatewayPreflightResult( + bool CanContinue, + bool RequiresAdmin, + bool RequiresRestart, + IReadOnlyList Issues); + +public enum SetupElevationOperation +{ + EnableWindowsSubsystemForLinux, + EnableVirtualMachinePlatform, + UpdateWsl +} + +public sealed record SetupElevationRequest( + SetupElevationOperation Operation, + string Reason, + IReadOnlyDictionary? Parameters = null); + +public sealed record SetupElevationResult( + bool Success, + bool RequiresRestart = false, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface ISetupElevationBroker +{ + IReadOnlySet SupportedOperations { get; } + Task ExecuteAsync(SetupElevationRequest request, CancellationToken cancellationToken = default); +} + +public sealed class UnavailableSetupElevationBroker : ISetupElevationBroker +{ + public IReadOnlySet SupportedOperations { get; } = new HashSet(); + + public Task ExecuteAsync(SetupElevationRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(new SetupElevationResult( + false, + ErrorCode: "elevation_broker_unavailable", + ErrorMessage: "The OpenClaw setup elevation broker is not available.")); + } +} + +public interface IPortProbe +{ + bool IsPortAvailable(int port); +} + +public sealed class TcpPortProbe : IPortProbe +{ + public bool IsPortAvailable(int port) + { + try + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } +} + +public interface ILocalGatewayPreflightProbe +{ + Task RunAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayPreflightProbe : ILocalGatewayPreflightProbe +{ + private readonly IWslCommandRunner _wsl; + private readonly IPortProbe _portProbe; + + public LocalGatewayPreflightProbe(IWslCommandRunner wsl, IPortProbe? portProbe = null) + { + _wsl = wsl; + _portProbe = portProbe ?? new TcpPortProbe(); + } + + public async Task RunAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var issues = new List(); + + if (!OperatingSystem.IsWindows()) + issues.Add(new LocalGatewaySetupIssue("unsupported_os", "OpenClaw local WSL gateway setup requires Windows.", LocalGatewaySetupSeverity.Blocking)); + + if (Environment.Is64BitOperatingSystem is false) + issues.Add(new LocalGatewaySetupIssue("unsupported_architecture", "OpenClaw local WSL gateway setup requires a 64-bit Windows installation.", LocalGatewaySetupSeverity.Blocking)); + + var wslStatus = await _wsl.RunAsync(["--status"], cancellationToken); + if (!wslStatus.Success) + { + issues.Add(new LocalGatewaySetupIssue("wsl_unavailable", WslLogsHelp("WSL is not available or is blocked by policy."), LocalGatewaySetupSeverity.Blocking)); + } + else + { + var status = WslExeCommandRunner.ParseStatus(wslStatus.StandardOutput); + if (status.DefaultVersion == 1) + issues.Add(new LocalGatewaySetupIssue("wsl_default_version_1", "The host default WSL version is WSL1. OpenClaw creates its dedicated gateway instance as WSL2.", LocalGatewaySetupSeverity.Warning)); + } + + var distros = await _wsl.ListDistrosAsync(cancellationToken); + if (!options.AllowExistingDistro && distros.Any(d => string.Equals(d.Name, options.DistroName, StringComparison.OrdinalIgnoreCase))) + issues.Add(new LocalGatewaySetupIssue("distro_exists", $"A WSL distro named {options.DistroName} already exists.", LocalGatewaySetupSeverity.Blocking)); + + if (distros.Any(d => d.Version == 1)) + issues.Add(new LocalGatewaySetupIssue("wsl1_present", "WSL1 distros are present. OpenClaw uses WSL2 and does not modify existing distros.", LocalGatewaySetupSeverity.Warning)); + + if (!_portProbe.IsPortAvailable(options.GatewayPort)) + { + if (options.AllowExistingDistro && await IsExistingGatewayPortAsync(options, cancellationToken)) + { + issues.Add(new LocalGatewaySetupIssue( + "gateway_port_already_active", + $"Local gateway port {options.GatewayPort} is already served by the OpenClawGateway WSL instance; setup will resume against it.", + LocalGatewaySetupSeverity.Warning)); + } + else + { + issues.Add(new LocalGatewaySetupIssue("port_in_use", $"Local gateway port {options.GatewayPort} is already in use.", LocalGatewaySetupSeverity.Blocking)); + } + } + + var canContinue = issues.All(x => x.Severity != LocalGatewaySetupSeverity.Blocking); + return new LocalGatewayPreflightResult(canContinue, RequiresAdmin: false, RequiresRestart: false, issues); + } + + private async Task IsExistingGatewayPortAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "if [ -s /var/lib/openclaw/gateway-token ]; then", + // TODO(aaron-token-argv-backlog): move this status probe to env auth so gateway tokens never reach argv. + " xargs -r " + ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw") + " gateway status --json --require-rpc --url " + ShellQuote(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)) + " --token message + " If WSL diagnostics are needed, follow aka.ms/wsllogs."; + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed record WslInstanceInstallResult( + bool Success, + string? InstallLocation = null, + IReadOnlyList? Warnings = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IWslInstanceInstaller +{ + Task EnsureInstalledAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class WslStoreInstanceInstaller : IWslInstanceInstaller +{ + private readonly IWslCommandRunner _wsl; + private readonly Action _createDirectory; + + public WslStoreInstanceInstaller(IWslCommandRunner wsl, Action? createDirectory = null) + { + _wsl = wsl; + _createDirectory = createDirectory ?? (path => Directory.CreateDirectory(path)); + } + + public async Task EnsureInstalledAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var installLocation = ResolveInstallLocation(options); + var distros = await _wsl.ListDistrosAsync(cancellationToken); + if (distros.Any(d => string.Equals(d.Name, options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2)) + { + return options.AllowExistingDistro + ? new WslInstanceInstallResult(true, installLocation, ["wsl_instance_already_exists"]) + : new WslInstanceInstallResult(false, installLocation, ErrorCode: "distro_exists", ErrorMessage: $"A WSL distro named {options.DistroName} already exists."); + } + + _createDirectory(installLocation); + var install = await _wsl.RunAsync([ + "--install", + options.BaseDistroName, + "--name", + options.DistroName, + "--location", + installLocation, + "--no-launch", + "--version", + "2"], cancellationToken); + + if (install.Success) + return new WslInstanceInstallResult(true, installLocation); + + var diagnostics = new List { $"wsl_install_exit_code={install.ExitCode}" }; + AddDiagnosticOutput(diagnostics, "wsl_install_stdout", install.StandardOutput); + AddDiagnosticOutput(diagnostics, "wsl_install_stderr", install.StandardError); + diagnostics.Add("wsl_logs=aka.ms/wsllogs"); + return new WslInstanceInstallResult( + false, + installLocation, + diagnostics, + "wsl_instance_install_failed", + WslLogsHelp("Creating the OpenClaw Gateway WSL instance failed.")); + } + + public static string ResolveInstallLocation(LocalGatewaySetupOptions options) + { + if (!string.IsNullOrWhiteSpace(options.InstanceInstallLocation)) + return options.InstanceInstallLocation; + + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", + "wsl", + options.DistroName); + } + + private static void AddDiagnosticOutput(List diagnostics, string name, string value) + { + var sanitized = SanitizeForDiagnostic(value); + if (!string.IsNullOrWhiteSpace(sanitized)) + diagnostics.Add($"{name}={sanitized}"); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + + private static string SanitizeForDiagnostic(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sanitized = SecretRedactor.Redact(value).Replace("\0", string.Empty).Trim(); + const int maxLength = 2000; + return sanitized.Length <= maxLength ? sanitized : sanitized[..maxLength] + "..."; + } +} + +public sealed record WslInstanceConfigurationResult( + bool Success, + IReadOnlyList? Warnings = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IWslInstanceConfigurator +{ + Task ConfigureAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class WslFirstBootConfigurator : IWslInstanceConfigurator +{ + private readonly IWslCommandRunner _wsl; + + public WslFirstBootConfigurator(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task ConfigureAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + if (options.AllowExistingDistro && await IsAlreadyConfiguredAsync(options, cancellationToken)) + return new WslInstanceConfigurationResult(true, ["wsl_instance_already_configured"]); + + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "if ! id -u openclaw >/dev/null 2>&1; then useradd --create-home --shell /bin/bash openclaw; fi", + "install -d -m 0755 -o openclaw -g openclaw /home/openclaw/.openclaw", + "install -d -m 0755 -o openclaw -g openclaw " + ShellQuote(options.OpenClawInstallPrefix), + "install -d -m 0755 -o openclaw -g openclaw /var/lib/openclaw", + "install -d -m 0755 -o openclaw -g openclaw /var/log/openclaw", + "cat >/etc/wsl.conf <<'EOF'", + "[boot]", + "systemd=true", + "", + "[automount]", + "enabled=false", + "", + "[interop]", + "enabled=false", + "appendWindowsPath=false", + "", + "[user]", + "default=openclaw", + "EOF", + "cat >/etc/wsl-distribution.conf <<'EOF'", + "[oobe]", + "defaultName=openclaw", + "EOF", + "loginctl enable-linger openclaw || true", + "chown -R openclaw:openclaw /home/openclaw/.openclaw " + ShellQuote(options.OpenClawInstallPrefix) + " /var/lib/openclaw /var/log/openclaw" + }); + + var configure = await _wsl.RunAsync(["-d", options.DistroName, "-u", "root", "--", "bash", "-lc", script], cancellationToken); + if (!configure.Success) + { + return new WslInstanceConfigurationResult( + false, + ErrorCode: "wsl_firstboot_config_failed", + ErrorMessage: WslLogsHelp("Failed to configure the OpenClaw WSL instance.")); + } + + var warnings = new List(); + var setDefaultUser = await _wsl.RunAsync(["--manage", options.DistroName, "--set-default-user", "openclaw"], cancellationToken); + if (!setDefaultUser.Success) + warnings.Add("wsl_manage_set_default_user_failed"); + + var terminate = await _wsl.TerminateDistroAsync(options.DistroName, cancellationToken); + if (!terminate.Success) + { + return new WslInstanceConfigurationResult( + false, + warnings, + "wsl_instance_restart_failed", + WslLogsHelp("Failed to restart the OpenClaw WSL instance after writing WSL configuration.")); + } + + return new WslInstanceConfigurationResult(true, warnings); + } + + private async Task IsAlreadyConfiguredAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "id -u openclaw >/dev/null", + "test -d /home/openclaw/.openclaw", + "test -d " + ShellQuote(options.OpenClawInstallPrefix), + "grep -q '^systemd=true$' /etc/wsl.conf", + "grep -q '^enabled=false$' /etc/wsl.conf", + "grep -q '^appendWindowsPath=false$' /etc/wsl.conf", + "grep -q '^default=openclaw$' /etc/wsl.conf" + }); + + var probe = await _wsl.RunAsync(["-d", options.DistroName, "-u", "root", "--", "bash", "-lc", script], cancellationToken); + return probe.Success; + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed record OpenClawLinuxInstallerEvent(string? Event, string? Phase, string? Message, string RawLine); + +public sealed record OpenClawLinuxInstallResult( + bool Success, + IReadOnlyList? Events = null, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public interface IOpenClawLinuxInstaller +{ + Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawInstallCliLinuxInstaller : IOpenClawLinuxInstaller +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawInstallCliLinuxInstaller(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + await StopExistingGatewayServiceAsync(options, cancellationToken); + + var script = string.Join(" ", new[] + { + "set -euo pipefail;", + "curl -fsSL --proto '=https' --tlsv1.2", + ShellQuote(options.OpenClawInstallerUrl), + "|", + "OPENCLAW_PREFIX=" + ShellQuote(options.OpenClawInstallPrefix), + "OPENCLAW_INSTALL_METHOD=" + ShellQuote(options.OpenClawInstallMethod), + "OPENCLAW_VERSION=" + ShellQuote(options.OpenClawInstallVersion), + "SHARP_IGNORE_GLOBAL_LIBVIPS=1", + "bash -s -- --json --prefix", + ShellQuote(options.OpenClawInstallPrefix), + "--version", + ShellQuote(options.OpenClawInstallVersion), + "--no-onboard" + }); + + var install = await _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + var events = ParseInstallerEvents(install.StandardOutput); + if (!install.Success) + { + var detail = BuildCommandDiagnostic("openclaw_install", install); + return new OpenClawLinuxInstallResult(false, events, "openclaw_linux_install_failed", "The upstream OpenClaw Linux installer failed.", detail); + } + + var version = await _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", options.OpenClawInstallPrefix + "/bin/openclaw", "--version"], cancellationToken); + if (!version.Success) + { + var detail = BuildCommandDiagnostic("openclaw_cli_verify", version); + return new OpenClawLinuxInstallResult(false, events, "openclaw_cli_verify_failed", "The OpenClaw CLI was installed, but the installed binary could not be verified.", detail); + } + + return new OpenClawLinuxInstallResult(true, events); + } + + public static IReadOnlyList ParseInstallerEvents(string output) + { + var events = new List(); + foreach (var line in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var redactedLine = SecretRedactor.Redact(line); + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + events.Add(new OpenClawLinuxInstallerEvent( + TryGetString(root, "event"), + TryGetString(root, "phase"), + SecretRedactor.Redact(TryGetString(root, "message") ?? string.Empty), + redactedLine)); + } + catch (JsonException) + { + events.Add(new OpenClawLinuxInstallerEvent(null, null, redactedLine, redactedLine)); + } + } + + return events; + } + + private Task StopExistingGatewayServiceAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + const string serviceName = "openclaw-gateway.service"; + var script = string.Join("\n", new[] + { + "set +e", + "systemctl --user stop " + serviceName + " >/dev/null 2>&1", + "systemctl --user reset-failed " + serviceName + " >/dev/null 2>&1" + }); + return _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + } + + private static string? TryGetString(JsonElement root, string propertyName) + { + if (root.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) + return property.GetString(); + return null; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; + private static string BuildCommandDiagnostic(string prefix, WslCommandResult result) => DiagnosticFormatter.Build(prefix, result); +} + +public sealed record GatewayServiceOperationResult( + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public sealed record GatewayConfigurationResult( + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public interface IGatewayConfigurationPreparer +{ + Task PrepareAsync(LocalGatewaySetupOptions options, string sharedGatewayToken, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawCliGatewayConfigurationPreparer : IGatewayConfigurationPreparer +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawCliGatewayConfigurationPreparer(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task PrepareAsync(LocalGatewaySetupOptions options, string sharedGatewayToken, CancellationToken cancellationToken = default) + { + var openClaw = ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw"); + var script = string.Join("\n", new[] + { + "set -euo pipefail", + "umask 077", + ": \"${OPENCLAW_SHARED_GATEWAY_TOKEN:?missing shared gateway token}\"", + "printf '%s' \"$OPENCLAW_SHARED_GATEWAY_TOKEN\" >/var/lib/openclaw/gateway-token", + openClaw + " config set gateway.mode local", + openClaw + " config set gateway.port " + options.GatewayPort.ToString(CultureInfo.InvariantCulture) + " --strict-json", + openClaw + " config set gateway.auth.mode token", + "xargs -r " + openClaw + " config set gateway.auth.token + { + [SharedGatewayTokenEnvironment.VariableName] = sharedGatewayToken + }; + var result = await _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken, environment); + return result.Success + ? new GatewayConfigurationResult(true) + : new GatewayConfigurationResult(false, "gateway_config_prepare_failed", "Failed to prepare upstream OpenClaw gateway configuration.", DiagnosticFormatter.Build("gateway_config_prepare", result)); + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public interface IGatewayServiceManager +{ + Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); + Task StartAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawCliGatewayServiceManager : IGatewayServiceManager +{ + private readonly IWslCommandRunner _wsl; + + public OpenClawCliGatewayServiceManager(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task InstallAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + await ResetFailedServiceStateAsync(options, cancellationToken); + var result = await RunOpenClawAsync(options, ["gateway", "install", "--force", "--port", options.GatewayPort.ToString(CultureInfo.InvariantCulture)], cancellationToken); + if (result.Success) + return new GatewayServiceOperationResult(true); + + var firstFailure = DiagnosticFormatter.Build("gateway_service_install", result); + if (IsSystemdStartLimitFailure(result)) + { + await ResetFailedServiceStateAsync(options, cancellationToken); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + + var retry = await RunOpenClawAsync(options, ["gateway", "install", "--force", "--port", options.GatewayPort.ToString(CultureInfo.InvariantCulture)], cancellationToken); + if (retry.Success) + return new GatewayServiceOperationResult(true); + + return new GatewayServiceOperationResult(false, "gateway_service_install_failed", "Failed to install the upstream OpenClaw gateway service.", firstFailure + Environment.NewLine + DiagnosticFormatter.Build("gateway_service_install_retry", retry)); + } + + return new GatewayServiceOperationResult(false, "gateway_service_install_failed", "Failed to install the upstream OpenClaw gateway service.", firstFailure); + } + + public async Task StartAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var start = await RunOpenClawAsync(options, ["gateway", "start"], cancellationToken); + if (!start.Success) + return new GatewayServiceOperationResult(false, "gateway_service_start_failed", WslLogsHelp("Failed to start the upstream OpenClaw gateway service.")); + + WslCommandResult? lastStatus = null; + var deadline = DateTimeOffset.UtcNow.AddSeconds(90); + while (DateTimeOffset.UtcNow < deadline) + { + lastStatus = await RunStatusWithTokenAsync(options, cancellationToken); + if (lastStatus.Success) + return new GatewayServiceOperationResult(true); + + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + + return new GatewayServiceOperationResult( + false, + "gateway_service_status_failed", + WslLogsHelp("The OpenClaw gateway service started, but did not report ready status."), + lastStatus is null ? null : DiagnosticFormatter.Build("gateway_service_status", lastStatus)); + } + + private Task RunOpenClawAsync(LocalGatewaySetupOptions options, IReadOnlyList arguments, CancellationToken cancellationToken) + { + var args = new List { "-d", options.DistroName, "-u", "openclaw", "--", options.OpenClawInstallPrefix + "/bin/openclaw" }; + args.AddRange(arguments); + return _wsl.RunAsync(args, cancellationToken); + } + + private Task RunStatusWithTokenAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + var script = string.Join("\n", new[] + { + "set -euo pipefail", + // TODO(aaron-token-argv-backlog): move this status probe to env auth so gateway tokens never reach argv. + "xargs -r " + ShellQuote(options.OpenClawInstallPrefix + "/bin/openclaw") + + " gateway status --json --require-rpc --url " + + ShellQuote(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)) + + " --token ResetFailedServiceStateAsync(LocalGatewaySetupOptions options, CancellationToken cancellationToken) + { + const string serviceName = "openclaw-gateway.service"; + var script = string.Join("\n", new[] + { + "set +e", + "systemctl --user reset-failed " + serviceName + " >/dev/null 2>&1" + }); + return _wsl.RunAsync(["-d", options.DistroName, "-u", "openclaw", "--", "bash", "-lc", script], cancellationToken); + } + + private static bool IsSystemdStartLimitFailure(WslCommandResult result) + { + var output = (result.StandardOutput ?? string.Empty) + "\n" + (result.StandardError ?? string.Empty); + return output.Contains("start-limit-hit", StringComparison.OrdinalIgnoreCase) + || output.Contains("Start request repeated too quickly", StringComparison.OrdinalIgnoreCase) + || output.Contains("systemctl restart failed", StringComparison.OrdinalIgnoreCase); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +internal static partial class SecretRedactor +{ + [GeneratedRegex("(?i)(setup[_-]?code|bootstrap[_-]?token|device[_-]?token|gateway[_-]?token|auth[_-]?token|private[_-]?key(?:base64)?|public[_-]?key(?:base64)?|secret)(['\\\"\\s:=]+)([^\\s,'\\\"}]+)")] + private static partial Regex SecretValueRegex(); + + public static string Redact(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + return SecretValueRegex().Replace(value, "$1$2"); + } +} + +internal static class DiagnosticFormatter +{ + public static string Build(string prefix, WslCommandResult result) + { + var diagnostics = new List { $"{prefix}_exit_code={result.ExitCode}" }; + AddOutput(diagnostics, $"{prefix}_stdout", result.StandardOutput); + AddOutput(diagnostics, $"{prefix}_stderr", result.StandardError); + return string.Join(Environment.NewLine, diagnostics); + } + + private static void AddOutput(List diagnostics, string name, string value) + { + var sanitized = SanitizeForDiagnostic(value); + if (!string.IsNullOrWhiteSpace(sanitized)) + diagnostics.Add($"{name}={sanitized}"); + } + + private static string SanitizeForDiagnostic(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var sanitized = SecretRedactor.Redact(value).Replace("\0", string.Empty).Trim(); + const int maxLength = 2000; + return sanitized.Length <= maxLength ? sanitized : sanitized[..maxLength] + "..."; + } +} + +public sealed record LocalGatewayHealthResult(bool Success, string? Error = null); + +public sealed record LocalGatewayEndpointResolutionResult( + bool Success, + string GatewayUrl, + string? Error = null); + +public interface ILocalGatewayHealthProbe +{ + Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayHealthProbe : ILocalGatewayHealthProbe +{ + public async Task WaitForHealthyAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < 10; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = await GatewayHealthCheck.TestAsync(gatewayUrl, token: null); + if (result.Success) + return new LocalGatewayHealthResult(true); + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + } + + return new LocalGatewayHealthResult(false, WslLogsHelp("Gateway did not become healthy.")); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; +} + +public interface ILocalGatewayEndpointResolver +{ + Task ResolveAsync( + LocalGatewaySetupOptions options, + string currentGatewayUrl, + ILocalGatewayHealthProbe healthProbe, + IWslCommandRunner wsl, + CancellationToken cancellationToken = default); +} + +public sealed class LocalGatewayEndpointResolver : ILocalGatewayEndpointResolver +{ + public Task ResolveAsync( + LocalGatewaySetupOptions options, + string currentGatewayUrl, + ILocalGatewayHealthProbe healthProbe, + IWslCommandRunner wsl, + CancellationToken cancellationToken = default) + { + return ResolveLoopbackAsync(options, healthProbe, cancellationToken); + } + + public static string BuildLoopbackGatewayUrl(LocalGatewaySetupOptions options) + { + var scheme = Uri.TryCreate(options.GatewayUrl, UriKind.Absolute, out var uri) ? uri.Scheme : "ws"; + return $"{scheme}://localhost:{options.GatewayPort}"; + } + + private static async Task ResolveLoopbackAsync(LocalGatewaySetupOptions options, ILocalGatewayHealthProbe healthProbe, CancellationToken cancellationToken) + { + var gatewayUrl = BuildLoopbackGatewayUrl(options); + var result = await healthProbe.WaitForHealthyAsync(gatewayUrl, cancellationToken); + return result.Success + ? new LocalGatewayEndpointResolutionResult(true, gatewayUrl) + : new LocalGatewayEndpointResolutionResult(false, gatewayUrl, result.Error ?? WslLogsHelp("Gateway did not become healthy.")); + } + + private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; +} + +public sealed record ProvisioningResult(bool Success, string? ErrorCode = null, string? ErrorMessage = null); + +public interface IOperatorPairingService +{ + Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public sealed record BootstrapTokenResult( + bool Success, + string? BootstrapToken = null, + DateTimeOffset? ExpiresAtUtc = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public interface IBootstrapTokenProvider +{ + Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface IBootstrapTokenProvisioner +{ + Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} +public enum SharedGatewayTokenSource +{ + Generated, + PreservedFromWsl +} + +public sealed record SharedGatewayTokenResult( + bool Success, + string? Token = null, + SharedGatewayTokenSource? Source = null, + string? ErrorCode = null, + string? ErrorMessage = null); + +public sealed record SharedGatewayProvisioningResult( + bool Success, + string? Token = null, + SharedGatewayTokenSource? Source = null, + string? ErrorCode = null, + string? ErrorMessage = null, + string? Detail = null); + +public interface ISharedGatewayTokenProvider +{ + Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface ISharedGatewayTokenProvisioner +{ + Task ProvisionAsync(LocalGatewaySetupState state, LocalGatewaySetupOptions options, CancellationToken cancellationToken = default); +} + +public static class SharedGatewayTokenEnvironment +{ + public const string VariableName = "OPENCLAW_SHARED_GATEWAY_TOKEN"; +} + +public static class OpenClawGatewayTokenEnvironment +{ + public const string VariableName = "OPENCLAW_GATEWAY_TOKEN"; +} + +public interface IWindowsTrayNodeProvisioner +{ + Task CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); + Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); +} + +public interface ILocalGatewaySetupSettings +{ + string GatewayUrl { get; set; } + string Token { get; set; } + string BootstrapToken { get; set; } + bool UseSshTunnel { get; set; } + bool EnableNodeMode { get; set; } + void Save(); +} + +public sealed class SettingsManagerLocalGatewaySetupSettings : ILocalGatewaySetupSettings +{ + private readonly SettingsManager _settings; + + public SettingsManagerLocalGatewaySetupSettings(SettingsManager settings) + { + _settings = settings; + } + + public string GatewayUrl { get => _settings.GatewayUrl; set => _settings.GatewayUrl = value; } + public string Token { get => _settings.Token; set => _settings.Token = value; } + public string BootstrapToken { get => _settings.BootstrapToken; set => _settings.BootstrapToken = value; } + public bool UseSshTunnel { get => _settings.UseSshTunnel; set => _settings.UseSshTunnel = value; } + public bool EnableNodeMode { get => _settings.EnableNodeMode; set => _settings.EnableNodeMode = value; } + public void Save() => _settings.Save(); +} + +public sealed class DeferredBootstrapTokenProvisioner : IBootstrapTokenProvisioner +{ + public Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) => + Task.FromResult(new ProvisioningResult(true)); +} + +public interface IWindowsNodeConnector +{ + Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken, CancellationToken cancellationToken = default); +} + +#if !OPENCLAW_TRAY_TESTS +public sealed class NodeServiceWindowsNodeConnector : IWindowsNodeConnector +{ + private readonly NodeService _nodeService; + + public NodeServiceWindowsNodeConnector(NodeService nodeService) + { + _nodeService = nodeService; + } + + public async Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await _nodeService.ConnectAsync(gatewayUrl, token, bootstrapToken); + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(35); + while (DateTimeOffset.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_nodeService.IsConnected && _nodeService.IsPaired) + return; + + await Task.Delay(250, cancellationToken); + } + + throw new TimeoutException("Timed out waiting for the Windows tray node to pair with the gateway."); + } +} +#endif + +public static class WslDistroKeepAlive +{ + private static readonly object s_lock = new(); + private static readonly Dictionary s_processes = new(StringComparer.OrdinalIgnoreCase); + private static bool s_processExitRegistered; + + public static void EnsureStarted(string distroName, IOpenClawLogger? logger = null) + { + if (string.IsNullOrWhiteSpace(distroName)) + return; + + lock (s_lock) + { + if (s_processes.TryGetValue(distroName, out var existing) && !existing.HasExited) + return; + + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = "wsl.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "-d", distroName, "-u", "openclaw", "--", "sleep", "2147483647" } + }); + + if (process == null) + { + logger?.Warn("Failed to start WSL keepalive process."); + return; + } + + s_processes[distroName] = process; + logger?.Info($"Started WSL keepalive process for {distroName} (PID {process.Id})."); + + if (!s_processExitRegistered) + { + AppDomain.CurrentDomain.ProcessExit += (_, _) => StopAll(); + s_processExitRegistered = true; + } + } + catch (Exception ex) + { + logger?.Warn($"Failed to start WSL keepalive process: {ex.Message}"); + } + } + } + + private static void StopAll() + { + lock (s_lock) + { + foreach (var process in s_processes.Values) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // Process exit cleanup is best-effort only. + } + } + + s_processes.Clear(); + } + } +} + +public enum GatewayOperatorConnectionStatus +{ + Connected, + PairingRequired, + AuthFailed, + Timeout, + Failed +} + +public sealed record GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus Status, string? ErrorMessage = null, string? PairingRequestId = null); + +public interface IGatewayOperatorConnector +{ + Task ConnectAsync(string gatewayUrl, string token, bool tokenIsBootstrapToken = false, CancellationToken cancellationToken = default); + Task ConnectWithStoredDeviceTokenAsync(string gatewayUrl, CancellationToken cancellationToken = default); +} + +public sealed class OpenClawGatewayOperatorConnector : IGatewayOperatorConnector +{ + private readonly IOpenClawLogger _logger; + private readonly TimeSpan _timeout; + + public OpenClawGatewayOperatorConnector(IOpenClawLogger? logger = null, TimeSpan? timeout = null) + { + _logger = logger ?? NullLogger.Instance; + _timeout = timeout ?? TimeSpan.FromSeconds(35); + } + + public async Task ConnectAsync(string gatewayUrl, string token, bool tokenIsBootstrapToken = false, CancellationToken cancellationToken = default) + { + using var client = new OpenClawGatewayClient(gatewayUrl, token, _logger, tokenIsBootstrapToken, bootstrapPairAsNode: false); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.StatusChanged += (_, status) => + { + if (status == ConnectionStatus.Connected) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Connected)); + else if (status == ConnectionStatus.Error) + { + if (client.IsPairingRequired) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.PairingRequired, "Gateway requires pairing approval.", client.PairingRequiredRequestId)); + else if (client.IsAuthFailed) + completion.TrySetResult(new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.AuthFailed, "Gateway rejected operator authentication.")); + } + }; + + try + { + await client.ConnectAsync(); + var completed = await Task.WhenAny(completion.Task, Task.Delay(_timeout, cancellationToken)); + return completed == completion.Task + ? await completion.Task + : new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Timeout, "Timed out waiting for operator handshake."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.Failed, ex.Message); + } + finally + { + await client.DisconnectAsync(); + } + } + + public async Task ConnectWithStoredDeviceTokenAsync(string gatewayUrl, CancellationToken cancellationToken = default) + { + var dataPath = Path.Combine( + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); + var identity = new DeviceIdentity(dataPath, _logger); + identity.Initialize(); + + if (string.IsNullOrWhiteSpace(identity.DeviceToken)) + return new GatewayOperatorConnectionResult(GatewayOperatorConnectionStatus.AuthFailed, "Gateway did not return a stored device token after bootstrap pairing."); + + return await ConnectAsync(gatewayUrl, identity.DeviceToken, tokenIsBootstrapToken: false, cancellationToken); + } +} + +public sealed class WslGatewayCliSharedGatewayTokenProvider : ISharedGatewayTokenProvider +{ + private static readonly Regex s_safeHexTokenRegex = new("^[0-9a-f]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private readonly IWslCommandRunner _wsl; + + public WslGatewayCliSharedGatewayTokenProvider(IWslCommandRunner wsl) + { + _wsl = wsl; + } + + public async Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var read = await _wsl.RunAsync( + ["-d", state.DistroName, "--", "bash", "-lc", "cat /var/lib/openclaw/gateway-token 2>/dev/null"], + cancellationToken); + var existing = (read.StandardOutput ?? string.Empty).Trim(); + if (read.Success && s_safeHexTokenRegex.IsMatch(existing)) + return new SharedGatewayTokenResult(true, existing, SharedGatewayTokenSource.PreservedFromWsl); + + var bytes = RandomNumberGenerator.GetBytes(32); + var token = Convert.ToHexString(bytes).ToLowerInvariant(); + return new SharedGatewayTokenResult(true, token, SharedGatewayTokenSource.Generated); + } +} + +public sealed class SettingsSharedGatewayTokenProvisioner : ISharedGatewayTokenProvisioner +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly ISharedGatewayTokenProvider _tokenProvider; + private readonly IGatewayConfigurationPreparer _gatewayConfigurationPreparer; + + public SettingsSharedGatewayTokenProvisioner( + ILocalGatewaySetupSettings settings, + ISharedGatewayTokenProvider tokenProvider, + IGatewayConfigurationPreparer gatewayConfigurationPreparer) + { + _settings = settings; + _tokenProvider = tokenProvider; + _gatewayConfigurationPreparer = gatewayConfigurationPreparer; + } + + public async Task ProvisionAsync(LocalGatewaySetupState state, LocalGatewaySetupOptions options, CancellationToken cancellationToken = default) + { + var minted = await _tokenProvider.MintAsync(state, cancellationToken); + if (!minted.Success || string.IsNullOrWhiteSpace(minted.Token)) + { + return new SharedGatewayProvisioningResult( + false, + ErrorCode: minted.ErrorCode ?? "shared_gateway_token_missing", + ErrorMessage: minted.ErrorMessage ?? "Gateway shared token could not be prepared."); + } + + var prepared = await _gatewayConfigurationPreparer.PrepareAsync(options, minted.Token!, cancellationToken); + if (!prepared.Success) + { + return new SharedGatewayProvisioningResult( + false, + minted.Token, + minted.Source, + prepared.ErrorCode ?? "gateway_config_prepare_failed", + prepared.ErrorMessage ?? "Failed to prepare OpenClaw Gateway configuration.", + prepared.Detail); + } + + _settings.Token = minted.Token!; + _settings.Save(); + return new SharedGatewayProvisioningResult(true, minted.Token, minted.Source); + } +} + +public sealed class SettingsBootstrapTokenProvisioner : IBootstrapTokenProvisioner +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly IBootstrapTokenProvider _bootstrapTokenProvider; + + public SettingsBootstrapTokenProvisioner(ILocalGatewaySetupSettings settings, IBootstrapTokenProvider bootstrapTokenProvider) + { + _settings = settings; + _bootstrapTokenProvider = bootstrapTokenProvider; + } + + public async Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(_settings.BootstrapToken)) + return new ProvisioningResult(true); + + var minted = await _bootstrapTokenProvider.MintAsync(state, cancellationToken); + if (!minted.Success || string.IsNullOrWhiteSpace(minted.BootstrapToken)) + { + return new ProvisioningResult( + false, + minted.ErrorCode ?? "bootstrap_token_missing", + minted.ErrorMessage ?? "Gateway did not return a bootstrap token."); + } + + _settings.BootstrapToken = minted.BootstrapToken; + _settings.Save(); + return new ProvisioningResult(true); + } +} + +public sealed class SettingsOperatorPairingService : IOperatorPairingService +{ + private readonly ILocalGatewaySetupSettings _settings; + private readonly IGatewayOperatorConnector? _connector; + private readonly IPendingDeviceApprover? _pendingApprover; + + public SettingsOperatorPairingService(SettingsManager settings, IGatewayOperatorConnector? connector = null, IPendingDeviceApprover? pendingApprover = null) + : this(new SettingsManagerLocalGatewaySetupSettings(settings), connector, pendingApprover) + { + } + + public SettingsOperatorPairingService(ILocalGatewaySetupSettings settings, IGatewayOperatorConnector? connector = null, IPendingDeviceApprover? pendingApprover = null) + { + _settings = settings; + _connector = connector; + _pendingApprover = pendingApprover; + } + + public async Task PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var credential = ResolveCredential(); + if (credential is null) + { + return new ProvisioningResult( + false, + "operator_credential_missing", + "A gateway token or bootstrap token is required before the tray can pair as an operator."); + } + + _settings.GatewayUrl = state.GatewayUrl; + _settings.UseSshTunnel = false; + _settings.Save(); + + if (_connector == null) + return new ProvisioningResult(true); + + var result = await _connector.ConnectAsync(state.GatewayUrl, credential.Value, credential.IsBootstrapToken, cancellationToken); + + // Fresh bootstrap-token connects keep the historical --latest approval path. + // Fresh standard local-loopback connects may request operator.admin, so they must approve + // the exact structured requestId returned by the failed connect. Missing/malformed requestId + // fails closed by skipping auto-approval and surfacing PairingRequired below. + if (result.Status == GatewayOperatorConnectionStatus.PairingRequired + && _pendingApprover != null + && LocalGatewayApprover.IsLocalGateway(state.GatewayUrl) + && (credential.IsBootstrapToken || result.PairingRequestId is not null)) + { + var approval = credential.IsBootstrapToken + ? await _pendingApprover.ApproveLatestAsync(state, cancellationToken) + : await _pendingApprover.ApproveExplicitAsync(state, result.PairingRequestId!, cancellationToken); + if (!approval.Success) + { + return new ProvisioningResult( + false, + approval.ErrorCode ?? "operator_pending_approval_failed", + approval.ErrorMessage ?? "Local gateway pending pairing approval failed."); + } + + result = await _connector.ConnectAsync(state.GatewayUrl, credential.Value, credential.IsBootstrapToken, cancellationToken); + } + + if (result.Status != GatewayOperatorConnectionStatus.Connected) + { + return result.Status switch + { + GatewayOperatorConnectionStatus.PairingRequired => new ProvisioningResult(false, "operator_pairing_required", result.ErrorMessage), + GatewayOperatorConnectionStatus.AuthFailed => new ProvisioningResult(false, "operator_auth_failed", result.ErrorMessage), + GatewayOperatorConnectionStatus.Timeout => new ProvisioningResult(false, "operator_pairing_timeout", result.ErrorMessage), + _ => new ProvisioningResult(false, "operator_pairing_failed", result.ErrorMessage ?? "Operator pairing failed.") + }; + } + + if (credential.IsBootstrapToken) + { + var reconnectResult = await _connector.ConnectWithStoredDeviceTokenAsync(state.GatewayUrl, cancellationToken); + if (reconnectResult.Status != GatewayOperatorConnectionStatus.Connected) + { + return reconnectResult.Status switch + { + GatewayOperatorConnectionStatus.PairingRequired => new ProvisioningResult(false, "operator_reconnect_pairing_required", reconnectResult.ErrorMessage), + GatewayOperatorConnectionStatus.AuthFailed => new ProvisioningResult(false, "operator_reconnect_auth_failed", reconnectResult.ErrorMessage), + GatewayOperatorConnectionStatus.Timeout => new ProvisioningResult(false, "operator_reconnect_timeout", reconnectResult.ErrorMessage), + _ => new ProvisioningResult(false, "operator_reconnect_failed", reconnectResult.ErrorMessage ?? "Operator reconnect with stored device token failed.") + }; + } + + _settings.Save(); + } + + return new ProvisioningResult(true); + } + + private ResolvedOperatorCredential? ResolveCredential() + { + if (!string.IsNullOrWhiteSpace(_settings.Token)) + return new ResolvedOperatorCredential(_settings.Token, false); + + if (!string.IsNullOrWhiteSpace(_settings.BootstrapToken)) + return new ResolvedOperatorCredential(_settings.BootstrapToken, true); + + return null; + } + + private sealed record ResolvedOperatorCredential(string Value, bool IsBootstrapToken); +} + +public sealed class WslGatewayCliBootstrapTokenProvider : IBootstrapTokenProvider +{ + private readonly IWslCommandRunner _wsl; + private readonly string _commandName; + + public WslGatewayCliBootstrapTokenProvider(IWslCommandRunner wsl, string commandName = "openclaw") + { + _wsl = wsl; + _commandName = commandName; + } + + public async Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) + { + var script = string.Join(" ", new[] + { + "set -euo pipefail;", + "if [ -f /var/lib/openclaw/gateway.env ]; then set -a; . /var/lib/openclaw/gateway.env; set +a; fi;", + "exec", + ShellQuote(_commandName), + "qr", + "--json", + "--url", + ShellQuote(state.GatewayUrl) + }); + var result = await _wsl.RunInDistroAsync(state.DistroName, ["bash", "-lc", script], cancellationToken); + if (!result.Success) + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_command_failed", ErrorMessage: "Gateway bootstrap-token command failed."); + + return ParseQrJson(result.StandardOutput); + } + + public static BootstrapTokenResult ParseQrJson(string output) + { + try + { + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (!TryGetString(root, "bootstrapToken", out var token) + && !TryGetString(root, "bootstrap_token", out token) + && !TryGetString(root, "token", out token)) + { + if (!TryGetString(root, "setupCode", out var setupCode) + && !TryGetString(root, "setup_code", out setupCode)) + { + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_missing", ErrorMessage: "Gateway QR output did not include a bootstrap token or setup code."); + } + + var decoded = SetupCodeDecoder.Decode(setupCode); + if (!decoded.Success) + return new BootstrapTokenResult(false, ErrorCode: "setup_code_invalid", ErrorMessage: decoded.Error ?? "Gateway setup code could not be decoded."); + + if (string.IsNullOrWhiteSpace(decoded.Token)) + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_missing", ErrorMessage: "Gateway setup code did not include a bootstrap token."); + + token = decoded.Token; + } + + return new BootstrapTokenResult(true, token, TryGetExpiry(root)); + } + catch (JsonException ex) + { + return new BootstrapTokenResult(false, ErrorCode: "bootstrap_token_json_invalid", ErrorMessage: ex.Message); + } + } + + private static bool TryGetString(JsonElement root, string propertyName, out string value) + { + if (root.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(property.GetString())) + { + value = property.GetString()!; + return true; + } + + value = string.Empty; + return false; + } + + private static DateTimeOffset? TryGetExpiry(JsonElement root) + { + foreach (var name in new[] { "expiresAtMs", "expires_at_ms" }) + { + if (root.TryGetProperty(name, out var property) && property.TryGetInt64(out var ms)) + return DateTimeOffset.FromUnixTimeMilliseconds(ms); + } + + foreach (var name in new[] { "expiresAt", "expires_at", "expires", "expiry" }) + { + if (root.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse(property.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) + return parsed; + } + + return null; + } + + private static string ShellQuote(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; +} + +public sealed record PendingDeviceApprovalResult(bool Success, string? ErrorCode = null, string? ErrorMessage = null); + +/// +/// Approves the most-recent pending device pairing request on a local-loopback gateway by +/// invoking openclaw devices approve --latest via the gateway CLI inside WSL. +/// Used during operator bootstrap pairing where the same user is both operator and approver. +/// +public interface IPendingDeviceApprover +{ + Task ApproveLatestAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default); + Task ApproveExplicitAsync(LocalGatewaySetupState state, string requestId, CancellationToken cancellationToken = default); +} + +public sealed class WslGatewayCliPendingDeviceApprover : IPendingDeviceApprover +{ + // Bug 1 part 5 (CLI v2026.5.3-1): Bostick-11 Round-3 verification (commit 05f7be0) + // proved the retry from part 4 IS firing but BOTH attempts of stage 1 still exit + // non-zero with EMPTY stderr. The same script, when invoked manually via + // `wsl -- bash -lc