Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
49bd13a265
chore(deps): bump actions/github-script
Bumps [actions/github-script](https://github.com/actions/github-script) from 373c709c69115d41ff229c7e5df9f8788daa9553 to 3a2844b7e9c422d3c10d287c895573f7108da1b3.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](373c709c69...3a2844b7e9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: 3a2844b7e9c422d3c10d287c895573f7108da1b3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 06:52:27 +00:00
247 changed files with 7191 additions and 44136 deletions

View File

@ -21,6 +21,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup-cli@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
version: v0.68.1

View File

@ -49,7 +49,7 @@
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
# - github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.20@sha256:9161f2415a3306a344aca34dd671ee69f122317e0a512e66dc64c94b9c508682
@ -131,7 +131,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -430,7 +430,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -1179,7 +1179,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -1342,7 +1342,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -1493,7 +1493,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -1542,7 +1542,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@ -1637,7 +1637,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
uses: github/gh-aw-actions/setup@239aec45b78c8799417efdd5bc6d8cc036629ec1 # v0.71.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}

2
.gitignore vendored
View File

@ -349,5 +349,3 @@ test_ws.py
# Local visual test output
visual-test-output/
.squad/

View File

@ -1,31 +0,0 @@
# 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`.

View File

@ -1,45 +0,0 @@
# 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`.

View File

@ -1,58 +0,0 @@
# 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`

View File

@ -1,21 +0,0 @@
# 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.

View File

@ -22,8 +22,5 @@ If a command fails:
Notes:
- If a build/test is blocked by an environmental lock (for example running executable locking output assemblies), stop/close the locking process and rerun.
- In linked git worktrees, set `OPENCLAW_REPO_ROOT` to the worktree path before running tests that discover the repository root, for example:
- `$env:OPENCLAW_REPO_ROOT='D:\github\moltbot-windows-hub.<worktree-name>'`
- Tray tests must isolate `SettingsManager` from real user settings. Do not use `new SettingsManager()` in tests unless the test intentionally reads `%APPDATA%\OpenClawTray\settings.json`; pass a temp settings directory or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts.
- Prefer isolated worktrees for PR validation. Use `git-wt` for worktree workflows; `wt.exe` may resolve to WorkTrunk instead of Windows Terminal, so use the full Windows Terminal path when explicitly launching Terminal.
- Do not claim completion without reporting validation results.

View File

@ -87,7 +87,7 @@ OpenClaw.Tray.Tests ──tests──▶ OpenClaw.Shared
|-----------|----------|---------|
| **Gateway Communication** | `OpenClaw.Shared/OpenClawGatewayClient.cs` | WebSocket client with protocol v3, reconnect/backoff logic |
| **Notification System** | `OpenClaw.Tray.WinUI/App.xaml.cs` | Event routing, toast notifications, classification |
| **WebView2 Integration** | `OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml.cs` | Embedded chat panel with lifecycle management |
| **WebView2 Integration** | `OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs` | Embedded chat panel with lifecycle management |
| **Tray Icon Management** | `OpenClaw.Tray.WinUI/Helpers/IconHelper.cs` | GDI handle management, dynamic icon generation |
| **Session Tracking** | `OpenClaw.Shared/OpenClawGatewayClient.cs` | Session state, activity tracking, polling |
| **Settings & Logging** | `OpenClaw.Tray.WinUI/Services/` | JSON settings persistence, file rotation logging |
@ -285,7 +285,7 @@ Notifications are classified using two strategies:
### WebView2 Lifecycle
The `ChatWindow` uses Microsoft Edge WebView2 for embedded web content:
The `WebChatWindow` uses Microsoft Edge WebView2 for embedded web content:
**Initialization:**
1. WebView2 control created in XAML
@ -299,7 +299,7 @@ Window Created → WebView2.EnsureCoreWebView2Async() → Navigate to Chat URL
```
**Key Design Decisions:**
- **Singleton pattern**: Only one chat window instance exists
- **Singleton pattern**: Only one WebChat window instance exists
- **Hidden instead of disposed**: Window is hidden when closed to preserve state
- **Separate user data folder**: Isolates cookies/storage from browser
- **Navigation guard**: Prevents accidental navigation away from chat
@ -425,8 +425,8 @@ dotnet test --filter "FullyQualifiedName~AgentActivityTests"
```
**Test Coverage:**
- ✅ **1182 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting, MCP, device identity, and WinNode client coverage
- ✅ **388 tests** in `OpenClaw.Tray.Tests` settings round-trip, deep link parsing, onboarding state, setup code decoder, gateway health/chat helpers, security validation, wizard step parsing, gateway discovery, localization validation
- ✅ **652 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting
- ✅ **262 tests** in `OpenClaw.Tray.Tests` — menu display, menu positioning, settings round-trip, deep link parsing, onboarding state, setup code decoder, security validation, wizard step parsing, localization validation
- ✅ All tests are pure unit tests (no network, no file system, no external dependencies)
See [tests/OpenClaw.Shared.Tests/README.md](tests/OpenClaw.Shared.Tests/README.md) for detailed test documentation.
@ -441,7 +441,7 @@ You can test the UI and basic functionality without a running gateway:
3. Enter a dummy gateway URL (e.g., `ws://localhost:18789`)
4. The app will show "Disconnected" status but you can:
- Test the tray menu structure
- Open the Settings page and configure preferences
- Open Settings dialog and configure preferences
- Test auto-start functionality
- View logs
@ -487,8 +487,8 @@ You can test the UI and basic functionality without a running gateway:
- Verify Windows toast notification appears (if enabled)
- Click toast → should open relevant UI
2. **Activity / notification history**:
- Right-click tray → **Activity Stream** or **Notification History**
2. **Notification History**:
- Right-click tray → **Notification History**
- Verify past notifications are listed
- Test filtering by category

View File

@ -98,13 +98,13 @@ Modern Windows 11-style system tray companion that connects to your local OpenCl
- 🌐 **Web Chat** - Embedded chat window with WebView2
- 📊 **Live Status** - Real-time sessions, channels, and usage display
- 🧭 **Command Center** - Dense gateway, channel, usage, node, pairing, and allowlist diagnostics from one window
- ⚡ **Activity Stream** - Command Center page for live session, usage, node, and notification events
- ⚡ **Activity Stream** - Dedicated flyout for live session, usage, node, and notification events
- 🔔 **Toast Notifications** - Clickable Windows notifications with [smart categorization](docs/NOTIFICATION_CATEGORIZATION.md)
- 📡 **Channel Control** - Start/stop Telegram & WhatsApp from the menu
- 🖥️ **Node Observability** - Node inventory with online/offline state and copyable summary
- ⏱ **Cron Jobs** - Quick access to scheduled tasks
- 🚀 **Auto-start** - Launch with Windows
- ⚙️ **Settings** - Full configuration page
- ⚙️ **Settings** - Full configuration dialog
- 🎯 **First-run onboarding** — 6-screen setup wizard (connection, permissions, chat, configuration)
#### Quick Send scope requirement
@ -123,7 +123,7 @@ If Quick Send fails with `pairing required` / `NOT_PAIRED`, that is a **device a
### Menu Sections
- **Status** - Gateway connection status with click-to-view details
- **Command Center** - Hub with diagnostics, channel health, usage, sessions, nodes, and copyable repair commands
- **Command Center** - Status detail window with diagnostics, channel health, usage, sessions, nodes, and copyable repair commands
- **Sessions** - Active agent sessions with preview and per-session controls
- **Usage** - Provider/cost summary with quick jump to activity details
- **Channels** - Telegram/WhatsApp status with toggle control
@ -177,13 +177,10 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t
| **Canvas** | `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.push`, `canvas.a2ui.pushJSONL`, `canvas.a2ui.reset` | Display and control a WebView2 window |
| **Screen** | `screen.snapshot`, `screen.record` | Capture screenshots and fixed-duration MP4 screen recordings |
| **Camera** | `camera.list`, `camera.snap`, `camera.clip` | Enumerate cameras and capture still photos or short video clips |
| **Speech-to-text** | `stt.transcribe` | Capture audio from the default microphone for a bounded duration and return transcribed text. Default-off; opt-in via Settings. When enabled, advertised to both gateway callers (subject to gateway allowlist) and local MCP clients (subject to bearer token). |
| **Location** | `location.get` | Return Windows geolocation when permission is available |
| **Device** | `device.info`, `device.status` | Return Windows host/app metadata and lightweight status |
| **Text-to-speech** | `tts.speak` | Speak text aloud through Windows speech synthesis, or ElevenLabs when configured |
Packaged installs declare camera, microphone, and location capabilities. Windows may ask for consent the first time a node capability uses one of those protected resources.
#### Node Setup
1. **Enable Node Mode** in Settings (enabled by default)
@ -302,12 +299,12 @@ OpenClaw registers the `openclaw://` URL scheme for automation and integration:
| Link | Description |
|------|-------------|
| `openclaw://settings` | Open the Settings page |
| `openclaw://settings` | Open Settings dialog |
| `openclaw://setup` | Open Setup Wizard |
| `openclaw://chat` | Open the Chat page |
| `openclaw://chat` | Open Web Chat window |
| `openclaw://commandcenter` | Open Command Center diagnostics |
| `openclaw://activity` | Open the Activity page |
| `openclaw://history` | Open the Activity page filtered to notification history |
| `openclaw://activity` | Open Activity Stream |
| `openclaw://history` | Open Notification History |
| `openclaw://dashboard` | Open Dashboard in browser |
| `openclaw://dashboard/sessions` | Open specific dashboard page |
| `openclaw://dashboard/channels` | Open Channels dashboard page |
@ -344,15 +341,15 @@ PowerToys Command Palette extension for quick OpenClaw access.
- **📡 Dashboard: Channels** - Open the channel configuration dashboard
- **🧩 Dashboard: Skills** - Open the skills dashboard
- **⏱️ Dashboard: Cron** - Open the scheduled jobs dashboard
- **💬 Web Chat** - Open the embedded Chat page
- **💬 Web Chat** - Open the embedded Web Chat window
- **📝 Quick Send** - Open the Quick Send dialog to compose a message
- **🧭 Setup Wizard** - Open pairing/setup
- **🧭 Command Center** - Open diagnostics and support actions
- **🔄 Run Health Check** - Refresh connection health
- **⬇️ Check for Updates** - Run a manual GitHub Releases update check
- **⚡ Activity Stream** - Open recent activity
- **📋 Notification History** - Open notification history in the Activity page
- **⚙️ Settings** - Open the OpenClaw Tray Settings page
- **📋 Notification History** - Open notification history
- **⚙️ Settings** - Open the OpenClaw Tray Settings dialog
- **📄 Open Log File / 📁 Logs / 🗂️ Config / 🧪 Diagnostics** - Open support files and folders
- **📋 Copy Support Context** - Copy redacted Command Center metadata
- **🧰 Copy Debug Bundle** - Copy combined support, port, capability, node, channel, and activity diagnostics

View File

@ -46,7 +46,7 @@ OpenClaw Tray uses WinUI `.resw` resource files for localization. Windows automa
5. **Do not translate resource key names** (the `name` attribute). Only translate `<value>` content.
6. **Submit a pull request** with just your new `Resources.resw` file. No code changes are needed — the build system and localization tests automatically discover new locale folders.
6. **Submit a pull request** with just your new `Resources.resw` file. No code changes are needed — the build system automatically discovers new locale folders.
## How It Works
@ -104,17 +104,15 @@ All onboarding wizard strings use the `Onboarding_` prefix:
## Validation
All resource files must have the **same set of keys**. Locale directories are discovered dynamically under `Strings/`, so adding a new `Strings/<locale>/Resources.resw` file automatically brings it under validation. You can verify counts with:
All 5 resource files must have the **same set of keys**. You can verify with:
```powershell
$locales = @("en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw")
$base = "src\OpenClaw.Tray.WinUI\Strings"
Get-ChildItem $base -Directory | ForEach-Object {
$loc = $_.Name
foreach ($loc in $locales) {
$count = (Select-String -Path "$base\$loc\Resources.resw" -Pattern '<data name="' | Measure-Object).Count
Write-Host "$loc : $count keys"
}
```
All locale counts should match. Missing or extra keys indicate an incomplete translation.
Non-English resource values must also follow the all-or-none rule enforced by `LocalizationValidationTests`: each key is either translated in every non-English locale, intentionally invariant in every non-English locale, or explicitly deferred with rationale. Partial translation, where only some non-English locales differ from `en-us`, is treated as a regression.

View File

@ -242,7 +242,7 @@ These are reasonable next steps but explicitly out of scope for the initial impl
| `src/OpenClaw.Shared/Mcp/McpHttpServer.cs` | `HttpListener`-based loopback HTTP transport. |
| `src/OpenClaw.Tray.WinUI/Services/NodeService.cs` | Owns the capability list. Hosts the MCP server when enabled. |
| `src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs` | In-memory settings model + load/save. Migrates legacy `McpOnlyMode`. |
| `src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml(.cs)` | Settings UI surface hosted by `HubWindow`. |
| `src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml(.cs)` | UI toggle, endpoint URL, and live status. |
| `src/OpenClaw.Tray.WinUI/App.xaml.cs` | Bootstraps `NodeService` based on the new mode matrix. |
| `tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs` | 9 unit tests for the bridge. |

View File

@ -241,7 +241,7 @@ In Settings, show read-only detected topology near gateway URL/tunnel settings:
### 4.5 Future Mission Control pages
Keep `HubWindow` as the Command Center host, with pages/sections for:
Keep StatusDetailWindow as the first Command Center, but plan for tabs/sections:
1. Overview
2. Gateway topology
@ -341,8 +341,8 @@ Files:
- `src/OpenClaw.Shared/SettingsData.cs` if optional declared kind is persisted
- `src/OpenClaw.Tray.WinUI/App.xaml.cs`
- `src/OpenClaw.Tray.WinUI/Services/SshTunnelService.cs`
- `src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml`
- `src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs`
- `src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml`
- `src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs`
- `tests/OpenClaw.Shared.Tests/ModelsTests.cs`
- `tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs` if settings change

View File

@ -54,7 +54,7 @@ Checks 5 Windows permissions using native APIs and registry:
Each permission shows its current status (Enabled/Disabled/Allowed/Denied) with an "Open Settings" button linking to the relevant `ms-settings:` URI.
### Chat
Embeds the gateway's web chat UI via WebView2, matching the post-setup `ChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization.
Embeds the gateway's web chat UI via WebView2, matching the post-setup `WebChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization.
On first load, a bootstrap message is auto-injected to kick off the gateway's first-run ritual (BOOTSTRAP.md). The message is safely encoded using `JsonSerializer.Serialize` to prevent XSS.
@ -75,7 +75,7 @@ The onboarding wizard follows these security practices:
## Localization
All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages are discovered from the `Strings/<locale>/Resources.resw` directories; the current locales are English, French, Dutch, Chinese Simplified, and Chinese Traditional.
All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages: English, French, Dutch, Chinese Simplified, Chinese Traditional.
Translations are AI-generated following the repo convention. Technical terms (Gateway, Token, Node Mode) are kept in English across all locales.

View File

@ -39,15 +39,15 @@ Open Command Palette (`Win+Alt+Space`), type **"OpenClaw"** — you should see t
| **📡 Dashboard: Channels** | Opens the channel configuration dashboard |
| **🧩 Dashboard: Skills** | Opens the skills dashboard |
| **⏱️ Dashboard: Cron** | Opens the scheduled jobs dashboard |
| **💬 Web Chat** | Opens the embedded Chat page in OpenClaw Tray |
| **💬 Web Chat** | Opens the embedded Web Chat window in OpenClaw Tray |
| **📝 Quick Send** | Opens the Quick Send dialog to compose a message |
| **🧭 Setup Wizard** | Opens QR, setup code, and manual gateway pairing |
| **🧭 Command Center** | Opens gateway, tunnel, node, browser, and support diagnostics |
| **🔄 Run Health Check** | Refreshes gateway or node connection health |
| **⬇️ Check for Updates** | Runs a manual GitHub Releases update check |
| **⚡ Activity Stream** | Opens recent tray activity and support bundle actions |
| **📋 Notification History** | Opens recent OpenClaw tray notifications in the Activity page |
| **⚙️ Settings** | Opens the OpenClaw Tray Settings page |
| **📋 Notification History** | Opens recent OpenClaw tray notifications |
| **⚙️ Settings** | Opens the OpenClaw Tray Settings dialog |
| **📄 Open Log File** | Opens the current OpenClaw Tray log |
| **📁 Open Logs Folder** | Opens the OpenClaw Tray logs folder |
| **🗂️ Open Config Folder** | Opens the OpenClaw Tray configuration folder |

View File

@ -63,7 +63,7 @@ On first launch, Molty opens a **6-screen onboarding wizard** that walks you thr
- **Camera** — for camera capture
- **Microphone** — for voice input
- **Screen Capture** — for screenshots
- **Location** — optional, for location-aware features; packaged installs declare this capability so Windows may prompt for location consent the first time it is used
- **Location** — optional, for location-aware features
Each permission shows its current status. Click **Open Settings** next to any permission to jump directly to the relevant Windows Settings page.
@ -95,14 +95,14 @@ OpenClaw Tray responds to `openclaw://` deep links, which can be invoked from a
| `openclaw://dashboard/channels` | Open the channels dashboard page |
| `openclaw://dashboard/skills` | Open the skills dashboard page |
| `openclaw://dashboard/cron` | Open the cron dashboard page |
| `openclaw://chat` | Open the embedded Chat page |
| `openclaw://chat` | Open the embedded Web Chat window |
| `openclaw://send` | Open the Quick Send dialog |
| `openclaw://send?message=Hello` | Open Quick Send with pre-filled text |
| `openclaw://settings` | Open the Settings page |
| `openclaw://settings` | Open the Settings dialog |
| `openclaw://setup` | Open the Setup Wizard |
| `openclaw://commandcenter` | Open Command Center diagnostics |
| `openclaw://activity` | Open the Activity page |
| `openclaw://history` | Open the Activity page filtered to notification history |
| `openclaw://activity` | Open the Activity Stream |
| `openclaw://history` | Open Notification History |
| `openclaw://healthcheck` | Run a manual health check |
| `openclaw://check-updates` | Run a manual update check |
| `openclaw://logs` | Open the current tray log file |

View File

@ -1,17 +1,17 @@
# Test Coverage Summary
**1570 tests total** (1182 shared + 388 tray) — all passing ✅
**914 tests total** (652 shared + 262 tray) — all passing ✅
| Metric | Value |
|--------|-------|
| Total Tests | 1570 |
| Passing | 1570 (100%) |
| Total Tests | 914 |
| Passing | 914 (100%) |
| Failing | 0 |
| Framework | xUnit 2.9.3 / .NET 10.0 |
## Test Projects
### OpenClaw.Shared.Tests — 1182 tests
### OpenClaw.Shared.Tests — 652 tests
#### ModelsTests
- **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting
@ -71,7 +71,7 @@
---
### OpenClaw.Tray.Tests — 388 tests
### OpenClaw.Tray.Tests — 262 tests
#### Core Tray Tests
@ -83,14 +83,14 @@
#### Onboarding Tests
- **OnboardingStateTests** (19) — Page order, mode logic, route changes, wizard state persistence, completion, disposal
- **WizardStepPropsTests** (4) — Enum values, record defaults, callback verification
- **GatewayChatHelperTests** (11) — URL scheme conversion, token encoding, localhost checks, session keys
- **LocalGatewayApproverTests** (13) — IsLocalGateway for localhost/remote/edge cases
- **SetupCodeDecoderTests** (14) — Base64url decode, size limits, JSON validation, URL/token extraction
- **GatewayHealthCheckTests** (6) — Health URI building, scheme conversion, port preservation
- **SecurityValidationTests** (16) — Locale whitelist, port range, path traversal, URI scheme validation
- **WizardStepParsingTests** (12) — JSON step parsing, options, completion, sensitive fields
- **GatewayDiscoveryServiceTests** — mDNS host selection and connection URL regression coverage
- **LocalizationValidationTests** — locale key parity, onboarding key presence, duplicate detection, and all-or-none translation consistency
- **LocalizationValidationTests** (6) — 5-locale key parity, onboarding key presence, no duplicates
---
@ -124,6 +124,6 @@ dotnet test --logger "console;verbosity=detailed"
---
**Last Updated**: 2026-05-04
**Last Updated**: 2026-04-26
**Framework**: xUnit 2.9.3 / .NET 10.0
**Status**: ✅ 1570 tests passing
**Status**: ✅ 914 tests passing

View File

@ -61,7 +61,6 @@ These features need the gateway to send `node.invoke` commands:
| `location.get` | Get Windows location | Uses Windows location permission/settings |
| `device.info` / `device.status` | Device metadata/status | Returns host/app/locale plus battery/storage/network/uptime payloads |
| `browser.proxy` | Proxy browser-control host requests | Requires Browser proxy bridge enabled, a compatible browser-control host listening on gateway port + 2, and matching browser-control auth |
| `stt.transcribe` | Speech-to-text from default microphone | Default-off; bounded `maxDurationMs` ≤ 30000; concatenates phrases until duration elapses; requires explicit gateway allowlist |
| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in `gateway.nodes.allowCommands` |
## Capabilities Advertised
@ -113,40 +112,6 @@ When the node connects, it advertises these capabilities:
- If you see "Camera access blocked", enable camera access for desktop apps in Windows Privacy settings
- Packaged MSIX builds will show the system consent prompt automatically
### `stt.transcribe` returns "Speech recognition failed" or "Internal Speech Error"
- Open Windows Settings → Privacy & security → Speech (`ms-settings:privacy-speech`)
- Turn **Online speech recognition** = On. The Windows speech recognizer's default dictation grammar often fails without it, and Windows surfaces an unmapped HRESULT as "Internal Speech Error"
- Open Windows Settings → Time & language → Language & region (`ms-settings:regionlanguage`), select your display language → Language options, and confirm **Speech** appears under Installed features (install it if not, ~50 MB; reboot or sign out/in afterward)
- Verify the recognizer end-to-end with `ms-settings:speech` → "Microphone" → **Get started** before re-trying `stt.transcribe`
### `stt.transcribe` returns "Microphone permission denied"
- Open Windows Settings → Privacy & security → Microphone
- Ensure **Microphone access** (top-level toggle) is on
- For **unpackaged** tray builds (the default `.\build.ps1` output): ensure **Let desktop apps access your microphone** is on. The tray exe will **not** appear as its own row — desktop-app access is granted as a group, not per-app
- For **packaged MSIX** tray builds: the tray appears as its own entry under "Let apps access your microphone" and must be individually enabled (the OS shows a consent prompt on first use)
- After changing permissions, re-pair the node so the gateway picks up the new advertised command
### `stt.transcribe` returns "Language pack 'X' is not installed"
- Open Windows Settings → Time & language → Language & region
- Add the requested display language and ensure the **Speech** optional feature is installed
- Restart the tray after installing the speech pack
### Manual STT validation
1. Enable Node Mode in Settings.
2. Enable **Speech-to-text (microphone)** in Settings → Node mode.
3. Append `stt.transcribe` to your existing gateway allowlist (do **not** copy a literal `...` — substitute the commands you already allow). For example, starting from the recommended Windows safe companion list:
```bash
openclaw config set gateway.nodes.allowCommands '["canvas.present","canvas.hide","canvas.navigate","canvas.eval","canvas.snapshot","canvas.a2ui.push","canvas.a2ui.pushJSONL","canvas.a2ui.reset","camera.list","location.get","screen.snapshot","device.info","device.status","system.execApprovals.get","system.execApprovals.set","stt.transcribe"]'
openclaw gateway restart
```
4. Re-pair or re-approve the node so the gateway refreshes its command snapshot.
5. Invoke and speak a short phrase:
```bash
openclaw nodes invoke --node <id> --command stt.transcribe \
--params '{"maxDurationMs":5000,"language":"en-US"}'
```
6. The Windows microphone OS indicator should appear during recognition. Confirm a `transcribed:true` payload returns the text.
## Remaining Work (Roadmap)
1. ~~**system.run + exec approvals**~~ ✅ Implemented

View File

@ -262,7 +262,7 @@ fake `WindowsNodeClient`).
| Per-surface theme scope | `Hosting/SurfaceHost.cs ApplyThemeToScope` | multi-surface tab views don't bleed themes |
| `IA2UITelemetry` seam | `Telemetry/IA2UITelemetry.cs` | structured events instead of log scraping |
| Single-handler `Func` events on `CanvasCapability` | reviewed in commit `5b9c468` | catches accidental multi-subscribe instead of silent `Delegate.Combine` |
| MCP bearer token in Settings UI | `SettingsPage.xaml.cs` | quality-of-life for MCP setup, kept out of action payloads |
| MCP bearer token in Settings UI | `SettingsWindow.xaml.cs` | quality-of-life for MCP setup, kept out of action payloads |
---

View File

@ -336,7 +336,7 @@ Recommended gateway defaults:
| Command bucket | Windows default? | Reason |
|----------------|------------------|--------|
| Safe declared companion commands: `canvas.*`, `camera.list`, `location.get`, `screen.snapshot`, `device.info`, `device.status` | Yes | Matches macOS parity and only applies when declared by the node |
| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, `stt.transcribe`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` |
| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` |
| Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior |
Until the gateway expands Windows safe defaults, the practical local solution is:
@ -364,7 +364,6 @@ Privacy-sensitive commands should stay out of the default safe list and should o
camera.snap
camera.clip
screen.record
stt.transcribe
```
After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyCommands`, re-approve or re-pair the Windows node. Approved device records may keep a snapshot of the commands that were visible at approval time, so a gateway restart alone may not refresh existing approvals.
@ -425,7 +424,6 @@ Proposal:
- `camera.snap`
- `camera.clip`
- `screen.record`
- `stt.transcribe`
- write commands such as `contacts.add`, `calendar.add`, etc.
This does not grant capabilities to headless Windows hosts by itself. A command still has to pass both gates: the node must declare it in `commands`, and the gateway policy must allow it. Headless Windows node hosts that only declare `system.run` / `system.which` remain exec-only.
@ -443,7 +441,7 @@ When shipping the Windows node, README/wiki should tell users:
> ```
> Then re-pair the node (`openclaw devices reject <old-id>` + re-approve).
>
> Add `camera.snap`, `camera.clip`, `screen.record`, and `stt.transcribe` only when you explicitly want to allow privacy-sensitive camera, screen, or microphone capture.
> Add `camera.snap`, `camera.clip`, and `screen.record` only when you explicitly want to allow privacy-sensitive camera or screen capture.
>
> The Windows tray Command Center (`openclaw://commandcenter`) surfaces these policy problems directly: it separates safe companion allowlist fixes from privacy-sensitive opt-ins and provides copyable repair text for safe fixes or pending pairing approval.

View File

@ -1,369 +0,0 @@
# 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 <gen> --location <path>
--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 **<https://aka.ms/wsllogs>**. 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 <https://aka.ms/wsllogs>; 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.1Q2.5)
4. **Lifecycle** — User-systemd + tray keepalive. Linger acceptable.
`wsl --terminate OpenClawGateway` for repair. **Never** global
`wsl --shutdown`. (Q3.1Q3.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.

View File

@ -1,384 +0,0 @@
# 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 <https://aka.ms/wsllogs>.
- 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 <https://aka.ms/wsllogs>;
no internal log scraping is attempted.
## End-state architecture
```mermaid
flowchart LR
subgraph Windows["Windows user session"]
Tray["OpenClaw Tray app"]
Identity["%APPDATA%\OpenClawTray\<br/>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<br/>systemd=true<br/>automount=false<br/>interop=false<br/>appendWindowsPath=false<br/>default user=openclaw"]
DistroConf["/etc/wsl-distribution.conf<br/>shortcut=false<br/>terminal=false"]
Systemd["systemd"]
Installer["public installer<br/>install-cli.sh<br/>--prefix /opt/openclaw"]
GatewaySvc["openclaw gateway<br/>bind=loopback :18789"]
State["/var/lib/openclaw"]
end
Tray --> Engine
Engine -->|"wsl --install Ubuntu-24.04 --name OpenClawGateway --location <appdata>\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 <gen> --location <path> --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
<https://aka.ms/wsllogs> 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/<run-id>/`.
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 <path> --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.

View File

@ -27,7 +27,6 @@
<Project Path="tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj" />
<Project Path="tests/OpenClaw.WinNode.Cli.Tests/OpenClaw.WinNode.Cli.Tests.csproj" />
<Project Path="tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj" />
<Project Path="tests/OpenClawTray.FunctionalUI.Tests/OpenClawTray.FunctionalUI.Tests.csproj" />
<Project Path="tests/OpenClaw.Tray.IntegrationTests/OpenClaw.Tray.IntegrationTests.csproj" />
<Project Path="tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj">
<Platform Solution="*|Any CPU" Project="x64" />

View File

@ -1,326 +0,0 @@
<#
.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=<path>
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 ""

View File

@ -1,388 +0,0 @@
# 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 <name>, 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
}

View File

@ -1,941 +0,0 @@
<#
.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 <path> --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 "<invalid-uri>"
}
}
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<redacted>$2'
$r = $r -replace '(?i)((?:bootstrap|device|gateway|auth)[_-]?token\s*[:=]\s*)[^\s,"''}]+', '$1<redacted>'
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<redacted>'
$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<redacted>$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

View File

@ -1,70 +0,0 @@
using System;
using System.Collections.Generic;
namespace OpenClaw.Shared.Audio;
/// <summary>Result of a speech-to-text transcription segment.</summary>
public sealed class TranscriptionResult
{
public string Text { get; init; } = "";
public TimeSpan Start { get; init; }
public TimeSpan End { get; init; }
public string Language { get; init; } = "en";
}
/// <summary>
/// Aggregated result of a single silence-bounded utterance — i.e. all the
/// Whisper segments produced from one VAD-bounded speech burst, combined.
/// Consumers that need "what the user said" (chat submission, stt.listen)
/// should listen for this event instead of per-segment TranscriptionResult
/// to avoid sending partial text.
/// </summary>
public sealed class UtteranceResult
{
/// <summary>Concatenated text across all segments, single-spaced.</summary>
public string Text { get; init; } = "";
/// <summary>Language detected on the first segment, or null if no segments.</summary>
public string? Language { get; init; }
/// <summary>Start of the first segment relative to capture start.</summary>
public TimeSpan Start { get; init; }
/// <summary>End of the last segment relative to capture start.</summary>
public TimeSpan End { get; init; }
/// <summary>Immutable snapshot of the per-segment results.</summary>
public IReadOnlyList<TranscriptionResult> Segments { get; init; } = Array.Empty<TranscriptionResult>();
}
/// <summary>Voice-activity detection event.</summary>
public sealed class VadEvent
{
public bool IsSpeaking { get; init; }
public float Probability { get; init; }
}
/// <summary>Configuration for the audio pipeline.</summary>
public sealed class AudioPipelineOptions
{
/// <summary>Path to the Whisper GGML model file.</summary>
public string ModelPath { get; init; } = "";
/// <summary>Language code for STT (e.g. "en", "auto").</summary>
public string Language { get; init; } = "auto";
/// <summary>Seconds of silence before a speech segment is finalized.</summary>
public float SilenceTimeoutSeconds { get; init; } = 1.5f;
/// <summary>Optional audio device ID. Null = system default microphone.</summary>
public string? DeviceId { get; init; }
/// <summary>VAD probability threshold (0.01.0). Audio above this is considered speech.</summary>
public float VadThreshold { get; init; } = 0.3f;
}
/// <summary>Pipeline state.</summary>
public enum AudioPipelineState
{
Stopped,
Starting,
Listening,
Processing,
Error
}

View File

@ -1,390 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
/// <summary>
/// Manages downloads and on-disk lifecycle for Piper TTS voices.
///
/// Each "voice" is a sherpa-onnx pre-packaged tarball that contains
/// everything needed for offline synthesis — the .onnx model, the
/// tokens.txt phoneme map, and the language-specific espeak-ng-data.
/// We use the sherpa-onnx repackaged distribution rather than the raw
/// HuggingFace Piper voices because the latter requires the user (or
/// us) to ship espeak-ng-data separately (~80 MB shared across voices).
///
/// Storage layout under the tray's data directory:
/// models/piper/&lt;voice-id&gt;/
/// &lt;voice-id&gt;.onnx
/// tokens.txt
/// espeak-ng-data/...
///
/// Each voice is ~50 MB compressed, ~80 MB extracted (with espeak data).
///
/// **TODO (pre-GA):** SHA-256 verification of downloaded tarballs before
/// extraction (Audio_FollowUps.md §2). The current implementation trusts
/// HTTPS + the system trust chain only.
/// </summary>
public sealed class PiperVoiceManager
{
private readonly string _voicesDirectory;
private readonly IOpenClawLogger _logger;
// Per-voice single-flight gate: prevents racing the same voice download
// from two callers (e.g. UI and a programmatic caller). Static so two
// PiperVoiceManager instances over the same data directory still
// coalesce against the same in-flight task.
private static readonly ConcurrentDictionary<string, Lazy<Task>> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Curated catalog of Piper voices we offer in the UI. Each entry is
/// a sherpa-onnx pre-packaged tarball from the project's GitHub
/// releases. To add a voice: pick its key from
/// https://github.com/k2-fsa/sherpa-onnx/releases/tag/tts-models,
/// download the tarball, compute its SHA-256, and pin it below.
/// Sizes shown in the UI are approximate compressed sizes.
///
/// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against
/// the sherpa-onnx GitHub release on 2026-05-05. Downloads with a
/// different hash are rejected and the partial tarball is deleted.
/// Before any public release: re-verify each hash from an independent
/// source and document provenance in Audio_FollowUps.md §2.
/// </summary>
public static readonly PiperVoiceInfo[] AvailableVoices =
[
new("en_US-amy-low", "English (US) — Amy (low quality, fast)", "en-US",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-amy-low.tar.bz2",
"c70f5284a09a7fd4ed203b39b2ff51cac1432b422b852eb647b481dade3cf639"),
new("en_US-libritts-high","English (US) — LibriTTS (high quality)", "en-US",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-libritts-high.tar.bz2",
"d9d35056703fd38ed38e95c202a50f603fefdc8a92a7b6332c4f1a41616eac72"),
new("en_GB-alan-low", "English (GB) — Alan (low quality, fast)", "en-GB",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_GB-alan-low.tar.bz2",
"1308e730b7a12c3b64b669d65daa0138fcb83b1a086edee92fa9fa68cb0290dd"),
new("fr_FR-siwis-low", "Français (FR) — Siwis (low quality, fast)","fr-FR",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-low.tar.bz2",
"3d69170c160c8375c4123901a72a3845222b39456d39ab74f5bbd7310952b5af"),
new("de_DE-thorsten-low","Deutsch (DE) — Thorsten (low quality)", "de-DE",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-low.tar.bz2",
"41fab35910fdcec4696b031951d8fd6c262e594cf77b35e1068fadbeb5a091a6"),
new("zh_CN-huayan-medium","中文 (CN) — Huayan (medium quality)", "zh-CN",
"https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-zh_CN-huayan-medium.tar.bz2",
"dbdfec42b91d9cee31cce9ff4b3e9c305eb6fbf60546d071f7e46273554cce6b"),
];
public PiperVoiceManager(string dataDirectory, IOpenClawLogger logger)
{
_voicesDirectory = Path.Combine(dataDirectory, "models", "piper");
_logger = logger;
Directory.CreateDirectory(_voicesDirectory);
}
/// <summary>Root directory where this voice's files live (created lazily).</summary>
public string GetVoiceDirectory(string voiceId)
{
var info = FindVoice(voiceId);
return Path.Combine(_voicesDirectory, info.VoiceId);
}
/// <summary>Path to the .onnx model file for a downloaded voice.</summary>
public string GetModelPath(string voiceId)
{
var dir = GetVoiceDirectory(voiceId);
// sherpa-onnx tarballs put files at the root of the voice dir; the
// model file is named after the voice id.
return Path.Combine(dir, $"{voiceId}.onnx");
}
/// <summary>Path to tokens.txt (phoneme map).</summary>
public string GetTokensPath(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "tokens.txt");
/// <summary>Path to the espeak-ng-data directory bundled with this voice.</summary>
public string GetEspeakDataDir(string voiceId) => Path.Combine(GetVoiceDirectory(voiceId), "espeak-ng-data");
/// <summary>True when all three files are present on disk.</summary>
public bool IsVoiceDownloaded(string voiceId)
{
try
{
return File.Exists(GetModelPath(voiceId))
&& File.Exists(GetTokensPath(voiceId))
&& Directory.Exists(GetEspeakDataDir(voiceId));
}
catch
{
// FindVoice throws on unknown voiceId — treat as not-downloaded.
return false;
}
}
/// <summary>
/// Download and extract a Piper voice from the sherpa-onnx release.
/// Reports progress as bytes downloaded / total bytes (extraction
/// progress is not reported separately).
/// Per-voice single-flight: concurrent calls for the same voice await
/// the in-flight download instead of racing on the same temp tarball.
/// </summary>
public Task DownloadVoiceAsync(
string voiceId,
IProgress<(long downloaded, long total)>? progress = null,
CancellationToken cancellationToken = default)
{
var info = FindVoice(voiceId);
if (IsVoiceDownloaded(info.VoiceId))
{
_logger.Info($"Piper voice '{info.VoiceId}' already downloaded");
return Task.CompletedTask;
}
// Preflight: bail out before downloading 50-150 MB if the OS isn't
// capable of extracting the .tar.bz2 we'd produce. tar.exe ships with
// Windows 10 1803+; older systems would fail at the extract step
// after a long, wasted download.
EnsureExtractorAvailable();
var key = info.VoiceId;
return SingleFlightDownload.RunAsync(
InFlightDownloads,
key,
token => DownloadVoiceCoreAsync(info, progress, token),
cancellationToken);
}
private async Task DownloadVoiceCoreAsync(
PiperVoiceInfo info,
IProgress<(long downloaded, long total)>? progress,
CancellationToken cancellationToken)
{
// SECURITY: refuse to install any voice that doesn't have a pinned
// hash. See Audio_FollowUps.md §2.
if (string.IsNullOrWhiteSpace(info.Sha256))
{
throw new InvalidOperationException(
$"Piper voice '{info.VoiceId}' has no pinned SHA-256; refusing to download. " +
"Add a verified hash to AvailableVoices before enabling this voice.");
}
var voiceDir = Path.Combine(_voicesDirectory, info.VoiceId);
Directory.CreateDirectory(voiceDir);
var tarballPath = Path.Combine(voiceDir, $"{info.VoiceId}.tar.bz2.tmp");
_logger.Info($"Downloading Piper voice '{info.VoiceId}' from {info.DownloadUrl}");
try
{
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(10);
using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? 0;
using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
using (var fileStream = new FileStream(tarballPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920))
{
var buffer = new byte[81920];
long downloaded = 0;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
downloaded += bytesRead;
progress?.Report((downloaded, totalBytes));
}
}
// SECURITY: verify SHA-256 of the downloaded tarball BEFORE we
// hand it to the extractor. tar reads file contents to disk; an
// attacker-controlled tarball could plant arbitrary files (path
// traversal aside, the .onnx model itself is loaded into the
// process). Fail closed on mismatch — partial dir cleanup runs
// in the catch block below.
await VerifyHashAsync(tarballPath, info.Sha256, info.VoiceId, cancellationToken);
_logger.Info($"Extracting Piper voice '{info.VoiceId}'");
ExtractTarBz2(tarballPath, voiceDir, cancellationToken);
// Verify the extraction produced the files we expect; if not,
// tear the half-extracted dir down so a retry starts clean.
if (!IsVoiceDownloaded(info.VoiceId))
{
throw new InvalidOperationException(
$"Extraction of Piper voice '{info.VoiceId}' did not produce the expected layout.");
}
_logger.Info($"Piper voice '{info.VoiceId}' verified and ready at {voiceDir}");
}
catch
{
// Best-effort cleanup — leaves the user able to retry without
// leftover partial files.
try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ }
try { if (Directory.Exists(voiceDir) && !IsVoiceDownloaded(info.VoiceId)) Directory.Delete(voiceDir, recursive: true); } catch { /* swallow */ }
throw;
}
finally
{
try { if (File.Exists(tarballPath)) File.Delete(tarballPath); } catch { /* swallow */ }
}
}
/// <summary>
/// Compute SHA-256 of <paramref name="filePath"/> and compare to
/// <paramref name="expectedHex"/>. Throws on mismatch (caller is
/// expected to delete the file). Does not echo the actual hash to
/// avoid handing attackers a confirmation oracle.
/// </summary>
private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken)
{
using var sha = System.Security.Cryptography.SHA256.Create();
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
var actual = await sha.ComputeHashAsync(stream, cancellationToken);
var actualHex = Convert.ToHexString(actual).ToLowerInvariant();
if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase))
{
throw new System.Security.SecurityException(
$"Piper voice '{assetName}' failed integrity check. The downloaded tarball does not match the pinned SHA-256.");
}
}
/// <summary>Delete a downloaded voice directory.</summary>
public bool DeleteVoice(string voiceId)
{
var info = FindVoice(voiceId);
var dir = Path.Combine(_voicesDirectory, info.VoiceId);
if (!Directory.Exists(dir)) return false;
Directory.Delete(dir, recursive: true);
_logger.Info($"Deleted Piper voice '{info.VoiceId}'");
return true;
}
/// <summary>Total disk usage of a downloaded voice, or 0 if not downloaded.</summary>
public long GetVoiceSize(string voiceId)
{
var info = FindVoice(voiceId);
var dir = Path.Combine(_voicesDirectory, info.VoiceId);
if (!Directory.Exists(dir)) return 0;
long total = 0;
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
try { total += new FileInfo(f).Length; } catch { /* skip */ }
}
return total;
}
/// <summary>
/// Probe the bundled OS tar.exe used by <see cref="ExtractTarBz2"/>.
/// Throws a clear error before any network I/O happens so users on
/// downlevel Windows aren't left with a half-downloaded tarball.
/// </summary>
private static void EnsureExtractorAvailable()
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "tar",
ArgumentList = { "--version" },
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc == null)
{
throw new InvalidOperationException("tar.exe not found on PATH.");
}
proc.WaitForExit(2000);
if (!proc.HasExited)
{
try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ }
throw new InvalidOperationException("tar.exe didn't respond to --version.");
}
if (proc.ExitCode != 0)
{
throw new InvalidOperationException($"tar.exe --version returned exit code {proc.ExitCode}.");
}
}
catch (System.ComponentModel.Win32Exception ex)
{
throw new InvalidOperationException(
"Piper voices need bundled tar (Windows 10 1803+). " +
"Your system doesn't have tar on PATH; please update Windows or install a tar utility.", ex);
}
}
/// <summary>
/// Extract a .tar.bz2 archive in-place. We use SharpCompress (already a
/// transitive dependency via PiperSharp's ecosystem, but explicit here)
/// so we don't need to shell out to tar.exe.
/// </summary>
private static void ExtractTarBz2(string archivePath, string destinationDir, CancellationToken cancellationToken)
{
// SharpCompress isn't a direct dep of OpenClaw.Shared today; we
// intentionally use the BCL .tar reader on top of a bzip2 stream
// from a small inline implementation. Keeping the dep surface small
// matters in this assembly because everything here is also referenced
// from OpenClaw.Cli.
//
// .NET 7+ ships System.Formats.Tar; bzip2 is not in the BCL, so we
// bring it in via a thin wrapper. For now the simplest-correct path
// is to call out to the OS-bundled `tar` (Win10 1803+ ships it),
// which transparently handles bz2.
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "tar",
ArgumentList = { "-xjf", archivePath, "-C", destinationDir, "--strip-components=1" },
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
};
using var proc = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("Could not start tar to extract Piper voice");
// Cancellation: kill the tar process if requested.
using var reg = cancellationToken.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { /* swallow */ } });
proc.WaitForExit();
if (proc.ExitCode != 0)
{
var err = proc.StandardError.ReadToEnd();
throw new InvalidOperationException($"tar extraction failed (exit {proc.ExitCode}): {err}");
}
}
private static PiperVoiceInfo FindVoice(string voiceId)
{
foreach (var v in AvailableVoices)
{
if (string.Equals(v.VoiceId, voiceId, StringComparison.OrdinalIgnoreCase))
return v;
}
var available = string.Join(", ", AvailableVoicesIds());
throw new ArgumentException($"Unknown Piper voice: '{voiceId}'. Available: {available}");
}
private static IEnumerable<string> AvailableVoicesIds()
{
foreach (var v in AvailableVoices) yield return v.VoiceId;
}
}
/// <summary>Metadata about a Piper voice variant.</summary>
/// <param name="VoiceId">Short id, e.g. "en_US-amy-low".</param>
/// <param name="DisplayName">Human-readable label for UI.</param>
/// <param name="LanguageTag">BCP-47 tag.</param>
/// <param name="DownloadUrl">HTTPS URL of the .tar.bz2.</param>
/// <param name="Sha256">Pinned lowercase hex SHA-256 of the downloaded
/// tarball. MUST be set; downloads are refused when null. See the catalog
/// for the "verified on" date — these need re-verification before any
/// public release (see Audio_FollowUps.md §2).</param>
public sealed record PiperVoiceInfo(
string VoiceId,
string DisplayName,
string LanguageTag,
string DownloadUrl,
string? Sha256);

View File

@ -1,28 +0,0 @@
namespace OpenClaw.Shared.Audio;
/// <summary>
/// Pinned descriptor for the Silero VAD ONNX model that the audio
/// pipeline auto-downloads on first use.
///
/// SECURITY — same fail-closed verification discipline as
/// <see cref="WhisperModelManager"/> and <see cref="PiperVoiceManager"/>:
/// the runtime checks the downloaded file's SHA-256 against
/// <see cref="Sha256"/> before installing it. The pinned hash here was
/// captured against the upstream raw URL on 2026-05-05; re-verify from
/// an independent source before any public release (Audio_FollowUps.md
/// §2 captures the broader signed-manifest plan).
/// </summary>
public static class SileroVadModelManifest
{
public const string FileName = "silero_vad.onnx";
public const string DownloadUrl =
"https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx";
/// <summary>Lowercase hex SHA-256 of the canonical upstream file.</summary>
public const string Sha256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3";
/// <summary>Approximate compressed size in bytes (UI hint; actual size
/// is asserted via the SHA-256 check).</summary>
public const long ApproximateSizeBytes = 2_327_524;
}

View File

@ -1,52 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
internal static class SingleFlightDownload
{
public static Task RunAsync(
ConcurrentDictionary<string, Lazy<Task>> inFlight,
string key,
Func<CancellationToken, Task> startDownload,
CancellationToken waitCancellationToken = default)
{
var candidate = new Lazy<Task>(() =>
{
try
{
return startDownload(CancellationToken.None)
?? Task.FromException(new InvalidOperationException("Download factory returned null."));
}
catch (Exception ex)
{
return Task.FromException(ex);
}
}, LazyThreadSafetyMode.ExecutionAndPublication);
var lazy = inFlight.GetOrAdd(key, candidate);
Task task;
try
{
task = lazy.Value;
}
catch
{
inFlight.TryRemove(new KeyValuePair<string, Lazy<Task>>(key, lazy));
throw;
}
_ = task.ContinueWith(
_ => inFlight.TryRemove(new KeyValuePair<string, Lazy<Task>>(key, lazy)),
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return waitCancellationToken.CanBeCanceled
? task.WaitAsync(waitCancellationToken)
: task;
}
}

View File

@ -1,182 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Whisper.net;
using Whisper.net.Ggml;
namespace OpenClaw.Shared.Audio;
/// <summary>
/// Wraps Whisper.net for speech-to-text transcription.
/// Lazily loads the model on first use and caches the factory.
/// Thread-safe: concurrent calls are serialized by a semaphore.
/// </summary>
public sealed class SpeechToTextService : IDisposable
{
private readonly IOpenClawLogger _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private WhisperFactory? _factory;
private string? _loadedModelPath;
public bool IsModelLoaded => _factory != null;
public string? LoadedModelPath => _loadedModelPath;
public SpeechToTextService(IOpenClawLogger logger)
{
_logger = logger;
}
/// <summary>Load (or reload) the Whisper model from disk.</summary>
public void LoadModel(string modelPath)
{
if (!System.IO.File.Exists(modelPath))
throw new System.IO.FileNotFoundException($"Whisper model not found: {modelPath}");
_factory?.Dispose();
_factory = WhisperFactory.FromPath(modelPath);
_loadedModelPath = modelPath;
_logger.Info($"Whisper model loaded: {modelPath}");
}
/// <summary>Unload the current model and free memory.</summary>
public void UnloadModel()
{
_factory?.Dispose();
_factory = null;
_loadedModelPath = null;
_logger.Info("Whisper model unloaded");
}
/// <summary>
/// Transcribe raw 16 kHz mono PCM float samples.
/// Returns all detected segments.
/// </summary>
public async Task<List<TranscriptionResult>> TranscribeAsync(
float[] samples,
string language = "auto",
CancellationToken cancellationToken = default)
{
if (_factory == null)
throw new InvalidOperationException("No Whisper model is loaded. Call LoadModel first.");
await _gate.WaitAsync(cancellationToken);
try
{
// Whisper.net's WithLanguage expects either "auto" or a 2-letter
// ISO 639-1 code. The capability validator accepts the broader
// BCP-47 shape ("en-US", "zh-Hans-CN") because that's what the
// public docs advertise; normalize down here so Whisper actually
// sees something it understands.
var whisperLang = NormalizeForWhisper(language);
var builder = _factory.CreateBuilder()
.WithLanguage(whisperLang)
.WithThreads(Math.Max(1, Environment.ProcessorCount / 2));
using var processor = builder.Build();
using var wavStream = PcmToWavStream(samples, 16000);
var results = new List<TranscriptionResult>();
await foreach (var segment in processor.ProcessAsync(wavStream, cancellationToken))
{
var text = segment.Text?.Trim();
if (!string.IsNullOrEmpty(text))
{
results.Add(new TranscriptionResult
{
Text = text,
Start = segment.Start,
End = segment.End,
Language = whisperLang
});
}
}
return results;
}
finally
{
_gate.Release();
}
}
/// <summary>
/// Convert raw 16-bit PCM float samples to a WAV MemoryStream.
/// Whisper.net processes WAV streams natively.
/// </summary>
private static System.IO.MemoryStream PcmToWavStream(float[] samples, int sampleRate)
{
var ms = new System.IO.MemoryStream();
using var writer = new System.IO.BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true);
int bitsPerSample = 16;
short channels = 1;
int byteRate = sampleRate * channels * bitsPerSample / 8;
short blockAlign = (short)(channels * bitsPerSample / 8);
int dataSize = samples.Length * blockAlign;
// RIFF header
writer.Write("RIFF"u8);
writer.Write(36 + dataSize);
writer.Write("WAVE"u8);
// fmt subchunk
writer.Write("fmt "u8);
writer.Write(16); // subchunk size
writer.Write((short)1); // PCM format
writer.Write(channels);
writer.Write(sampleRate);
writer.Write(byteRate);
writer.Write(blockAlign);
writer.Write((short)bitsPerSample);
// data subchunk
writer.Write("data"u8);
writer.Write(dataSize);
// Convert float [-1.0, 1.0] to int16
foreach (var sample in samples)
{
var clamped = Math.Clamp(sample, -1.0f, 1.0f);
var int16 = (short)(clamped * 32767);
writer.Write(int16);
}
writer.Flush();
ms.Position = 0;
return ms;
}
/// <summary>
/// Reduce a BCP-47 tag (e.g. "en-US", "zh-Hans-CN") to the 2-letter
/// language subtag that Whisper.net's WithLanguage call expects.
/// "auto" passes through unchanged. Returns "auto" for nulls/whitespace
/// or values that don't begin with at least 2 ASCII letters.
/// </summary>
internal static string NormalizeForWhisper(string? language)
{
if (string.IsNullOrWhiteSpace(language)) return "auto";
var trimmed = language.Trim();
if (string.Equals(trimmed, "auto", StringComparison.OrdinalIgnoreCase)) return "auto";
// Take everything up to the first '-' (the primary subtag) and lowercase.
var dash = trimmed.IndexOf('-');
var primary = (dash >= 0 ? trimmed[..dash] : trimmed).ToLowerInvariant();
// Whisper expects 2-letter ISO 639-1. If the caller handed us a
// 3-letter ISO 639-3 tag (no good cross-walk without a table) or
// garbage, fall back to auto-detection rather than silently
// sending an invalid value.
if (primary.Length != 2 || primary[0] is < 'a' or > 'z' || primary[1] is < 'a' or > 'z')
return "auto";
return primary;
}
public void Dispose()
{
_factory?.Dispose();
_gate.Dispose();
}
}

View File

@ -1,108 +0,0 @@
using System;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
namespace OpenClaw.Shared.Audio;
/// <summary>
/// Voice Activity Detection using Silero VAD ONNX model.
/// Processes 16 kHz mono audio in 512-sample chunks (~32 ms each)
/// and returns a speech probability per chunk.
/// </summary>
public sealed class VoiceActivityDetector : IDisposable
{
private InferenceSession? _session;
private float[] _state; // internal RNN state: shape [2, 1, 128]
private readonly int _stateSize;
private readonly IOpenClawLogger _logger;
/// <summary>Expected sample rate for input audio.</summary>
public const int SampleRate = 16000;
/// <summary>Number of samples per VAD chunk (512 @ 16 kHz = 32 ms).</summary>
public const int ChunkSamples = 512;
public bool IsLoaded => _session != null;
public VoiceActivityDetector(IOpenClawLogger logger)
{
_logger = logger;
_stateSize = 2 * 1 * 128;
_state = new float[_stateSize];
}
/// <summary>Load the Silero VAD ONNX model from disk.</summary>
public void LoadModel(string modelPath)
{
if (!System.IO.File.Exists(modelPath))
throw new System.IO.FileNotFoundException($"VAD model not found: {modelPath}");
var opts = new SessionOptions
{
InterOpNumThreads = 1,
IntraOpNumThreads = 1,
EnableCpuMemArena = true
};
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
_session?.Dispose();
_session = new InferenceSession(modelPath, opts);
ResetState();
_logger.Info($"Silero VAD model loaded: {modelPath}");
}
/// <summary>Reset the internal RNN state (call between utterances).</summary>
public void ResetState()
{
Array.Clear(_state, 0, _state.Length);
}
/// <summary>
/// Process a single chunk of audio and return the speech probability (0.01.0).
/// Input must be exactly <see cref="ChunkSamples"/> float samples at 16 kHz.
/// </summary>
public float ProcessChunk(float[] audioChunk)
{
if (_session == null)
throw new InvalidOperationException("VAD model not loaded. Call LoadModel first.");
if (audioChunk.Length != ChunkSamples)
throw new ArgumentException($"Audio chunk must be exactly {ChunkSamples} samples, got {audioChunk.Length}");
// Build input tensors matching Silero VAD v5 expected shapes.
// See: github.com/snakers4/silero-vad/blob/master/examples/csharp/SileroVadOnnxModel.cs
var inputTensor = new DenseTensor<float>(audioChunk, new[] { 1, ChunkSamples });
var srTensor = new DenseTensor<long>(new long[] { SampleRate }, new[] { 1 });
var stateTensor = new DenseTensor<float>(_state, new[] { 2, 1, 128 });
using var results = _session.Run(new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input", inputTensor),
NamedOnnxValue.CreateFromTensor("sr", srTensor),
NamedOnnxValue.CreateFromTensor("state", stateTensor)
});
float probability = 0f;
foreach (var result in results)
{
if (result.Name == "output")
{
var tensor = result.AsTensor<float>();
probability = tensor.Length > 0 ? tensor.GetValue(0) : 0f;
}
else if (result.Name == "stateN")
{
var newState = result.AsTensor<float>();
for (int i = 0; i < _stateSize && i < newState.Length; i++)
_state[i] = newState.GetValue(i);
}
}
return probability;
}
public void Dispose()
{
_session?.Dispose();
}
}

View File

@ -1,223 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
/// <summary>
/// Manages Whisper GGML model downloads, storage, and lifecycle.
/// Models are stored in <c>%APPDATA%\OpenClawTray\models\</c> (or the
/// configured data directory).
/// </summary>
public sealed class WhisperModelManager
{
private readonly string _modelsDirectory;
private readonly IOpenClawLogger _logger;
// Per-model single-flight gate: a manual auto-download (VoiceService
// EnsureInitializedAsync) and a UI-triggered download for the same
// model would otherwise both write the same .tmp file. Static so an
// additional manager instance constructed elsewhere (e.g. the Settings
// page's status-only check) doesn't bypass the lock.
private static readonly ConcurrentDictionary<string, Lazy<Task>> InFlightDownloads = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Known Whisper model definitions.
///
/// SECURITY — pinned SHA-256 hashes (lowercase hex) verified against
/// HuggingFace on 2026-05-05. Downloads with a different hash are
/// rejected and the partial file is deleted. Before any public release:
/// re-verify each hash from an independent source and document the
/// provenance in Audio_FollowUps.md §2 (also consider replacing this
/// inline table with a signed manifest).
/// </summary>
public static readonly WhisperModelInfo[] AvailableModels =
[
new("ggml-tiny.bin", "tiny", 77_691_713, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin",
"be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21"),
new("ggml-base.bin", "base", 147_951_465, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin",
"60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe"),
new("ggml-small.bin", "small", 487_601_967, "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
"1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b"),
];
public WhisperModelManager(string dataDirectory, IOpenClawLogger logger)
{
_modelsDirectory = Path.Combine(dataDirectory, "models");
_logger = logger;
Directory.CreateDirectory(_modelsDirectory);
}
/// <summary>Full file path for a given model name.</summary>
public string GetModelPath(string modelName)
{
var info = FindModel(modelName);
return Path.Combine(_modelsDirectory, info.FileName);
}
/// <summary>Check whether a model file already exists on disk.</summary>
public bool IsModelDownloaded(string modelName)
{
var path = GetModelPath(modelName);
return File.Exists(path);
}
/// <summary>Get the size of a downloaded model, or 0 if not downloaded.</summary>
public long GetModelSize(string modelName)
{
var path = GetModelPath(modelName);
return File.Exists(path) ? new FileInfo(path).Length : 0;
}
/// <summary>
/// Download a model from HuggingFace if not already present.
/// Reports progress as bytes downloaded / total bytes.
/// Per-model single-flight: concurrent calls for the same model await
/// the in-flight download instead of racing on the same .tmp file.
/// </summary>
public Task DownloadModelAsync(
string modelName,
IProgress<(long downloaded, long total)>? progress = null,
CancellationToken cancellationToken = default)
{
var info = FindModel(modelName);
var destPath = Path.Combine(_modelsDirectory, info.FileName);
if (File.Exists(destPath))
{
_logger.Info($"Model '{modelName}' already exists at {destPath}");
return Task.CompletedTask;
}
// Use the canonical key (FileName) so two callers that pass "base"
// and "ggml-base.bin" still coalesce.
var key = info.FileName;
return SingleFlightDownload.RunAsync(
InFlightDownloads,
key,
token => DownloadModelCoreAsync(info, destPath, progress, token),
cancellationToken);
}
private async Task DownloadModelCoreAsync(
WhisperModelInfo info,
string destPath,
IProgress<(long downloaded, long total)>? progress,
CancellationToken cancellationToken)
{
// SECURITY: a missing pinned hash is treated as a hard failure so we
// never install an unverified asset. The catalog above pins all
// shipped models; if you add a new one without a hash, this is the
// place that refuses to download it. See Audio_FollowUps.md §2.
if (string.IsNullOrWhiteSpace(info.Sha256))
{
throw new InvalidOperationException(
$"Whisper model '{info.Name}' has no pinned SHA-256; refusing to download. " +
"Add a verified hash to AvailableModels before enabling this model.");
}
_logger.Info($"Downloading model '{info.Name}' from {info.DownloadUrl}");
var tempPath = destPath + ".tmp";
try
{
using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromMinutes(30);
using var response = await httpClient.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? info.ApproximateSizeBytes;
using (var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken))
using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920))
{
var buffer = new byte[81920];
long downloadedBytes = 0;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
downloadedBytes += bytesRead;
progress?.Report((downloadedBytes, totalBytes));
}
await fileStream.FlushAsync(cancellationToken);
}
// SECURITY: verify SHA-256 BEFORE the atomic rename, so a
// tampered file never lands at the canonical path. On mismatch
// we delete the temp file (no partial install) and surface a
// sanitized error — we deliberately do NOT echo the actual
// hash because that gives an attacker a confirmation oracle.
await VerifyHashAsync(tempPath, info.Sha256, info.Name, cancellationToken);
File.Move(tempPath, destPath, overwrite: true);
_logger.Info($"Model '{info.Name}' downloaded and verified");
}
catch
{
// Clean up partial download
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best effort */ }
throw;
}
}
/// <summary>
/// Compute SHA-256 of <paramref name="filePath"/> and compare to
/// <paramref name="expectedHex"/>. Throws on mismatch (and the caller
/// is expected to delete the file). Does not echo the actual hash to
/// avoid handing attackers a confirmation oracle.
/// </summary>
private static async Task VerifyHashAsync(string filePath, string expectedHex, string assetName, CancellationToken cancellationToken)
{
using var sha = System.Security.Cryptography.SHA256.Create();
await using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true);
var actual = await sha.ComputeHashAsync(stream, cancellationToken);
var actualHex = Convert.ToHexString(actual).ToLowerInvariant();
if (!string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase))
{
throw new System.Security.SecurityException(
$"Whisper model '{assetName}' failed integrity check. The downloaded file does not match the pinned SHA-256.");
}
}
/// <summary>Delete a downloaded model file.</summary>
public bool DeleteModel(string modelName)
{
var path = GetModelPath(modelName);
if (!File.Exists(path)) return false;
File.Delete(path);
_logger.Info($"Deleted model '{modelName}'");
return true;
}
private static WhisperModelInfo FindModel(string modelName)
{
foreach (var m in AvailableModels)
{
if (string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase))
return m;
}
throw new ArgumentException($"Unknown model: '{modelName}'. Available: tiny, base, small");
}
}
/// <summary>Metadata about a Whisper model variant.</summary>
/// <param name="FileName">On-disk filename (e.g. "ggml-base.bin").</param>
/// <param name="Name">Short identifier used by callers ("tiny" / "base" / "small").</param>
/// <param name="ApproximateSizeBytes">Approximate size hint for UI; the
/// actual size is asserted against <paramref name="Sha256"/> after download.</param>
/// <param name="DownloadUrl">HTTPS URL of the model file.</param>
/// <param name="Sha256">Pinned lowercase hex SHA-256 of the downloaded file.
/// MUST be set; downloads are refused when null. See the catalog for the
/// "verified on" date — these need re-verification before any public
/// release (see Audio_FollowUps.md §2).</param>
public sealed record WhisperModelInfo(
string FileName,
string Name,
long ApproximateSizeBytes,
string DownloadUrl,
string? Sha256);

View File

@ -1,154 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Capabilities;
/// <summary>
/// App-level capability exposing navigation, status, and configuration
/// through the MCP server for programmatic testing and CLI agents.
/// </summary>
public class AppCapability : NodeCapabilityBase
{
public override string Category => "app";
private static readonly string[] _commands = new[]
{
"app.navigate",
"app.status",
"app.sessions",
"app.agents",
"app.nodes",
"app.config.get",
"app.settings.get",
"app.settings.set",
"app.menu",
"app.search",
};
public override IReadOnlyList<string> Commands => _commands;
// Handler delegates — wired up by App.xaml.cs after construction.
public Func<string, Task<object?>>? NavigateHandler;
public Func<object?>? StatusHandler;
public Func<string?, Task<object?>>? SessionsHandler;
public Func<Task<object?>>? AgentsHandler;
public Func<object?>? NodesHandler;
public Func<string?, Task<object?>>? ConfigGetHandler;
public Func<string, object?>? SettingsGetHandler;
public Func<string, string, object?>? SettingsSetHandler;
public Func<object?>? MenuHandler;
public Func<string, object?>? SearchHandler;
public AppCapability(IOpenClawLogger logger) : base(logger) { }
public override async Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
{
return request.Command switch
{
"app.navigate" => await HandleNavigate(request),
"app.status" => HandleStatus(),
"app.sessions" => await HandleSessions(request),
"app.agents" => await HandleAgents(),
"app.nodes" => HandleNodes(),
"app.config.get" => await HandleConfigGet(request),
"app.settings.get" => HandleSettingsGet(request),
"app.settings.set" => HandleSettingsSet(request),
"app.menu" => HandleMenu(),
"app.search" => HandleSearch(request),
_ => Error($"Unknown command: {request.Command}")
};
}
private async Task<NodeInvokeResponse> HandleNavigate(NodeInvokeRequest request)
{
var page = GetStringArg(request.Args, "page");
if (string.IsNullOrEmpty(page))
return Error("Missing required arg: page");
if (NavigateHandler == null)
return Error("Navigate handler not registered");
var result = await NavigateHandler(page);
return Success(result);
}
private NodeInvokeResponse HandleStatus()
{
if (StatusHandler == null)
return Error("Status handler not registered");
return Success(StatusHandler());
}
private async Task<NodeInvokeResponse> HandleSessions(NodeInvokeRequest request)
{
var agentId = GetStringArg(request.Args, "agentId");
if (SessionsHandler == null)
return Error("Sessions handler not registered");
var result = await SessionsHandler(agentId);
return Success(result);
}
private async Task<NodeInvokeResponse> HandleAgents()
{
if (AgentsHandler == null)
return Error("Agents handler not registered");
var result = await AgentsHandler();
return Success(result);
}
private NodeInvokeResponse HandleNodes()
{
if (NodesHandler == null)
return Error("Nodes handler not registered");
return Success(NodesHandler());
}
private async Task<NodeInvokeResponse> HandleConfigGet(NodeInvokeRequest request)
{
var path = GetStringArg(request.Args, "path");
if (ConfigGetHandler == null)
return Error("Config handler not registered");
var result = await ConfigGetHandler(path);
return Success(result);
}
private NodeInvokeResponse HandleSettingsGet(NodeInvokeRequest request)
{
var name = GetStringArg(request.Args, "name");
if (string.IsNullOrEmpty(name))
return Error("Missing required arg: name");
if (SettingsGetHandler == null)
return Error("Settings handler not registered");
return Success(SettingsGetHandler(name));
}
private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request)
{
var name = GetStringArg(request.Args, "name");
var value = GetStringArg(request.Args, "value");
if (string.IsNullOrEmpty(name))
return Error("Missing required arg: name");
if (value == null)
return Error("Missing required arg: value");
if (SettingsSetHandler == null)
return Error("Settings handler not registered");
return Success(SettingsSetHandler(name, value));
}
private NodeInvokeResponse HandleMenu()
{
if (MenuHandler == null)
return Error("Menu handler not registered");
return Success(MenuHandler());
}
private NodeInvokeResponse HandleSearch(NodeInvokeRequest request)
{
var query = GetStringArg(request.Args, "query");
if (string.IsNullOrEmpty(query))
return Error("Missing required arg: query");
if (SearchHandler == null)
return Error("Search handler not registered");
return Success(SearchHandler(query));
}
}

View File

@ -60,7 +60,7 @@ public class CameraCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("Camera list failed", ex);
return Error("List failed");
return Error($"List failed: {ex.Message}");
}
}
@ -106,7 +106,7 @@ public class CameraCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("Camera snap failed", ex);
return Error("Snap failed");
return Error($"Snap failed: {ex.Message}");
}
}
@ -147,7 +147,7 @@ public class CameraCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("Camera clip failed", ex);
return Error("Clip failed");
return Error($"Clip failed: {ex.Message}");
}
}
}

View File

@ -404,10 +404,8 @@ public class CanvasCapability : NodeCapabilityBase
}
using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
// GetFinalPathFromHandle is a Windows-only guard (returns "" on non-Windows); skip the
// containment check when no resolved path is available — prior symlink resolution covers that case.
var finalPath = GetFinalPathFromHandle(stream.SafeFileHandle);
if (!string.IsNullOrEmpty(finalPath) && !IsPathWithinRoot(finalPath, tempRoot))
if (!IsPathWithinRoot(finalPath, tempRoot))
{
Logger.Warn($"{command}: jsonlPath file handle resolves outside temp directory: {finalPath}");
throw new InvalidOperationException("jsonlPath must resolve within the system temp directory");

View File

@ -64,7 +64,7 @@ public class LocationCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("location.get failed", ex);
return Error("Location failed");
return Error($"Location failed: {ex.Message}");
}
}
}

View File

@ -84,7 +84,7 @@ public class ScreenCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("Screen capture failed", ex);
return Error("Capture failed");
return Error($"Capture failed: {ex.Message}");
}
}
@ -134,7 +134,7 @@ public class ScreenCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("Screen recording failed", ex);
return Error("Recording failed");
return Error($"Recording failed: {ex.Message}");
}
}

View File

@ -1,339 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Capabilities;
/// <summary>
/// Speech-to-text node capability. Three commands:
///
/// * <see cref="TranscribeCommand"/> — bounded fixed-duration capture + transcription.
/// Caller must specify <c>maxDurationMs</c> (capped at <see cref="MaxTranscribeDurationMs"/>).
/// Useful for quick "give me 5 seconds of audio" prompts.
///
/// * <see cref="ListenCommand"/> — VAD-driven capture that returns when speech ends
/// or after <c>timeoutMs</c> (default <see cref="DefaultListenTimeoutMs"/>, range
/// <see cref="MinListenTimeoutMs"/>..<see cref="MaxListenTimeoutMs"/>).
/// Useful for conversational "listen until I stop talking" prompts.
///
/// * <see cref="StatusCommand"/> — reports engine readiness (no PII).
///
/// The actual engine lives in the tray (Whisper.net + NAudio + Silero VAD).
/// Whisper is local-first and privacy-respecting; the legacy WinRT
/// <c>SpeechRecognizer</c> + desktop SAPI fallback was removed because both
/// stacks are old, can leak audio to the Microsoft cloud (online-speech),
/// and don't work in unpackaged builds.
///
/// **Privacy invariants for the response surface:**
/// - Validation errors never echo the caller-supplied language string.
/// - Handler exceptions never propagate their <c>Message</c> into the response;
/// full detail stays in the local logger only. This is critical because
/// failed-invoke errors land in recent activity / support bundles.
/// - <see cref="StatusCommand"/> response carries no PII (no transcript fragments,
/// no language history, no device IDs, no model paths).
/// </summary>
public sealed class SttCapability : NodeCapabilityBase
{
public const string TranscribeCommand = "stt.transcribe";
public const string ListenCommand = "stt.listen";
public const string StatusCommand = "stt.status";
public const int MaxTranscribeDurationMs = 30_000;
public const int MinListenTimeoutMs = 1_000;
public const int MaxListenTimeoutMs = 120_000;
public const int DefaultListenTimeoutMs = 30_000;
public const string DefaultLanguage = "en-US";
public const string AutoLanguage = "auto";
/// <summary>
/// Engine identifier returned in <c>engineEffective</c> on every successful
/// stt.* response. Currently always <c>"whisper"</c>; the field exists so
/// adding a future engine doesn't break the wire shape.
/// </summary>
public const string EngineWhisper = "whisper";
private static readonly string[] _commands = [TranscribeCommand, ListenCommand, StatusCommand];
// Conservative BCP-47 check: 2-3 letter language, optional script
// (4 letter), optional region (2 letter or 3 digit), each separated
// by a hyphen. Rejects whitespace and punctuation that would otherwise
// trip Windows.Globalization.Language ctor. The literal "auto"
// sentinel is accepted in addition (Whisper supports auto-detect).
private static readonly Regex BcpTagRegex = new(
"^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?$",
RegexOptions.Compiled);
public override string Category => "stt";
public override IReadOnlyList<string> Commands => _commands;
/// <summary>
/// Tray-side handler for <see cref="TranscribeCommand"/>: bounded fixed-duration
/// capture + transcription.
/// </summary>
public event Func<SttTranscribeArgs, CancellationToken, Task<SttTranscribeResult>>? TranscribeRequested;
/// <summary>
/// Tray-side handler for <see cref="ListenCommand"/>: VAD-driven capture that
/// returns on end-of-speech or after <c>timeoutMs</c>.
/// </summary>
public event Func<SttListenArgs, CancellationToken, Task<SttListenResult>>? ListenRequested;
/// <summary>
/// Tray-side handler for <see cref="StatusCommand"/>: returns per-engine readiness.
/// </summary>
public event Func<CancellationToken, Task<SttStatusResult>>? StatusRequested;
public SttCapability(IOpenClawLogger logger) : base(logger) { }
/// <summary>
/// Trim and validate a single language tag. Returns the trimmed tag on
/// success, the literal <see cref="AutoLanguage"/> sentinel on a case-insensitive
/// "auto" input, or <c>null</c> if the input is neither.
/// Public so UI surfaces can validate against the same rule the wire applies.
/// </summary>
public static string? NormalizeLanguageTag(string tag)
{
var trimmed = tag.Trim();
if (string.Equals(trimmed, AutoLanguage, StringComparison.OrdinalIgnoreCase))
return AutoLanguage;
return BcpTagRegex.IsMatch(trimmed) ? trimmed : null;
}
/// <summary>
/// Resolve the language to use for a recognition call: per-call argument
/// wins, then configured setting, then <see cref="DefaultLanguage"/>.
/// Returns <c>null</c> if the resolved string fails validation.
/// </summary>
public static string? ResolveLanguage(string? requested, string? configured)
{
var candidate = !string.IsNullOrWhiteSpace(requested)
? requested
: (!string.IsNullOrWhiteSpace(configured) ? configured : DefaultLanguage);
return NormalizeLanguageTag(candidate!);
}
public override Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest request)
=> ExecuteAsync(request, CancellationToken.None);
public override async Task<NodeInvokeResponse> ExecuteAsync(
NodeInvokeRequest request,
CancellationToken cancellationToken)
{
return request.Command switch
{
TranscribeCommand => await HandleTranscribeAsync(request, cancellationToken).ConfigureAwait(false),
ListenCommand => await HandleListenAsync(request, cancellationToken).ConfigureAwait(false),
StatusCommand => await HandleStatusAsync(cancellationToken).ConfigureAwait(false),
_ => Error($"Unknown command: {request.Command}")
};
}
private async Task<NodeInvokeResponse> HandleTranscribeAsync(
NodeInvokeRequest request,
CancellationToken cancellationToken)
{
// maxDurationMs is required and bounded server-side. We deliberately
// reject 0/negative rather than substituting a default — callers
// explicitly choose how much mic time they're spending.
var maxDurationMs = GetIntArg(request.Args, "maxDurationMs", 0);
if (maxDurationMs <= 0)
return Error("Missing required maxDurationMs");
if (maxDurationMs > MaxTranscribeDurationMs)
return Error($"maxDurationMs exceeds {MaxTranscribeDurationMs} ms");
var requestedLanguage = GetStringArg(request.Args, "language");
string? resolvedLanguage = null;
if (!string.IsNullOrWhiteSpace(requestedLanguage))
{
resolvedLanguage = NormalizeLanguageTag(requestedLanguage);
if (resolvedLanguage == null)
return Error("Invalid language tag");
}
if (TranscribeRequested == null)
return Error("STT transcribe not available");
var args = new SttTranscribeArgs
{
MaxDurationMs = maxDurationMs,
Language = resolvedLanguage // null lets the tray fall back to its configured setting
};
Logger.Info($"stt.transcribe: maxDurationMs={args.MaxDurationMs}, language={args.Language ?? "(default)"}");
try
{
var result = await TranscribeRequested(args, cancellationToken).ConfigureAwait(false);
return Success(new
{
transcribed = result.Transcribed,
text = result.Text,
durationMs = result.DurationMs,
language = result.Language,
engineEffective = result.EngineEffective
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Error("Transcribe canceled");
}
catch (Exception ex)
{
// Privacy: never echo raw exception text into the response. The
// exception flows through the failed-invoke path and may be
// persisted to recent activity / support bundles. Full detail
// stays in the local log only.
Logger.Error("STT transcribe failed", ex);
return Error("Transcribe failed");
}
}
private async Task<NodeInvokeResponse> HandleListenAsync(
NodeInvokeRequest request,
CancellationToken cancellationToken)
{
// timeoutMs is optional with a sane default; bounded both ways so
// a hostile caller can't pin the mic open for an hour.
var timeoutMs = GetIntArg(request.Args, "timeoutMs", DefaultListenTimeoutMs);
if (timeoutMs < MinListenTimeoutMs) timeoutMs = MinListenTimeoutMs;
if (timeoutMs > MaxListenTimeoutMs) timeoutMs = MaxListenTimeoutMs;
var requestedLanguage = GetStringArg(request.Args, "language");
string resolvedLanguage = AutoLanguage;
if (!string.IsNullOrWhiteSpace(requestedLanguage))
{
var normalized = NormalizeLanguageTag(requestedLanguage);
if (normalized == null)
return Error("Invalid language tag");
resolvedLanguage = normalized;
}
if (ListenRequested == null)
return Error("STT listen not available");
var args = new SttListenArgs
{
TimeoutMs = timeoutMs,
Language = resolvedLanguage
};
Logger.Info($"stt.listen: timeoutMs={timeoutMs}, language={resolvedLanguage}");
try
{
var result = await ListenRequested(args, cancellationToken).ConfigureAwait(false);
return Success(new
{
text = result.Text,
language = result.Language,
durationMs = result.DurationMs,
segments = result.Segments,
engineEffective = result.EngineEffective
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return Error("Listen canceled");
}
catch (Exception ex)
{
// Same privacy invariant as Transcribe.
Logger.Error("STT listen failed", ex);
return Error("Listen failed");
}
}
private async Task<NodeInvokeResponse> HandleStatusAsync(CancellationToken cancellationToken)
{
if (StatusRequested == null)
return Error("STT status not available");
try
{
var result = await StatusRequested(cancellationToken).ConfigureAwait(false);
return Success(new
{
engine = result.Engine,
readiness = result.Readiness,
modelDownloadProgress = result.ModelDownloadProgress,
isListenWithVadSupported = result.IsListenWithVadSupported,
isBoundedTranscribeSupported = result.IsBoundedTranscribeSupported
});
}
catch (Exception ex)
{
// Status must not leak engine internals; carry only a fixed message.
Logger.Error("STT status failed", ex);
return Error("Status failed");
}
}
}
public sealed class SttTranscribeArgs
{
public int MaxDurationMs { get; set; }
/// <summary>
/// BCP-47 tag (e.g., "en-US"), the literal "auto" sentinel, or null
/// to let the tray fall back to its configured <c>SttLanguage</c> setting.
/// </summary>
public string? Language { get; set; }
}
public sealed class SttTranscribeResult
{
public bool Transcribed { get; set; }
public string Text { get; set; } = "";
public int DurationMs { get; set; }
public string Language { get; set; } = SttCapability.DefaultLanguage;
/// <summary>
/// Engine that served this call. Always <see cref="SttCapability.EngineWhisper"/>
/// today; the field exists so a future engine doesn't break the wire.
/// </summary>
public string EngineEffective { get; set; } = SttCapability.EngineWhisper;
}
public sealed class SttListenArgs
{
public int TimeoutMs { get; set; }
/// <summary>
/// BCP-47 tag (e.g., "en-US"), or the literal "auto" sentinel
/// (default; lets Whisper auto-detect).
/// </summary>
public string Language { get; set; } = SttCapability.AutoLanguage;
}
public sealed class SttListenResult
{
public string Text { get; set; } = "";
public string Language { get; set; } = SttCapability.AutoLanguage;
public int DurationMs { get; set; }
public IReadOnlyList<SttSegment> Segments { get; set; } = Array.Empty<SttSegment>();
public string EngineEffective { get; set; } = SttCapability.EngineWhisper;
}
public sealed class SttSegment
{
public string Text { get; set; } = "";
public int StartMs { get; set; }
public int EndMs { get; set; }
}
public sealed class SttStatusResult
{
public string Engine { get; set; } = SttCapability.EngineWhisper;
/// <summary>One of "ready", "initializing", "model-downloading", "model-not-downloaded", "unavailable".</summary>
public string Readiness { get; set; } = "unavailable";
/// <summary>0..1 download progress when <see cref="Readiness"/> == "model-downloading"; null otherwise.</summary>
public double? ModelDownloadProgress { get; set; }
public bool IsListenWithVadSupported { get; set; }
public bool IsBoundedTranscribeSupported { get; set; }
}

View File

@ -271,7 +271,7 @@ public class SystemCapability : NodeCapabilityBase
{
// Rail 1: no silent fallback — handler exceptions become typed denies.
Logger.Error($"[system.run] corr={correlationId} path=v2 handler threw", ex);
v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception");
v2Result = ExecApprovalV2Result.ValidationFailed($"Handler exception: {ex.Message}");
}
Logger.Info($"[system.run] corr={correlationId} decision={v2Result.Code} reason={v2Result.Reason}");
@ -413,7 +413,7 @@ public class SystemCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("system.run failed", ex);
return Error("Execution failed");
return Error($"Execution failed: {ex.Message}");
}
}
@ -614,7 +614,7 @@ public class SystemCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("execApprovals.set failed", ex);
return Error("Failed to update policy");
return Error($"Failed to update policy: {ex.Message}");
}
}

View File

@ -10,11 +10,6 @@ public sealed class TtsCapability : NodeCapabilityBase
public const string SpeakCommand = "tts.speak";
public const string WindowsProvider = "windows";
public const string ElevenLabsProvider = "elevenlabs";
/// <summary>
/// Local neural TTS via Sherpa-ONNX wrapping Piper voices. No network
/// egress; voice models download once to %LOCALAPPDATA%.
/// </summary>
public const string PiperProvider = "piper";
public const int MaxTextLength = 5000;
private static readonly string[] _commands = [SpeakCommand];
@ -35,7 +30,7 @@ public sealed class TtsCapability : NodeCapabilityBase
: requestedProvider;
return string.IsNullOrWhiteSpace(provider)
? PiperProvider
? WindowsProvider
: provider.Trim().ToLowerInvariant();
}
@ -86,14 +81,8 @@ public sealed class TtsCapability : NodeCapabilityBase
}
catch (Exception ex)
{
// Privacy: never echo raw exception text into the response. The
// exception flows through the failed-invoke path and may be
// persisted to recent activity / support bundles. ElevenLabs
// error messages can contain key prefixes; OS speech errors
// can contain device names. Full detail stays in the local
// log only. (Same pattern as SttCapability.)
Logger.Error("TTS speak failed", ex);
return Error("Speak failed");
return Error($"Speak failed: {ex.Message}");
}
}

View File

@ -20,13 +20,10 @@ public static class DeepLinkParser
if (!uri.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase))
return null;
var remainder = uri[Scheme.Length..];
var remainder = uri[Scheme.Length..].TrimEnd('/');
var queryIndex = remainder.IndexOf('?');
var query = queryIndex >= 0 ? remainder[(queryIndex + 1)..] : "";
// Trim trailing slash AFTER splitting off the query so the
// Windows-canonicalized form `openclaw://send/?args=...` (slash
// BEFORE the `?`) yields path "send", not "send/".
var path = (queryIndex >= 0 ? remainder[..queryIndex] : remainder).TrimEnd('/');
var path = queryIndex >= 0 ? remainder[..queryIndex] : remainder;
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))

View File

@ -1,11 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using OpenClaw.Shared.Mcp;
using NSec.Cryptography;
namespace OpenClaw.Shared;
@ -21,25 +18,15 @@ 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<string>? DeviceTokenScopes => _deviceTokenScopes;
public string? NodeDeviceToken => _nodeDeviceToken;
public IReadOnlyList<string>? NodeDeviceTokenScopes => _nodeDeviceTokenScopes;
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
TryReadStoredDeviceTokenForRole(dataPath, "operator", logger);
public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null)
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null)
{
var tokenRole = ParseDeviceTokenRole(role);
var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
if (!File.Exists(keyPath))
{
@ -49,11 +36,7 @@ public class DeviceIdentity
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(keyPath));
var tokenPropertyName = tokenRole == DeviceTokenRole.Node
? nameof(DeviceKeyData.NodeDeviceToken)
: nameof(DeviceKeyData.DeviceToken);
if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) &&
if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) &&
deviceToken.ValueKind == JsonValueKind.String)
{
var value = deviceToken.GetString();
@ -78,9 +61,6 @@ 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)
{
@ -122,9 +102,6 @@ public class DeviceIdentity
_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]}...");
}
@ -169,11 +146,8 @@ public class DeviceIdentity
{
Directory.CreateDirectory(dir);
}
if (!string.IsNullOrEmpty(dir))
McpAuthToken.TryRestrictDataDirectoryAcl(dir);
File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath);
_logger.Info($"Generated new Ed25519 device identity: {_deviceId}");
}
@ -333,40 +307,7 @@ public class DeviceIdentity
/// </summary>
public void StoreDeviceToken(string token)
{
StoreDeviceTokenCore(token, null);
}
public void StoreDeviceTokenWithScopes(string token, IEnumerable<string>? scopes)
{
StoreDeviceTokenCore(token, NormalizeScopes(scopes));
}
public void StoreDeviceTokenForRole(string role, string token, IEnumerable<string>? 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
@ -378,9 +319,7 @@ public class DeviceIdentity
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");
}
}
@ -390,48 +329,6 @@ public class DeviceIdentity
_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<DeviceKeyData>(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<string>? 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)
{
@ -441,21 +338,12 @@ public class DeviceIdentity
.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; }
}

View File

@ -249,8 +249,14 @@ public class ExecApprovalPolicy
var dir = Path.GetDirectoryName(_policyFilePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions);
var data = new ExecPolicyData
{
DefaultAction = _defaultAction,
Rules = _rules
};
var json = JsonSerializer.Serialize(data, _jsonOptions);
File.WriteAllText(_policyFilePath, json);
}
catch (Exception ex)

View File

@ -1,70 +0,0 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Architectural barrier produced by PR3.
// Equivalent to ExecHostValidatedRequest in the macOS reference, extended with resolution outputs.
// No module from PR4 onward may accept ValidatedRunRequest as direct input (research doc 05 line 439).
// Rail 15: a single canonical representation reused across evaluation, logging, prompting, execution.
public sealed class CanonicalCommandIdentity
{
// ── Normalization outputs ─────────────────────────────────────────────────
// Argv exactly as produced by PR2 (no trimming; coding contract process-argv-semantics).
public IReadOnlyList<string> Command { get; }
// Canonical display form generated from argv. Never rawCommand from the agent.
// Used by logging and prompting. Research doc 05 decision 2.
public string DisplayCommand { get; }
// Safe rawCommand for executable resolution. Null in Windows v1 (rawCommand not in
// system.run protocol; research doc 05 OQ-V4 / decision 10).
public string? EvaluationRawCommand { get; }
// ── Resolution outputs ────────────────────────────────────────────────────
// Singular resolution for the state machine (PR5).
// Null if the primary executable cannot be determined.
public ExecCommandResolution? Resolution { get; }
// Per-segment resolutions for the allowlist matcher (PR4/PR5).
// Empty list means fail-closed — no allowlist satisfaction possible.
public IReadOnlyList<ExecCommandResolution> AllowlistResolutions { get; }
// Suggested allowlist patterns for prompt/UI (PR6). Not a security decision.
public IReadOnlyList<string> AllowAlwaysPatterns { get; }
// ── Request context (carried from ValidatedRunRequest) ────────────────────
public string? Cwd { get; }
public int TimeoutMs { get; }
public IReadOnlyDictionary<string, string>? Env { get; }
public string? AgentId { get; }
public string? SessionKey { get; }
internal CanonicalCommandIdentity(
IReadOnlyList<string> command,
string displayCommand,
string? evaluationRawCommand,
ExecCommandResolution? resolution,
IReadOnlyList<ExecCommandResolution> allowlistResolutions,
IReadOnlyList<string> allowAlwaysPatterns,
string? cwd,
int timeoutMs,
IReadOnlyDictionary<string, string>? env,
string? agentId,
string? sessionKey)
{
Command = command;
DisplayCommand = displayCommand;
EvaluationRawCommand = evaluationRawCommand;
Resolution = resolution;
AllowlistResolutions = allowlistResolutions;
AllowAlwaysPatterns = allowAlwaysPatterns;
Cwd = cwd;
TimeoutMs = timeoutMs;
Env = env;
AgentId = agentId;
SessionKey = sessionKey;
}
}

View File

@ -1,85 +0,0 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Either a CanonicalCommandIdentity (IsResolved=true) or a typed denial (IsResolved=false).
// Produced by ExecApprovalV2Normalizer; consumed by the coordinator pipeline (PR7).
public sealed class ExecApprovalV2NormalizationOutcome
{
public bool IsResolved { get; }
public CanonicalCommandIdentity? Identity { get; }
public ExecApprovalV2Result? Error { get; }
private ExecApprovalV2NormalizationOutcome(CanonicalCommandIdentity identity)
{
IsResolved = true;
Identity = identity;
}
private ExecApprovalV2NormalizationOutcome(ExecApprovalV2Result error)
{
IsResolved = false;
Error = error;
}
public static ExecApprovalV2NormalizationOutcome Ok(CanonicalCommandIdentity identity)
=> new(identity);
public static ExecApprovalV2NormalizationOutcome Fail(ExecApprovalV2Result error)
=> new(error);
}
// Rail 18 steps 2-4: normalize command form → resolve executable → build canonical identity.
// Stateless — safe to call concurrently.
public static class ExecApprovalV2Normalizer
{
public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest request)
{
var argv = request.Argv;
var cwd = request.Cwd;
var env = request.Env as IReadOnlyDictionary<string, string>;
// displayCommand is always derived from argv, never from rawCommand (research doc 05 decision 2).
var displayCommand = ShellQuoting.FormatExecCommand(argv);
// rawCommand is null in Windows v1 (system.run does not carry it; research doc 05 OQ-V4).
// EvaluationRawCommand stays null — correct and documented conservative output.
string? evaluationRawCommand = null;
// Singular resolution for state machine.
var resolution = ExecCommandResolver.Resolve(argv, cwd, env);
// Multi-segment resolution for allowlist.
// Empty list is fail-closed: no allowlist satisfaction possible (research doc 04 R2).
// An empty list is NOT itself a denial at this step — the evaluator decides.
var allowlistResolutions = ExecCommandResolver.ResolveForAllowlist(
argv, evaluationRawCommand, cwd, env);
// UX patterns for prompting.
var allowAlwaysPatterns = ExecCommandResolver.ResolveAllowAlwaysPatterns(argv, cwd, env);
// Rail 6: if argv is non-empty but resolution is entirely impossible, deny.
// "Ambiguous or inconsistent" → typed deny, not silent allow.
if (resolution is null && allowlistResolutions.Count == 0)
return Fail("executable-resolution-failed");
var identity = new CanonicalCommandIdentity(
argv,
displayCommand,
evaluationRawCommand,
resolution,
allowlistResolutions,
allowAlwaysPatterns,
cwd,
request.TimeoutMs,
env,
request.AgentId,
request.SessionKey);
return ExecApprovalV2NormalizationOutcome.Ok(identity);
}
private static ExecApprovalV2NormalizationOutcome Fail(string reason)
=> ExecApprovalV2NormalizationOutcome.Fail(
ExecApprovalV2Result.ResolutionFailed(reason));
}

View File

@ -1,501 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenClaw.Shared.ExecApprovals;
// Resolved identity of a single executable token.
// Shape mirrors macOS ExecCommandResolution struct.
public readonly record struct ExecCommandResolution(
string RawExecutable,
string? ResolvedPath,
string ExecutableName,
string? Cwd);
// The three resolution functions required by the pipeline.
// resolve() → singular, for state machine
// ResolveForAllowlist() → multi-segment, fail-closed, for allowlist matching
// ResolveAllowAlwaysPatterns() → UX suggestions for prompt
internal static class ExecCommandResolver
{
// Windows executable extensions, tried in order for basename search.
private static readonly string[] s_extensions = [".exe", ".cmd", ".bat", ".com"];
// ── Public API ───────────────────────────────────────────────────────────
// Singular resolution of the primary executable for the state machine.
// Returns null if the command is empty or resolution is impossible.
// Unwraps transparent env prefixes (no modifiers).
internal static ExecCommandResolution? Resolve(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count == 0) return null;
var raw = effective[0].Trim();
return raw.Length == 0 ? null : ResolveExecutable(raw, cwd, env);
}
// Multi-segment resolution for allowlist matching.
// Detects shell wrappers; splits payload chain; resolves one executable per segment.
// Returns empty list (fail-closed) on any ambiguity, command substitution, or env manipulation.
internal static IReadOnlyList<ExecCommandResolution> ResolveForAllowlist(
IReadOnlyList<string> command,
string? evaluationRawCommand,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
// Fail-closed: any env invocation with modifiers (flags or VAR=val assignments).
// The allowlist cannot verify which executable will actually run under a modified env —
// the resolver uses the original env while execution uses the modified one.
// Subsumes the previous shell-wrapper-only check (Hanselman review finding #2).
if (command.Count > 0
&& ExecCommandToken.IsEnv(command[0].Trim())
&& ExecEnvInvocationUnwrapper.HasModifiers(command))
return [];
var wrapper = ExecShellWrapperNormalizer.Extract(command);
if (wrapper.IsWrapper)
{
if (wrapper.InlineCommand is null) return [];
var segments = SplitShellCommandChain(wrapper.InlineCommand);
if (segments is null) return [];
var resolutions = new List<ExecCommandResolution>(segments.Count);
foreach (var segment in segments)
{
var token = ParseFirstToken(segment);
if (token is null) return [];
// -EncodedCommand and aliases in segment position: fail-closed (research doc 04 S1).
if (SegmentUsesEncodedCommand(segment, token)) return [];
var res = ResolveExecutable(token, cwd, env);
if (res is null) return [];
resolutions.Add(res.Value);
}
return resolutions;
}
// Direct exec: fail-closed if powershell/pwsh invoked directly with -EncodedCommand.
// Covers top-level `["powershell", "-enc", ...]` and transparent `["env", "pwsh", "-enc", ...]`.
if (DirectExecUsesEncodedCommand(command)) return [];
var single = ResolveSingle(command, evaluationRawCommand, cwd, env);
return single is null ? [] : [single.Value];
}
// UX suggestions of allowlist patterns for prompting.
// Unlike ResolveForAllowlist, this unwraps env with modifiers to surface the real executable.
internal static IReadOnlyList<string> ResolveAllowAlwaysPatterns(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var patterns = new List<string>();
CollectPatterns(command, cwd, env, seen, patterns, 0);
return patterns;
}
// ── Resolution helpers ───────────────────────────────────────────────────
private static ExecCommandResolution? ResolveSingle(
IReadOnlyList<string> command,
string? rawCommand,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
// Prefer first token of evaluationRawCommand when present.
if (!string.IsNullOrWhiteSpace(rawCommand))
{
var token = ParseFirstToken(rawCommand);
if (token is not null) return ResolveExecutable(token, cwd, env);
}
return Resolve(command, cwd, env);
}
private static ExecCommandResolution? ResolveExecutable(
string rawExecutable,
string? cwd,
IReadOnlyDictionary<string, string>? env)
{
try
{
var expanded = ExpandTilde(rawExecutable);
var hasSep = expanded.Contains('/') || expanded.Contains('\\');
string? resolvedPath;
if (hasSep)
{
// Reject paths with ':' in non-volume-separator positions (ADS, non-standard forms).
if (HasNonStandardColon(expanded)) return null;
resolvedPath = Path.IsPathFullyQualified(expanded)
? Path.GetFullPath(expanded)
: Path.GetFullPath(expanded, string.IsNullOrWhiteSpace(cwd)
? Directory.GetCurrentDirectory()
: cwd.Trim());
}
else
{
resolvedPath = FindInPath(expanded, GetSearchPaths(env), GetPathExtensions(env));
}
var name = resolvedPath is not null ? Path.GetFileName(resolvedPath) : expanded;
return new ExecCommandResolution(expanded, resolvedPath, name, cwd);
}
catch { return null; } // fail-closed; intentionally broad — add diagnostic tracing here if needed
}
// ── Shell command chain splitting ────────────────────────────────────────
// Splits a shell command string on ;, &&, ||, |, &, \n.
// Returns null (fail-closed) on command/process substitution: $(...), `...`, <(...), >(...).
// Returns null on unclosed quotes or unresolved escapes.
private static IReadOnlyList<string>? SplitShellCommandChain(string command)
{
var trimmed = command.Trim();
if (trimmed.Length == 0) return null;
var segments = new List<string>();
var current = new StringBuilder();
bool inSingle = false, inDouble = false, escaped = false;
var chars = trimmed.ToCharArray();
for (var i = 0; i < chars.Length; i++)
{
var ch = chars[i];
char? next = i + 1 < chars.Length ? chars[i + 1] : null;
if (escaped) { current.Append(ch); escaped = false; continue; }
if (ch == '\\' && !inSingle) { current.Append(ch); escaped = true; continue; }
if (ch == '\'' && !inDouble) { inSingle = !inSingle; current.Append(ch); continue; }
if (ch == '"' && !inSingle) { inDouble = !inDouble; current.Append(ch); continue; }
// Fail-closed on command/process substitution.
if (!inSingle && IsCommandSubstitution(ch, next, inDouble)) return null;
if (!inSingle && !inDouble)
{
var step = DelimiterStep(ch, i > 0 ? chars[i - 1] : (char?)null, next);
if (step.HasValue)
{
var seg = current.ToString().Trim();
if (seg.Length == 0) return null;
segments.Add(seg);
current.Clear();
i += step.Value - 1;
continue;
}
}
current.Append(ch);
}
if (escaped || inSingle || inDouble) return null;
var last = current.ToString().Trim();
if (last.Length == 0) return null;
segments.Add(last);
return segments;
}
private static bool IsCommandSubstitution(char ch, char? next, bool inDouble)
{
if (inDouble) return ch == '`' || (ch == '$' && next == '(');
return ch == '`' ||
(ch == '$' && next == '(') ||
(ch == '<' && next == '(') ||
(ch == '>' && next == '(');
}
private static int? DelimiterStep(char ch, char? prev, char? next)
{
if (ch == ';' || ch == '\n') return 1;
if (ch == '&')
{
if (next == '&') return 2;
return (prev == '>' || next == '>') ? null : (int?)1;
}
if (ch == '|')
{
if (next == '|' || next == '&') return 2;
return 1;
}
return null;
}
// Extracts the first shell-tokenized word from a command string.
private static string? ParseFirstToken(string command)
{
var trimmed = command.Trim();
if (trimmed.Length == 0) return null;
var first = trimmed[0];
if (first == '"' || first == '\'')
{
var rest = trimmed.AsSpan(1);
var end = rest.IndexOf(first);
if (end < 0) return null; // unclosed quote — fail-closed; do not guess the token
var inner = rest[..end].ToString();
if (inner.Length == 0) return null;
// Preserve any suffix after the closing quote up to the next whitespace.
// Handles `"git".exe` → "git.exe" and `"C:\Program Files\Git\bin\git".exe` → *.exe.
var afterClose = rest[(end + 1)..];
var suffixEnd = afterClose.IndexOfAny(' ', '\t');
var suffix = suffixEnd >= 0 ? afterClose[..suffixEnd].ToString() : afterClose.ToString();
return suffix.Length > 0 ? inner + suffix : inner;
}
var space = trimmed.AsSpan().IndexOfAny(' ', '\t');
return space >= 0 ? trimmed[..space] : trimmed;
}
// ── allowAlwaysPatterns collection ───────────────────────────────────────
private static void CollectPatterns(
IReadOnlyList<string> command,
string? cwd,
IReadOnlyDictionary<string, string>? env,
HashSet<string> seen,
List<string> patterns,
int depth)
{
if (depth >= 3 || command.Count == 0) return;
var wrapper = ExecShellWrapperNormalizer.Extract(command);
if (wrapper.IsWrapper && wrapper.InlineCommand is not null)
{
var segments = SplitShellCommandChain(wrapper.InlineCommand);
if (segments is null) return;
foreach (var seg in segments)
{
// allowAlwaysPatterns does NOT fail-closed on -EncodedCommand: it's UX only.
var token = ParseFirstToken(seg);
if (token is null) continue;
var res = ResolveExecutable(token, cwd, env);
if (res is null) continue;
var pattern = res.Value.ResolvedPath ?? res.Value.RawExecutable;
if (seen.Add(pattern)) patterns.Add(pattern);
}
return;
}
// For direct exec, unwrap env including with-modifier cases for pattern discovery.
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count == 0) return;
var rawToken = effective[0].Trim();
if (rawToken.Length == 0) return;
var resolution = ResolveExecutable(rawToken, cwd, env);
if (resolution is null) return;
var pat = resolution.Value.ResolvedPath ?? resolution.Value.RawExecutable;
if (seen.Add(pat)) patterns.Add(pat);
}
// ── -EncodedCommand detection ─────────────────────────────────────────────
// Research doc 04 S1: if a chain segment invokes PowerShell with -EncodedCommand (or any
// alias / unambiguous prefix abbreviation), the payload is opaque base64 — fail-closed.
// Only triggers when the first token IS a PowerShell binary AND the segment contains the flag.
// `powershell -c 'Get-Date'` (no -enc) must NOT be fail-closed.
private static bool SegmentUsesEncodedCommand(string segment, string firstToken)
{
var b = ExecCommandToken.NormalizedBasename(firstToken);
if (b is not ("powershell" or "pwsh")) return false;
var rest = segment.AsSpan();
while (rest.Length > 0)
{
var i = 0;
while (i < rest.Length && char.IsWhiteSpace(rest[i])) i++;
rest = rest[i..];
if (rest.Length == 0) break;
// Extract next token — quoted strings count as one unit so `"-enc"` is detected.
int end;
if (rest[0] is '"' or '\'')
{
var q = rest[0];
end = 1;
while (end < rest.Length && rest[end] != q) end++;
if (end < rest.Length) end++; // include closing quote
}
else
{
end = 0;
while (end < rest.Length && !char.IsWhiteSpace(rest[end])) end++;
}
var token = rest[..end].ToString();
rest = rest[end..];
if (IsEncodedCommandFlag(token)) return true;
if (token == "--") break;
}
return false;
}
// Returns true when a raw flag token (possibly quoted, possibly with colon/equals value suffix)
// represents -EncodedCommand or any of its unambiguous prefix abbreviations.
// Covers: "-EncodedCommand", "-enc", "-ec", "-e", `"-enc"`, `-enc:payload`, `-encod`, etc.
private static bool IsEncodedCommandFlag(string rawToken)
{
var t = rawToken;
if (t.Length >= 2 && t[0] is '"' or '\'' && t[^1] == t[0])
t = t[1..^1]; // strip matching outer quotes
if (t.Length == 0 || t[0] != '-') return false;
// Strip trailing :value or =value (e.g. -EncodedCommand:base64).
var sep = t.AsSpan(1).IndexOfAny('=', ':');
var flag = (sep >= 0 ? t[..(sep + 1)] : t).ToLowerInvariant();
// -e is accepted by Windows PowerShell as a short alias for -EncodedCommand.
if (flag is "-e" or "-ec" or "-enc" or "-encodedcommand") return true;
// Any unambiguous prefix abbreviation of -encodedcommand beginning at -en.
const string full = "-encodedcommand";
return flag.Length >= 3 && full.StartsWith(flag, StringComparison.Ordinal);
}
// True when direct exec (no shell wrapper) is a PowerShell invocation with -EncodedCommand.
// Unwraps transparent env prefixes so `["env", "pwsh", "-enc", ...]` is also caught.
private static bool DirectExecUsesEncodedCommand(IReadOnlyList<string> command)
{
var effective = ExecEnvInvocationUnwrapper.UnwrapForResolution(command);
if (effective.Count < 2) return false;
var b = ExecCommandToken.NormalizedBasename(effective[0].Trim());
if (b is not ("powershell" or "pwsh")) return false;
for (var i = 1; i < effective.Count; i++)
{
var t = effective[i].Trim();
if (t == "--") break;
if (IsEncodedCommandFlag(t)) return true;
}
return false;
}
// ── PATH search ───────────────────────────────────────────────────────────
private static string? GetEnvValueIgnoreCase(IReadOnlyDictionary<string, string>? env, string key)
{
if (env is null) return null;
foreach (var kvp in env)
{
if (string.Equals(kvp.Key, key, StringComparison.OrdinalIgnoreCase))
return kvp.Value;
}
return null;
}
private static string? FindInPath(
string name,
IReadOnlyList<string> searchPaths,
IReadOnlyList<string> extensions)
{
foreach (var dir in searchPaths)
{
if (string.IsNullOrEmpty(dir)) continue;
var candidate = Path.Combine(dir, name);
// PATHEXT extensions first — matches Windows CreateProcess resolution order.
// A no-extension shadow in PATH must not shadow a PATHEXT binary of the same stem.
// Note: PATHEXT is probed even when `name` already carries an extension (git.exe →
// tries git.exe.exe, git.exe.cmd, …). This matches CreateProcess behavior — the extra
// File.Exists calls are harmless and avoiding them would require extension detection here.
foreach (var ext in extensions)
{
var withExt = candidate + ext;
if (File.Exists(withExt)) return TryNormalizePath(withExt);
}
// Bare name as final fallback (covers names that already have an explicit extension).
if (File.Exists(candidate)) return TryNormalizePath(candidate);
}
return null;
}
private static IReadOnlyList<string> GetSearchPaths(IReadOnlyDictionary<string, string>? env)
{
var rawPath = GetEnvValueIgnoreCase(env, "PATH");
if (!string.IsNullOrEmpty(rawPath))
{
var parts = rawPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
// Fallback to process PATH.
var processPath = Environment.GetEnvironmentVariable("PATH");
if (!string.IsNullOrEmpty(processPath))
{
var parts = processPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
return WellKnownPaths();
}
private static IReadOnlyList<string> GetPathExtensions(IReadOnlyDictionary<string, string>? env)
{
var rawPathExt = GetEnvValueIgnoreCase(env, "PATHEXT");
if (!string.IsNullOrEmpty(rawPathExt))
{
var parts = rawPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
var processPathExt = Environment.GetEnvironmentVariable("PATHEXT");
if (!string.IsNullOrEmpty(processPathExt))
{
var parts = processPathExt.Split(';', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0) return parts;
}
return s_extensions;
}
private static IReadOnlyList<string> WellKnownPaths()
{
var sys32 = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32");
var sys = Environment.GetFolderPath(Environment.SpecialFolder.System);
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
return
[
sys32,
sys,
Path.Combine(sys32, "OpenSSH"),
Path.Combine(pf, "Git", "usr", "bin"),
Path.Combine(pf, "Git", "bin"),
];
}
// ── Path helpers ──────────────────────────────────────────────────────────
private static string ExpandTilde(string path)
{
if (!path.StartsWith('~')) return path;
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return path.Length == 1 ? home : home + path[1..];
}
// Paths with ':' outside the volume-separator position are rejected (ADS, non-standard forms).
// Research doc 04 section 3 / S3.
private static bool HasNonStandardColon(string path)
{
// Extended-length prefix — strip it and evaluate the remainder (\\?\C:\ is valid).
var effective = path.StartsWith(@"\\?\", StringComparison.Ordinal) ? path[4..] : path;
// UNC paths (\\server\share) and extended UNC (\\?\UNC\...) have no drive colon — fine.
if (effective.StartsWith(@"\\", StringComparison.Ordinal)) return false;
var colonIdx = effective.IndexOf(':');
if (colonIdx < 0) return false; // no colon — fine
// Drive-letter form: single ASCII letter at index 0 followed by ':' — fine if no second colon.
// '1', '!' etc. at index 0 are not valid drive letters and must be rejected.
if (colonIdx == 1 && char.IsAsciiLetter(effective[0]))
return effective.IndexOf(':', 2) >= 0;
return true;
}
// Attempt 8.3 → long path normalization for paths that exist on disk.
// Only applied to resolved paths from PATH search (existence already confirmed).
// Research doc 04 section canonicalization / 8.3 short names.
private static string TryNormalizePath(string path)
{
// GetFullPath resolves . and .. but does not expand 8.3 short names.
// Full GetLongPathName P/Invoke is left as OQ-R1 in the research docs.
try { return Path.GetFullPath(path); }
catch { return path; } // hostile path must not throw out of resolution
}
}

View File

@ -1,28 +0,0 @@
using System;
using System.IO;
namespace OpenClaw.Shared.ExecApprovals;
// Utility helpers for command token classification.
internal static class ExecCommandToken
{
// Returns the lowercased last path component (basename) of a token, without extension.
internal static string BasenameLower(string token)
{
var trimmed = token.Trim();
if (trimmed.Length == 0) return string.Empty;
var name = Path.GetFileName(trimmed.Replace('\\', '/'));
if (name.Length == 0) name = trimmed;
return name.ToLowerInvariant();
}
// Returns the basename without .exe suffix (lowercased).
internal static string NormalizedBasename(string token)
{
var b = BasenameLower(token);
return b.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? b[..^4] : b;
}
internal static bool IsEnv(string token) =>
NormalizedBasename(token) == "env";
}

View File

@ -1,100 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace OpenClaw.Shared.ExecApprovals;
// Strips `env [OPTIONS] [VAR=VAL...] COMMAND [ARGS...]` so the true executable can be resolved.
// Fail-closed: returns null when any unknown flag is encountered or the command cannot be safely
// unwrapped. Mirrors ExecEnvInvocationUnwrapper in the windows-app reference.
internal static class ExecEnvInvocationUnwrapper
{
internal const int MaxWrapperDepth = 4;
private static readonly Regex s_envAssignment =
new(@"^[A-Za-z_][A-Za-z0-9_]*=", RegexOptions.Compiled);
// Strips one level of `env` wrapper.
// Returns the remaining argv starting at the real COMMAND token, or null on any ambiguity.
internal static IReadOnlyList<string>? Unwrap(IReadOnlyList<string> command)
{
var idx = 1;
var expectsOptionValue = false;
while (idx < command.Count)
{
var token = command[idx].Trim();
if (token.Length == 0) { idx++; continue; }
if (expectsOptionValue) { expectsOptionValue = false; idx++; continue; }
if (token == "--" || token == "-") { idx++; break; }
if (s_envAssignment.IsMatch(token)) { idx++; continue; }
if (token.StartsWith('-') && token != "-")
{
var lower = token.ToLowerInvariant();
var flag = lower.Split('=', 2)[0];
if (ExecEnvOptions.FlagOnly.Contains(flag)) { idx++; continue; }
if (ExecEnvOptions.WithValue.Contains(flag))
{
if (!lower.Contains('=')) expectsOptionValue = true;
idx++;
continue;
}
if (ExecEnvOptions.InlineValuePrefixes.Any(p => lower.StartsWith(p, StringComparison.Ordinal)))
{
idx++;
continue;
}
return null; // Unknown flag — fail-closed.
}
break; // Executable token found.
}
if (idx >= command.Count) return null;
return command.Skip(idx).ToList();
}
// Returns true when the env invocation has flags or VAR=val assignments before the command.
// `--` ends option processing without modifying the environment → not a modifier.
// `-` alone replaces the environment entirely → modifier.
internal static bool HasModifiers(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
var token = command[i].Trim();
if (token.Length == 0) continue;
if (token == "--") return false;
if (token == "-") return true;
if (token.StartsWith('-')) return true;
if (s_envAssignment.IsMatch(token)) return true;
return false; // first non-modifier token is the command
}
return false;
}
// Iteratively strips env wrappers for executable resolution only.
internal static IReadOnlyList<string> UnwrapForResolution(IReadOnlyList<string> command)
{
var current = command;
for (var depth = 0; depth < MaxWrapperDepth; depth++)
{
if (current.Count == 0) break;
var token = current[0].Trim();
if (token.Length == 0) break;
if (!ExecCommandToken.IsEnv(token)) break;
var unwrapped = Unwrap(current);
if (unwrapped is null || unwrapped.Count == 0) break;
current = unwrapped;
}
return current;
}
}

View File

@ -1,38 +0,0 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Option grammar of the POSIX `env` command.
// Mirrors the constants in the windows-app reference (ExecEnvOptions.cs).
internal static class ExecEnvOptions
{
// Options that consume the next argument as their value (or use inline = form).
internal static readonly HashSet<string> WithValue = new(System.StringComparer.Ordinal)
{
"-u", "--unset",
"-c", "--chdir",
"-s", "--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
};
// Options that are standalone flags (take no value at all).
internal static readonly HashSet<string> FlagOnly = new(System.StringComparer.Ordinal)
{
"-i", "--ignore-environment",
"-0", "--null",
};
// Prefixes for the inline-value form (e.g. `-uFOO` or `--unset=FOO`).
internal static readonly IReadOnlyList<string> InlineValuePrefixes =
[
"-u", "-c", "-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
];
}

View File

@ -1,118 +0,0 @@
using System;
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Single-level shell wrapper detection for the V2 exec approval pipeline.
// Differs from the legacy ExecShellWrapperParser.Expand (BFS multi-level, string-based).
// This normalizer operates on argv (IReadOnlyList<string>) and performs one level of
// wrapper detection, with recursive env-prefix unwrapping up to MaxWrapperDepth.
// Rail 18 step 2: normalize command form.
internal static class ExecShellWrapperNormalizer
{
private enum WrapperKind { Posix, Cmd, PowerShell }
private sealed record WrapperSpec(WrapperKind Kind, HashSet<string> Names);
private static readonly HashSet<string> s_posixInlineFlags =
new(StringComparer.OrdinalIgnoreCase) { "-lc", "-c", "--command" };
private static readonly HashSet<string> s_powerShellInlineFlags =
new(StringComparer.OrdinalIgnoreCase) { "-c", "-command", "--command" };
private static readonly WrapperSpec[] s_specs =
[
new(WrapperKind.Posix, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "ash", "sh", "bash", "zsh", "dash", "ksh", "fish" }),
new(WrapperKind.Cmd, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "cmd", "cmd.exe" }),
new(WrapperKind.PowerShell, new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "powershell", "powershell.exe", "pwsh", "pwsh.exe" }),
];
internal sealed record ParsedWrapper(bool IsWrapper, string? InlineCommand);
internal static readonly ParsedWrapper NotWrapper = new(false, null);
// Detects a single-level shell wrapper in argv.
// rawCommand is always null in Windows v1 (not in system.run protocol; research doc 05 OQ-V4).
// Detection is on argv only; rawCommand is accepted for API compatibility with future use.
internal static ParsedWrapper Extract(IReadOnlyList<string> command, string? rawCommand = null)
=> ExtractInner(command, rawCommand, 0);
private static ParsedWrapper ExtractInner(
IReadOnlyList<string> command, string? rawCommand, int depth)
{
if (depth >= ExecEnvInvocationUnwrapper.MaxWrapperDepth) return NotWrapper;
if (command.Count == 0) return NotWrapper;
var token0 = command[0].Trim();
if (token0.Length == 0) return NotWrapper;
// Recursively unwrap transparent env prefixes.
if (ExecCommandToken.IsEnv(token0))
{
var unwrapped = ExecEnvInvocationUnwrapper.Unwrap(command);
if (unwrapped is null) return NotWrapper;
return ExtractInner(unwrapped, rawCommand, depth + 1);
}
var basename = ExecCommandToken.NormalizedBasename(token0);
var spec = Array.Find(s_specs, s => s.Names.Contains(basename));
if (spec is null) return NotWrapper;
var payload = ExtractPayload(command, spec);
if (payload is null) return NotWrapper;
return new ParsedWrapper(true, payload);
}
private static string? ExtractPayload(IReadOnlyList<string> command, WrapperSpec spec) =>
spec.Kind switch
{
WrapperKind.Posix => ExtractPosixPayload(command),
WrapperKind.Cmd => ExtractCmdPayload(command),
WrapperKind.PowerShell => ExtractPowerShellPayload(command),
_ => null,
};
private static string? ExtractPosixPayload(IReadOnlyList<string> command)
{
if (command.Count < 2) return null;
var flag = command[1].Trim();
if (!s_posixInlineFlags.Contains(flag)) return null;
if (command.Count < 3) return null;
var payload = command[2].Trim();
return payload.Length == 0 ? null : payload;
}
private static string? ExtractCmdPayload(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
if (string.Equals(command[i].Trim(), "/c", StringComparison.OrdinalIgnoreCase))
{
var tail = string.Join(" ", command.Skip(i + 1)).Trim();
return tail.Length == 0 ? null : tail;
}
}
return null;
}
private static string? ExtractPowerShellPayload(IReadOnlyList<string> command)
{
for (var i = 1; i < command.Count; i++)
{
var t = command[i].Trim().ToLowerInvariant();
if (t.Length == 0) continue;
if (t == "--") break;
if (s_powerShellInlineFlags.Contains(t))
{
if (i + 1 >= command.Count) return null;
var payload = command[i + 1].Trim();
return payload.Length == 0 ? null : payload;
}
}
return null;
}
}

View File

@ -90,20 +90,9 @@ internal static class ExecEnvSanitizer
if (name.IndexOfAny(['=', '\0', '\r', '\n']) >= 0)
return true;
// Vectorized scan: any char in [0x00, 0x20] covers all ASCII control characters
// (0x010x1F) plus space (0x20) in a single SIMD pass — the common fast path for
// the ASCII-only names that make up virtually all environment variable keys.
var span = name.AsSpan();
if (span.IndexOfAnyInRange('\x00', '\x20') >= 0)
return true;
// DEL (0x7F) — control char outside the range above.
if (span.IndexOf('\x7F') >= 0)
return true;
// Non-ASCII Unicode control / whitespace (rare; UTF-8 env var names are uncommon).
for (var i = 0; i < name.Length; i++)
foreach (var c in name)
{
var c = name[i];
if (c > '\x7F' && (char.IsControl(c) || char.IsWhiteSpace(c)))
if (char.IsControl(c) || char.IsWhiteSpace(c))
return true;
}

View File

@ -135,26 +135,8 @@ internal static class ExecShellWrapperParser
for (var i = 1; i < tokens.Length; i++)
{
var option = tokens[i];
// Check for inline separator form first: -flag:value or -flag=value
var sepIdx = IndexOfFlagSeparator(option);
if (sepIdx > 0)
{
var flagPart = option[..sepIdx];
var valuePart = option[(sepIdx + 1)..];
if (IsCommandFlag(flagPart))
{
return string.IsNullOrWhiteSpace(valuePart)
? ("", shell, "Shell wrapper payload was empty")
: (valuePart, shell, null);
}
if (IsEncodedCommandFlag(flagPart))
return DecodeEncodedPayload(valuePart, shell);
}
if (IsCommandFlag(option))
if (option.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-c", StringComparison.OrdinalIgnoreCase))
{
var payload = string.Join(" ", tokens, i + 1, tokens.Length - i - 1).Trim();
return string.IsNullOrWhiteSpace(payload)
@ -162,68 +144,32 @@ internal static class ExecShellWrapperParser
: (payload, shell, null);
}
if (IsEncodedCommandFlag(option))
if (option.Equals("-EncodedCommand", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-enc", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-ec", StringComparison.OrdinalIgnoreCase))
{
var encoded = i + 1 < tokens.Length ? tokens[i + 1] : null;
return DecodeEncodedPayload(encoded, shell);
if (string.IsNullOrWhiteSpace(encoded))
return ("", shell, "Shell wrapper payload was empty");
try
{
var bytes = Convert.FromBase64String(encoded);
var payload = Encoding.Unicode.GetString(bytes).Trim();
return string.IsNullOrWhiteSpace(payload)
? ("", shell, "EncodedCommand decoded to an empty payload")
: (payload, shell, null);
}
catch (FormatException)
{
return ("", shell, "EncodedCommand could not be decoded");
}
}
}
return default;
}
// Returns the index of the first ':' or '=' in a flag token (after the leading '-').
private static int IndexOfFlagSeparator(string token)
{
for (var i = 1; i < token.Length; i++)
{
if (token[i] == ':' || token[i] == '=')
return i;
}
return -1;
}
// Matches -Command and -c (documented PowerShell -Command aliases).
private static bool IsCommandFlag(string flag) =>
flag.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
flag.Equals("-c", StringComparison.OrdinalIgnoreCase);
// Matches -e/-ec aliases and all unique prefix abbreviations of -EncodedCommand.
// Windows PowerShell accepts -e as EncodedCommand despite the apparent ambiguity with
// -ExecutionPolicy, so the parser must fail closed and decode it.
private static bool IsEncodedCommandFlag(string flag)
{
if (flag.Equals("-e", StringComparison.OrdinalIgnoreCase))
return true;
if (flag.Equals("-ec", StringComparison.OrdinalIgnoreCase))
return true;
const string fullFlag = "-encodedcommand";
return flag.Length >= 3 && // minimum: -en
flag.Length <= fullFlag.Length &&
fullFlag.StartsWith(flag, StringComparison.OrdinalIgnoreCase);
}
private static (string? Payload, string? Shell, string? Error) DecodeEncodedPayload(string? encoded, string shell)
{
if (string.IsNullOrWhiteSpace(encoded))
return ("", shell, "Shell wrapper payload was empty");
try
{
var bytes = Convert.FromBase64String(encoded);
var payload = Encoding.Unicode.GetString(bytes).Trim();
return string.IsNullOrWhiteSpace(payload)
? ("", shell, "EncodedCommand decoded to an empty payload")
: (payload, shell, null);
}
catch (FormatException)
{
return ("", shell, "EncodedCommand could not be decoded");
}
}
private static List<string> SplitTopLevelCommands(string command)
{
var parts = new List<string>();

View File

@ -1,25 +0,0 @@
using System;
namespace OpenClaw.Shared;
/// <summary>
/// Shared literal-host classifier for gateway URLs that point at the local machine.
/// </summary>
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;
}
}
}

View File

@ -96,7 +96,7 @@ public static class McpAuthToken
try
{
File.WriteAllText(tempPath, token, Encoding.UTF8);
TryRestrictSensitiveFileAcl(tempPath);
TryRestrictFileAcl(tempPath);
File.Move(tempPath, path, overwrite: true);
}
catch
@ -104,7 +104,7 @@ public static class McpAuthToken
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
throw;
}
TryRestrictSensitiveFileAcl(path);
TryRestrictFileAcl(path);
return token;
}
@ -127,7 +127,7 @@ public static class McpAuthToken
try
{
File.WriteAllText(tempPath, token, Encoding.UTF8);
TryRestrictSensitiveFileAcl(tempPath);
TryRestrictFileAcl(tempPath);
File.Move(tempPath, path, overwrite: true);
}
catch
@ -137,7 +137,7 @@ public static class McpAuthToken
}
// Move on Windows preserves the source's DACL; re-apply defensively in
// case a future rename strategy substitutes a different file.
TryRestrictSensitiveFileAcl(path);
TryRestrictFileAcl(path);
return token;
}
@ -183,9 +183,8 @@ public static class McpAuthToken
catch { /* best-effort; acl restriction is defense-in-depth, not load-bearing */ }
}
public static void TryRestrictSensitiveFileAcl(string path)
private static void TryRestrictFileAcl(string path)
{
if (string.IsNullOrEmpty(path)) return;
if (!OperatingSystem.IsWindows()) return;
try { RestrictFileAclWindows(path); }
catch { /* see above */ }

View File

@ -89,10 +89,11 @@ public sealed class McpHttpServer : IDisposable
_port = port;
_authToken = string.IsNullOrEmpty(authToken) ? null : authToken;
_listener = new HttpListener();
// Loopback binding — not reachable from other machines. Use only the
// numeric host on Windows so non-elevated startup does not require a
// separate netsh http urlacl reservation for http://localhost:port/.
// Loopback binding — not reachable from other machines.
// Register both numeric and hostname forms so clients that connect
// via http://localhost:port/ (common on Linux/macOS) are also served.
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
_listener.Prefixes.Add($"http://localhost:{port}/");
}
public void Start()

View File

@ -236,40 +236,9 @@ public class McpToolBridge
["camera.clip"] =
"Record a short clip from a camera. Args: deviceId (string, optional), durationMs (int, required, max 60000), format ('mp4'|'webm', default 'mp4'), maxWidth (int, default 1280). Returns { format, durationMs, base64 }.",
// stt.* — microphone capture → text. Default-off; privacy-sensitive.
// Single engine: Whisper.net runs locally on the device.
["stt.transcribe"] =
"Capture microphone audio for a bounded duration and return the transcribed text. Args: maxDurationMs (int, required, > 0, max 30000), language (string, optional BCP-47 tag like 'en-US' or 'auto' — falls back to the configured SttLanguage setting). Returns { transcribed, text, durationMs, language, engineEffective ('whisper') }. Whisper model is downloaded on first use; until then this returns an error pointing to Voice Settings. Requires NodeSttEnabled.",
["stt.listen"] =
"Capture microphone audio with voice-activity detection and return when the user stops speaking, or after timeoutMs. Args: timeoutMs (int, optional, default 30000, range 1000..120000), language (string, optional BCP-47 tag or 'auto', default 'auto'). Returns { text, language, durationMs, segments[{ text, startMs, endMs }], engineEffective ('whisper') }. Result is the full silence-bounded utterance (all Whisper segments concatenated), not a partial first segment. Requires NodeSttEnabled.",
["stt.status"] =
"Report STT engine readiness. No args. Returns { engine ('whisper'), readiness ('ready'|'initializing'|'model-downloading'|'model-not-downloaded'|'unavailable'), modelDownloadProgress (0..1 or null), isListenWithVadSupported (bool), isBoundedTranscribeSupported (bool) }. Carries no PII (no transcript history, no language history, no device IDs, no model paths).",
// tts.*
["tts.speak"] =
"Speak text aloud on the Windows node. Args: text (string, required), provider ('piper'|'windows'|'elevenlabs', optional — falls back to the configured TtsProvider setting, default 'piper' for fresh installs), voiceId (string, optional — overrides the per-provider configured voice), model (string, optional, ElevenLabs only), interrupt (bool, default false — interrupts any in-progress playback). Returns { spoken, provider, contentType, durationMs }.",
// app.*
["app.navigate"] =
"Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.",
["app.status"] =
"Get current connection status, node state, and gateway info. Returns { connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }.",
["app.sessions"] =
"List active sessions with optional agent filter. Args: agentId (string, optional). Returns array of { Key, Status, Model, AgeText, tokens }.",
["app.agents"] =
"List agents from the connected gateway. Returns the raw agents JSON array.",
["app.nodes"] =
"List connected nodes and their capabilities. Returns array of { DisplayName, NodeId, IsOnline, Platform, CapabilityCount }.",
["app.config.get"] =
"Read gateway configuration value at a dot-path. Args: path (string, optional). Returns the config subtree or full config.",
["app.settings.get"] =
"Read a local app setting by name. Args: name (string, required). Returns the setting value.",
["app.settings.set"] =
"Set a local app setting (name and value). Args: name (string, required), value (string, required). Returns { name, value }.",
["app.menu"] =
"Get tray menu state (status, session count, node count). Returns array of menu items.",
["app.search"] =
"Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.",
"Speak text aloud on the Windows node. Args: text (string, required), provider ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.",
};
private async Task<object> HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken)

View File

@ -5,8 +5,6 @@ namespace OpenClaw.Shared;
/// </summary>
public static class MenuSizingHelper
{
private const double ScaleTolerance = 0.001;
public static int ConvertPixelsToViewUnits(int pixels, uint dpi)
{
if (pixels <= 0) return 0;
@ -15,19 +13,6 @@ public static class MenuSizingHelper
return Math.Max(1, (int)Math.Floor(pixels * 96.0 / dpi));
}
public static bool HasDpiOrScaleChanged(uint previousDpi, double previousRasterizationScale, uint currentDpi, double currentRasterizationScale)
{
previousDpi = NormalizeDpi(previousDpi);
currentDpi = NormalizeDpi(currentDpi);
if (previousDpi != currentDpi)
return true;
var previousScale = NormalizeScale(previousRasterizationScale);
var currentScale = NormalizeScale(currentRasterizationScale);
return Math.Abs(previousScale - currentScale) > ScaleTolerance;
}
public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100)
{
if (contentHeight < 0) contentHeight = 0;
@ -40,9 +25,4 @@ public static class MenuSizingHelper
var desiredHeight = Math.Max(contentHeight, minimumVisibleHeight);
return Math.Min(desiredHeight, workAreaHeight);
}
private static uint NormalizeDpi(uint dpi) => dpi == 0 ? 96u : dpi;
private static double NormalizeScale(double scale) =>
double.IsFinite(scale) && scale > 0 ? scale : 1.0;
}

View File

@ -762,7 +762,7 @@ public static class PermissionDiagnostics
{
Name = "Microphone",
Status = "review",
Detail = "Required for camera clips with audio and for stt.transcribe speech-to-text capture.",
Detail = "Required only for camera clips with audio or future voice features.",
SettingsUri = "ms-settings:privacy-microphone"
},
new()
@ -1019,7 +1019,7 @@ public static class CommandCenterCommandGroups
public static readonly FrozenSet<string> SafeCompanionCommandSet =
SafeCompanionCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static readonly string[] CommonDangerousCommands =
public static readonly string[] DangerousCommands =
[
"camera.snap",
"camera.clip",
@ -1027,14 +1027,6 @@ public static class CommandCenterCommandGroups
"tts.speak"
];
public static readonly string[] DangerousCommands =
[
.. CommonDangerousCommands,
"stt.transcribe",
"stt.listen",
"stt.status"
];
public static readonly FrozenSet<string> DangerousCommandSet =
DangerousCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
@ -1243,7 +1235,7 @@ public static class CommandCenterDiagnostics
Severity = GatewayDiagnosticSeverity.Info,
Category = "allowlist",
Title = "Privacy-sensitive commands are currently blocked",
Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.",
Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera or screen recording access for this node.",
RepairAction = "Copy opt-in guidance",
CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands)
});
@ -1513,323 +1505,3 @@ internal static class ModelFormatting
}
}
// ── Agent Events ──
/// <summary>Raw agent event from gateway broadcast.</summary>
public class AgentEventInfo
{
public string RunId { get; set; } = "";
public int Seq { get; set; }
public string Stream { get; set; } = "";
public double Ts { get; set; }
public JsonElement Data { get; set; }
public string? SessionKey { get; set; }
public string? Summary { get; set; }
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
/// <summary>Resolved event kind — for "item" stream events, uses data.kind instead.</summary>
public string ResolvedStream
{
get
{
var s = Stream.ToLowerInvariant();
if (s == "item" && Data.ValueKind == JsonValueKind.Object &&
Data.TryGetProperty("kind", out var k))
{
return k.GetString()?.ToLowerInvariant() ?? s;
}
return s;
}
}
public string StreamUpper => ResolvedStream.ToUpperInvariant();
/// <summary>Color hex for stream badge (used by UI to create brush).</summary>
public string BadgeColorHex => ResolvedStream switch
{
"tool" => "#FFB45D3A", // Burnt sienna
"assistant" => "#FF28A050", // Green
"error" => "#FFC83232", // Red
"lifecycle" => "#FF3C78C8", // Blue
"plan" => "#FF8C50C8", // Purple
"approval" => "#FFC8A01E", // Amber
"thinking" => "#FF648CB4", // Steel
"patch" => "#FF50A0A0", // Teal
_ => "#FF646464" // Gray
};
/// <summary>Human-readable summary extracted from event data.</summary>
public string SummaryLine
{
get
{
if (!string.IsNullOrEmpty(Summary)) return Summary;
try
{
var s = ResolvedStream;
if (s == "tool" && Data.ValueKind == JsonValueKind.Object)
{
var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null;
var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null;
var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null;
var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null;
// Prefer title (richer) over just name
if (title != null)
return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}";
if (name != null)
return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
}
if (s == "assistant" && Data.ValueKind == JsonValueKind.Object)
{
var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null;
if (text != null) return text.Length > 300 ? text[..300] + "…" : text;
}
if (s == "error" && Data.ValueKind == JsonValueKind.Object)
{
var msg = Data.TryGetProperty("message", out var m) ? m.GetString()
: Data.TryGetProperty("error", out var e) ? e.GetString() : null;
if (msg != null) return $"❌ {msg}";
}
if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object)
{
var state = Data.TryGetProperty("state", out var st) ? st.GetString()
: Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null;
var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null;
if (state != null)
return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}";
}
}
catch { }
return "";
}
}
public bool HasSummary => !string.IsNullOrEmpty(SummaryLine);
/// <summary>Full assistant message text (no truncation), for expanded view.</summary>
public string? FullAssistantText
{
get
{
if (ResolvedStream != "assistant" || Data.ValueKind != JsonValueKind.Object) return null;
try { return Data.TryGetProperty("text", out var t) ? t.GetString() : null; }
catch { return null; }
}
}
/// <summary>Whether this event is an assistant stream (expanded view shows full text instead of JSON).</summary>
public bool IsAssistantStream => ResolvedStream == "assistant";
/// <summary>Whether to show the raw DataJson section. Hidden for streams where SummaryLine is sufficient.</summary>
public bool ShowDataJson
{
get
{
var s = ResolvedStream;
if (s is "assistant" or "error" or "lifecycle") return false;
return true;
}
}
// UI-only state for expand/collapse (not serialized)
[System.Text.Json.Serialization.JsonIgnore]
public bool IsExpanded { get; set; }
private string? _cachedDataJson;
public string DataJson
{
get
{
if (_cachedDataJson != null) return _cachedDataJson;
try
{
_cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
}
catch
{
_cachedDataJson = Data.ToString() ?? "{}";
}
return _cachedDataJson;
}
}
}
// ── Node/Device Pairing ──
public class PairingRequest
{
public string RequestId { get; set; } = "";
public string NodeId { get; set; } = "";
public string? DisplayName { get; set; }
public string? Platform { get; set; }
public string? Version { get; set; }
public string? RemoteIp { get; set; }
public bool IsRepair { get; set; }
public double Ts { get; set; }
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
public string Description
{
get
{
var lines = new List<string>();
lines.Add($"Node: {DisplayName ?? NodeId}");
if (!string.IsNullOrEmpty(Platform)) lines.Add($"Platform: {Platform}");
if (!string.IsNullOrEmpty(Version)) lines.Add($"Version: {Version}");
if (!string.IsNullOrEmpty(RemoteIp)) lines.Add($"IP: {RemoteIp}");
if (IsRepair) lines.Add("Repair: yes");
return string.Join("\n", lines);
}
}
}
public class DevicePairingRequest
{
public string RequestId { get; set; } = "";
public string DeviceId { get; set; } = "";
public string? PublicKey { get; set; }
public string? DisplayName { get; set; }
public string? Platform { get; set; }
public string? ClientId { get; set; }
public string? ClientMode { get; set; }
public string? Role { get; set; }
public string[]? Scopes { get; set; }
public string? RemoteIp { get; set; }
public bool IsRepair { get; set; }
public double Ts { get; set; }
public DateTime Timestamp => DateTimeOffset.FromUnixTimeMilliseconds((long)Ts).LocalDateTime;
public string Description
{
get
{
var lines = new List<string>();
lines.Add($"Device: {DisplayName ?? DeviceId}");
if (!string.IsNullOrEmpty(Platform)) lines.Add($"Platform: {Platform}");
if (!string.IsNullOrEmpty(Role)) lines.Add($"Role: {Role}");
if (Scopes is { Length: > 0 }) lines.Add($"Scopes: {string.Join(", ", Scopes)}");
if (!string.IsNullOrEmpty(RemoteIp)) lines.Add($"IP: {RemoteIp}");
if (IsRepair) lines.Add("Repair: yes");
return string.Join("\n", lines);
}
}
}
public class PairingListInfo
{
public List<PairingRequest> Pending { get; set; } = new();
}
public class DevicePairingListInfo
{
public List<DevicePairingRequest> Pending { get; set; } = new();
}
// ── Models List ──
public class ModelInfo
{
public string Id { get; set; } = "";
public string? Name { get; set; }
public string? Provider { get; set; }
public int? ContextWindow { get; set; }
public bool IsConfigured { get; set; }
public string DisplayName => Name ?? Id;
}
public class ModelsListInfo
{
public List<ModelInfo> Models { get; set; } = new();
}
// ── Agent Info ──
public class AgentInfo
{
public string Id { get; set; } = "";
public string? Name { get; set; }
public string? Emoji { get; set; }
public string? Workspace { get; set; }
public string? ModelPrimary { get; set; }
public string DisplayName => Name ?? Id;
}
// ── Presence (connected clients/instances) ──
public class PresenceEntry
{
public string? Host { get; set; }
public string? Ip { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? DeviceFamily { get; set; }
public string? ModelIdentifier { get; set; }
public string? Mode { get; set; }
public int? LastInputSeconds { get; set; }
public string? Reason { get; set; }
public string[]? Tags { get; set; }
public string? Text { get; set; }
public long Ts { get; set; }
public string? DeviceId { get; set; }
public string[]? Roles { get; set; }
public string[]? Scopes { get; set; }
public string? InstanceId { get; set; }
public string DisplayName => Host ?? DeviceId ?? Ip ?? "Unknown";
public DateTime Timestamp => DateTimeOffset.FromUnixTimeSeconds(Ts).LocalDateTime;
public string PlatformLabel => Platform ?? "unknown";
public string ModeLabel => Mode ?? "unknown";
public string LastSeenText
{
get
{
if (LastInputSeconds is not { } secs) return "";
if (secs < 60) return $"{secs}s ago";
if (secs < 3600) return $"{secs / 60}m ago";
return $"{secs / 3600}h ago";
}
}
}
// ── Gateway Discovery ──
public class DiscoveredGateway
{
public string Id { get; set; } = "";
public string DisplayName { get; set; } = "";
public string? Host { get; set; }
public int Port { get; set; }
public string? LanHost { get; set; }
public string? TailnetDns { get; set; }
public bool TlsEnabled { get; set; }
public string? TlsFingerprint { get; set; }
public string ConnectionUrl
{
get
{
var scheme = TlsEnabled ? "wss" : "ws";
var host = Host ?? LanHost ?? "localhost";
return $"{scheme}://{host}:{Port}";
}
}
public string HttpUrl
{
get
{
var scheme = TlsEnabled ? "https" : "http";
var host = Host ?? LanHost ?? "localhost";
return $"{scheme}://{host}:{Port}";
}
}
}

View File

@ -15,13 +15,6 @@
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
</ItemGroup>
<!-- Audio / Speech-to-Text (platform-agnostic components) -->
<ItemGroup>
<PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.25.1" />
</ItemGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@ -16,14 +16,8 @@ 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; } = true;
public bool AutoStart { get; set; }
public bool GlobalHotkeyEnabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public bool HasInjectedFirstRunBootstrap { get; set; }
public bool ShowNotifications { get; set; } = true;
public string? NotificationSound { get; set; }
public bool NotifyHealth { get; set; } = true;
@ -38,37 +32,13 @@ public class SettingsData
public bool NodeCanvasEnabled { get; set; } = true;
public bool NodeScreenEnabled { get; set; } = true;
public bool NodeCameraEnabled { get; set; } = true;
public bool ScreenRecordingConsentGiven { get; set; } = false;
public bool CameraRecordingConsentGiven { get; set; } = false;
public bool NodeLocationEnabled { get; set; } = true;
public bool NodeBrowserProxyEnabled { get; set; } = true;
public bool NodeSttEnabled { get; set; } = false;
/// <summary>STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US".</summary>
public string SttLanguage { get; set; } = "auto";
/// <summary>Whisper model name: "tiny", "base", or "small".</summary>
public string SttModelName { get; set; } = "base";
/// <summary>Seconds of silence before auto-submit in voice chat mode.</summary>
public float SttSilenceTimeout { get; set; } = 2.5f;
/// <summary>Enable TTS playback of responses during voice sessions.</summary>
public bool VoiceTtsEnabled { get; set; } = true;
/// <summary>Play audio feedback chimes on listen start/stop.</summary>
public bool VoiceAudioFeedback { get; set; } = true;
public bool NodeTtsEnabled { get; set; } = false;
public string TtsProvider { get; set; } = OpenClaw.Shared.Capabilities.TtsCapability.PiperProvider;
/// <summary>Persisted: whether the Hub's NavigationView pane is expanded
/// (true) or collapsed/compact (false). Default true.</summary>
public bool HubNavPaneOpen { get; set; } = true;
/// <summary>Optional Windows TTS voice id (or display name). Empty = system default.</summary>
public string? TtsWindowsVoiceId { get; set; }
/// <summary>
/// ElevenLabs API key storage slot. When persisted by the Windows tray's
/// SettingsManager this is an opaque dpapi:-prefixed blob, not plaintext.
/// </summary>
public string TtsProvider { get; set; } = "windows";
public string? TtsElevenLabsApiKey { get; set; }
public string? TtsElevenLabsModel { get; set; }
public string? TtsElevenLabsVoiceId { get; set; }
/// <summary>Piper voice identifier, e.g. "en_US-amy-low". Voice file is downloaded on first use.</summary>
public string TtsPiperVoiceId { get; set; } = "en_US-amy-low";
/// <summary>Run the local MCP HTTP server. Independent of EnableNodeMode.</summary>
public bool EnableMcpServer { get; set; } = false;
/// <summary>
@ -83,15 +53,12 @@ public class SettingsData
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? McpOnlyMode { get; set; }
public string? PreferredGatewayId { get; set; }
public bool HasSeenActivityStreamTip { get; set; } = false;
public string? SkippedUpdateTag { get; set; }
public bool NotifyChatResponses { get; set; } = true;
public bool PreferStructuredCategories { get; set; } = true;
public List<UserNotificationRule>? UserRules { get; set; }
// ── (Voice / STT settings consolidated into the block above.) ──
private static readonly JsonSerializerOptions s_options = new()
{
WriteIndented = true,

View File

@ -8,16 +8,6 @@ public static class SshTunnelCommandLine
private static readonly Regex s_validSshUser = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
private static readonly Regex s_validSshHost = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
// Fixed SSH options shared by every tunnel invocation.
// Centralised here so the connection policy is visible and easy to review or adjust.
private const string BaseOptions =
"-o BatchMode=yes " +
"-o ExitOnForwardFailure=yes " +
"-o ServerAliveInterval=15 " +
"-o ServerAliveCountMax=3 " +
"-o TCPKeepAlive=yes " +
"-N ";
public static string BuildArguments(string user, string host, int remotePort, int localPort)
=> BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward: false);
@ -43,7 +33,13 @@ public static class SshTunnelCommandLine
ValidateBrowserProxyPort(localPort, nameof(localPort));
}
var sb = new StringBuilder(BaseOptions);
var sb = new StringBuilder();
sb.Append("-o BatchMode=yes ");
sb.Append("-o ExitOnForwardFailure=yes ");
sb.Append("-o ServerAliveInterval=15 ");
sb.Append("-o ServerAliveCountMax=3 ");
sb.Append("-o TCPKeepAlive=yes ");
sb.Append("-N ");
AppendLocalForward(sb, localPort, remotePort);
if (includeBrowserProxyForward)
AppendLocalForward(sb, localPort + 2, remotePort + 2);

View File

@ -12,10 +12,6 @@ public static class TokenSanitizer
@"""(?<key>[^""]*(?:token|secret|bearer|authorization)[^""]*)""\s*:\s*""(?<value>[^""]+)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly Regex BareGatewayHexTokenPattern = new(
@"(?<![0-9A-Fa-f])[0-9a-f]{64}(?![0-9A-Fa-f])",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex LongBase64UrlPattern = new(
@"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
@ -29,7 +25,6 @@ public static class TokenSanitizer
sanitized = JsonSecretFieldPattern.Replace(
sanitized,
match => $"\"{match.Groups["key"].Value}\":\"[REDACTED]\"");
sanitized = BareGatewayHexTokenPattern.Replace(sanitized, "[REDACTED_TOKEN]");
return LongBase64UrlPattern.Replace(sanitized, "[REDACTED_TOKEN]");
}
}

View File

@ -251,10 +251,6 @@ public abstract class WebSocketClientBase : IDisposable
while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
{
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
// Add 0-25% jitter to prevent thundering herd when multiple clients
// (operator + node) reconnect on the same schedule
var jitter = Random.Shared.Next(0, delay / 4);
delay += jitter;
_reconnectAttempts++;
_logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
RaiseStatusChanged(ConnectionStatus.Connecting);

View File

@ -30,14 +30,6 @@ public class WindowsNodeClient : WebSocketClientBase
private bool _isPaired;
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
private bool _pairingApprovedAwaitingReconnect;
// Persists across disconnect/error so ShouldAutoReconnect can block reconnect
// even after OnDisconnected clears _isPendingApproval.
private volatile bool _pairingBlocked;
private volatile bool _rateLimited;
// 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;
@ -65,7 +57,7 @@ public class WindowsNodeClient : WebSocketClientBase
public bool IsPendingApproval => _isPendingApproval;
/// <summary>True if device is paired via a stored token or an explicit gateway approval event.</summary>
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
/// <summary>Device ID for display/approval (first 16 chars of full ID)</summary>
public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16
@ -82,7 +74,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), logger)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger)
{
_gatewayToken = NormalizeOptionalCredential(token);
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
@ -106,14 +98,8 @@ public class WindowsNodeClient : WebSocketClientBase
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
}
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger)
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath)
{
var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger);
if (!string.IsNullOrEmpty(storedNodeToken))
{
return storedNodeToken;
}
var gatewayToken = NormalizeOptionalCredential(token);
if (!string.IsNullOrEmpty(gatewayToken))
{
@ -126,27 +112,14 @@ public class WindowsNodeClient : WebSocketClientBase
return bootstrap;
}
var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(dataPath);
if (!string.IsNullOrEmpty(storedDeviceToken))
{
return 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
{
var identity = new DeviceIdentity(dataPath, logger);
identity.Initialize();
return string.IsNullOrWhiteSpace(identity.NodeDeviceToken) ? null : identity.NodeDeviceToken;
}
catch
{
return null;
}
}
/// <summary>
/// Register a capability handler
@ -213,7 +186,7 @@ public class WindowsNodeClient : WebSocketClientBase
try
{
// Log raw messages at debug level (visible in dbgview, not in log file noise)
_logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}");
_logger.Debug($"[NODE RX] {json}");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
@ -304,12 +277,11 @@ public class WindowsNodeClient : WebSocketClientBase
_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info($"[NODE] Pairing requested for this device via {eventType}");
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
$"Run: openclaw devices approve {ShortDeviceId}..."));
@ -338,10 +310,9 @@ public class WindowsNodeClient : WebSocketClientBase
{
_isPendingApproval = false;
_isPaired = true;
_pairingBlocked = false; // Allow reconnect after approval
_pairingApprovedAwaitingReconnect = true;
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId,
"Pairing approved; reconnecting to refresh node state."));
@ -357,7 +328,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = false;
_pairingApprovedAwaitingReconnect = false;
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Rejected,
_deviceIdentity.DeviceId,
null));
@ -528,7 +499,7 @@ public class WindowsNodeClient : WebSocketClientBase
private async Task SendNodeConnectAsync(string? nonce, long ts)
{
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken);
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})");
@ -598,9 +569,9 @@ public class WindowsNodeClient : WebSocketClientBase
private (Dictionary<string, string> Auth, string TokenForSignature) BuildConnectAuth()
{
if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
{
return (new Dictionary<string, string> { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken);
return (new Dictionary<string, string> { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken);
}
if (!string.IsNullOrEmpty(_bootstrapToken))
@ -632,7 +603,6 @@ public class WindowsNodeClient : WebSocketClientBase
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect;
_isConnected = true;
_rateLimited = false; // Clear transient rate-limit on successful connect
ResetReconnectAttempts();
// Extract node ID if returned
@ -657,8 +627,8 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info("Received device token - we are now paired!");
_deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload));
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
_deviceIdentity.StoreDeviceToken(deviceToken);
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId,
wasWaiting ? "Pairing approved!" : null));
@ -671,7 +641,7 @@ public class WindowsNodeClient : WebSocketClientBase
// Skip this block if we already fired PairingStatusChanged above via gotNewToken.
if (!gotNewToken)
{
if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
{
if (reconnectingAfterApproval)
{
@ -684,10 +654,9 @@ public class WindowsNodeClient : WebSocketClientBase
{
_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
$"Run: openclaw devices approve {ShortDeviceId}..."));
@ -699,7 +668,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info("Already paired with stored device token");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId));
}
@ -709,22 +678,6 @@ public class WindowsNodeClient : WebSocketClientBase
}
}
/// <summary>
/// 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).
/// </summary>
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";
@ -764,7 +717,6 @@ public class WindowsNodeClient : WebSocketClientBase
_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;
var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
@ -772,26 +724,14 @@ public class WindowsNodeClient : WebSocketClientBase
: $"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}");
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
detail));
return;
}
// Rate-limit / terminal auth errors — stop reconnecting
if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) ||
error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) ||
error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) ||
error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
{
_rateLimited = true;
_logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {TokenSanitizer.Sanitize(error)}");
RaiseStatusChanged(ConnectionStatus.Error);
return;
}
_logger.Error($"Node registration failed: {TokenSanitizer.Sanitize(error)} (code: {errorCode})");
_logger.Error($"Node registration failed: {error} (code: {errorCode})");
RaiseStatusChanged(ConnectionStatus.Error);
}
@ -839,27 +779,6 @@ public class WindowsNodeClient : WebSocketClientBase
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<string>();
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)
{
@ -1011,8 +930,16 @@ public class WindowsNodeClient : WebSocketClientBase
}
/// <summary>
/// Sends a node.event request with JSON payload.
/// Returns false when not connected or when the transport send fails.
/// Send a generic node-event to the gateway. Mirrors the Android
/// <c>GatewaySession.sendNodeEvent</c> wire shape: a JSON-RPC request with
/// method <c>node.event</c> and params <c>{ event, payloadJSON }</c>,
/// where <c>payloadJSON</c> is the inner payload as a *string*, not a
/// nested object. The gateway's node-event dispatcher
/// (<c>server-node-events.ts</c>) 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.
/// </summary>
public async Task<bool> SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload)
{
@ -1020,6 +947,9 @@ public class WindowsNodeClient : WebSocketClientBase
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",
@ -1067,20 +997,6 @@ public class WindowsNodeClient : WebSocketClientBase
GatewaySelfUpdated?.Invoke(this, info);
}
protected override bool ShouldAutoReconnect()
{
// Don't reconnect while awaiting pairing approval — each reconnect
// generates a new pairing request on the gateway, causing a storm.
// _pairingBlocked survives OnDisconnected (which clears _isPendingApproval).
if (_pairingBlocked)
return false;
if (_rateLimited)
return false;
return true;
}
protected override void OnDisconnected()
{
_isConnected = false;

View File

@ -14,9 +14,6 @@
<SolidColorBrush x:Key="LobsterAccentBrush" Color="#E74C3C" />
<SolidColorBrush x:Key="LobsterAccentBrushHover" Color="#C0392B" />
<!-- Hub NavigationView accent -->
<SolidColorBrush x:Key="NavigationViewSelectionIndicatorForeground" Color="#E74C3C"/>
<!-- Custom Button Style -->
<Style x:Key="AccentButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource LobsterAccentBrush}" />

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<UserControl
x:Class="OpenClawTray.Controls.SchemaConfigEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="FieldsPanel" Spacing="4" Padding="0"/>
</ScrollViewer>
</UserControl>

View File

@ -1,481 +0,0 @@
using Microsoft.UI;
using Microsoft.UI.Text;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace OpenClawTray.Controls;
public sealed partial class SchemaConfigEditor : UserControl
{
private JsonElement _schema;
private JsonElement _config;
private readonly Dictionary<string, object?> _changes = new();
private static readonly Regex CamelCaseSplitPattern = new(
"([a-z])([A-Z])",
RegexOptions.Compiled);
private static readonly SolidColorBrush SecondaryBrush =
new(ColorHelper.FromArgb(255, 140, 150, 170));
public event EventHandler<Dictionary<string, object?>>? ConfigChanged;
public SchemaConfigEditor()
{
InitializeComponent();
}
public void LoadSchema(JsonElement schema, JsonElement config)
{
_schema = schema;
_config = config;
_changes.Clear();
FieldsPanel.Children.Clear();
try
{
RenderSchemaNode("", schema, config, FieldsPanel, 0);
}
catch { }
// If schema rendering produced nothing, fall back to rendering config as editable fields
if (FieldsPanel.Children.Count == 0 && config.ValueKind == JsonValueKind.Object)
{
RenderConfigDirectly("", config, FieldsPanel, 0);
}
}
public Dictionary<string, object?> GetChanges() => new(_changes);
/// <summary>
/// JSON Schema's "type" keyword may be either a string ("object") or an
/// array of strings (["string","null"]). Returns the first non-null type
/// when an array is encountered, or null if "type" is missing/unsupported.
/// </summary>
private static string? ExtractSchemaType(JsonElement schemaNode)
{
if (!schemaNode.TryGetProperty("type", out var typeEl)) return null;
if (typeEl.ValueKind == JsonValueKind.String) return typeEl.GetString();
if (typeEl.ValueKind == JsonValueKind.Array)
{
foreach (var item in typeEl.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String) continue;
var s = item.GetString();
if (!string.IsNullOrEmpty(s) && s != "null") return s;
}
}
return null;
}
private static string? SafeGetString(JsonElement parent, string propName)
{
if (!parent.TryGetProperty(propName, out var el)) return null;
return el.ValueKind == JsonValueKind.String ? el.GetString() : null;
}
private void RenderSchemaNode(string path, JsonElement schema, JsonElement config,
StackPanel parent, int depth)
{
if (ExtractSchemaType(schema) == "object"
&& schema.TryGetProperty("properties", out var props))
{
foreach (var prop in props.EnumerateObject())
{
var childPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}";
var childConfig = config.ValueKind == JsonValueKind.Object
&& config.TryGetProperty(prop.Name, out var cv)
? cv
: default;
var childSchema = prop.Value;
var childType = ExtractSchemaType(childSchema);
if (childType == "object" && childSchema.TryGetProperty("properties", out _))
{
RenderObjectSection(childPath, prop.Name, childSchema, childConfig, parent, depth);
}
else
{
RenderField(childPath, prop.Name, childSchema, childConfig, parent);
}
}
}
}
private void RenderObjectSection(string path, string name, JsonElement schema,
JsonElement config, StackPanel parent, int depth)
{
var title = GetLabel(path, name);
var description = SafeGetString(schema, "description");
var expander = new Expander
{
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
IsExpanded = true,
Margin = new Thickness(0, 2, 0, 2)
};
var headerPanel = new StackPanel { Spacing = 2 };
headerPanel.Children.Add(new TextBlock
{
Text = title,
FontWeight = FontWeights.SemiBold
});
if (!string.IsNullOrEmpty(description))
{
headerPanel.Children.Add(new TextBlock
{
Text = description,
FontSize = 11,
Foreground = SecondaryBrush,
TextWrapping = TextWrapping.Wrap
});
}
expander.Header = headerPanel;
var childPanel = new StackPanel { Spacing = 4, Padding = new Thickness(0, 4, 0, 4) };
RenderSchemaNode(path, schema, config, childPanel, depth + 1);
expander.Content = childPanel;
parent.Children.Add(expander);
}
private void RenderField(string path, string name, JsonElement schema,
JsonElement config, StackPanel parent)
{
var label = GetLabel(path, name);
var description = SafeGetString(schema, "description");
var type = ExtractSchemaType(schema) ?? "string";
var isSensitive = IsSensitive(path);
// Resolve default value if config is missing
var effectiveConfig = config;
if (effectiveConfig.ValueKind == JsonValueKind.Undefined
&& schema.TryGetProperty("default", out var defaultVal))
{
effectiveConfig = defaultVal;
}
UIElement control;
if (schema.TryGetProperty("enum", out var enumEl)
&& enumEl.ValueKind == JsonValueKind.Array)
{
control = RenderEnumField(path, label, description, enumEl, effectiveConfig);
}
else if (type == "boolean")
{
control = RenderBoolField(path, label, description, effectiveConfig);
}
else if (type == "integer" || type == "number")
{
control = RenderNumberField(path, label, description, type!, schema, effectiveConfig);
}
else if (type == "array" && schema.TryGetProperty("items", out var itemsSchema))
{
control = RenderArrayField(path, label, description, itemsSchema, effectiveConfig);
}
else // string (default)
{
control = isSensitive
? RenderSensitiveField(path, label, description, effectiveConfig)
: RenderStringField(path, label, description, effectiveConfig);
}
parent.Children.Add(control);
}
private UIElement RenderEnumField(string path, string label, string? description,
JsonElement enumEl, JsonElement config)
{
var combo = new ComboBox { Header = label, MinWidth = 200 };
if (!string.IsNullOrEmpty(description))
ToolTipService.SetToolTip(combo, description);
var currentVal = config.ValueKind == JsonValueKind.String ? config.GetString() : null;
foreach (var item in enumEl.EnumerateArray())
{
var val = item.GetString() ?? "";
combo.Items.Add(val);
if (val == currentVal) combo.SelectedItem = val;
}
combo.SelectionChanged += (s, e) =>
{
_changes[path] = combo.SelectedItem as string;
ConfigChanged?.Invoke(this, _changes);
};
return combo;
}
private UIElement RenderBoolField(string path, string label, string? description,
JsonElement config)
{
var toggle = new ToggleSwitch { Header = label };
if (!string.IsNullOrEmpty(description))
ToolTipService.SetToolTip(toggle, description);
toggle.IsOn = config.ValueKind == JsonValueKind.True;
toggle.Toggled += (s, e) =>
{
_changes[path] = toggle.IsOn;
ConfigChanged?.Invoke(this, _changes);
};
return toggle;
}
private UIElement RenderNumberField(string path, string label, string? description,
string type, JsonElement schema, JsonElement config)
{
var numBox = new NumberBox
{
Header = label,
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact,
MinWidth = 200
};
if (!string.IsNullOrEmpty(description))
ToolTipService.SetToolTip(numBox, description);
if (config.ValueKind == JsonValueKind.Number)
numBox.Value = config.GetDouble();
if (schema.TryGetProperty("minimum", out var min))
numBox.Minimum = min.GetDouble();
if (schema.TryGetProperty("maximum", out var max))
numBox.Maximum = max.GetDouble();
numBox.ValueChanged += (s, e) =>
{
_changes[path] = type == "integer" ? (object)(int)numBox.Value : numBox.Value;
ConfigChanged?.Invoke(this, _changes);
};
return numBox;
}
private UIElement RenderStringField(string path, string label, string? description,
JsonElement config)
{
var textBox = new TextBox { Header = label, MinWidth = 300 };
if (!string.IsNullOrEmpty(description))
ToolTipService.SetToolTip(textBox, description);
if (config.ValueKind == JsonValueKind.String)
textBox.Text = config.GetString() ?? "";
else if (config.ValueKind != JsonValueKind.Undefined
&& config.ValueKind != JsonValueKind.Null)
textBox.Text = config.ToString();
textBox.TextChanged += (s, e) =>
{
_changes[path] = textBox.Text;
ConfigChanged?.Invoke(this, _changes);
};
return textBox;
}
private UIElement RenderSensitiveField(string path, string label, string? description,
JsonElement config)
{
var pwBox = new PasswordBox { Header = label, Width = 350 };
if (!string.IsNullOrEmpty(description))
ToolTipService.SetToolTip(pwBox, description);
if (config.ValueKind == JsonValueKind.String)
pwBox.Password = config.GetString() ?? "";
pwBox.PasswordChanged += (s, e) =>
{
_changes[path] = pwBox.Password;
ConfigChanged?.Invoke(this, _changes);
};
return pwBox;
}
private UIElement RenderArrayField(string path, string label, string? description,
JsonElement itemsSchema, JsonElement config)
{
var panel = new StackPanel { Spacing = 4 };
panel.Children.Add(new TextBlock
{
Text = label,
FontWeight = FontWeights.SemiBold
});
if (!string.IsNullOrEmpty(description))
{
panel.Children.Add(new TextBlock
{
Text = description,
FontSize = 11,
Foreground = SecondaryBrush,
TextWrapping = TextWrapping.Wrap
});
}
var itemsPanel = new StackPanel { Spacing = 2 };
if (config.ValueKind == JsonValueKind.Array)
{
foreach (var item in config.EnumerateArray())
{
AddArrayItem(itemsPanel, path, item.GetString() ?? "");
}
}
panel.Children.Add(itemsPanel);
var addBtn = new Button
{
Content = "+ Add",
Margin = new Thickness(0, 4, 0, 0)
};
addBtn.Click += (s, e) =>
{
AddArrayItem(itemsPanel, path, "");
UpdateArrayChanges(itemsPanel, path);
};
panel.Children.Add(addBtn);
return panel;
}
private void AddArrayItem(StackPanel itemsPanel, string path, string value)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 };
var textBox = new TextBox { Text = value, MinWidth = 250 };
textBox.TextChanged += (s, e) => UpdateArrayChanges(itemsPanel, path);
var removeBtn = new Button
{
Content = "\u2715",
Width = 28,
Height = 28,
Padding = new Thickness(0)
};
removeBtn.Click += (s, e) =>
{
itemsPanel.Children.Remove(row);
UpdateArrayChanges(itemsPanel, path);
};
row.Children.Add(textBox);
row.Children.Add(removeBtn);
itemsPanel.Children.Add(row);
}
private void UpdateArrayChanges(StackPanel itemsPanel, string path)
{
var values = new List<string>();
foreach (var child in itemsPanel.Children)
{
if (child is StackPanel row && row.Children.Count > 0
&& row.Children[0] is TextBox tb)
{
values.Add(tb.Text);
}
}
_changes[path] = values.ToArray();
ConfigChanged?.Invoke(this, _changes);
}
private static string GetLabel(string path, string name)
{
var result = CamelCaseSplitPattern.Replace(name, "$1 $2");
result = result.Replace("_", " ").Replace(".", " \u203A ");
// Title-case the first character
if (result.Length > 0)
result = char.ToUpperInvariant(result[0]) + result[1..];
return result;
}
private static bool IsSensitive(string path)
{
var lower = path.ToLowerInvariant();
return lower.Contains("token") || lower.Contains("secret")
|| lower.Contains("password") || lower.Contains("apikey")
|| lower.Contains("api_key");
}
/// <summary>Fallback: render config values directly as editable fields when no schema available.</summary>
private void RenderConfigDirectly(string path, JsonElement config, StackPanel parent, int depth)
{
if (config.ValueKind != JsonValueKind.Object) return;
foreach (var prop in config.EnumerateObject())
{
var childPath = string.IsNullOrEmpty(path) ? prop.Name : $"{path}.{prop.Name}";
var value = prop.Value;
switch (value.ValueKind)
{
case JsonValueKind.Object:
var expander = new Expander
{
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
IsExpanded = true,
Margin = new Thickness(0, 2, 0, 2)
};
expander.Header = new TextBlock { Text = GetLabel(childPath, prop.Name), FontWeight = FontWeights.SemiBold };
var childPanel = new StackPanel { Spacing = 4, Padding = new Thickness(0, 4, 0, 4) };
RenderConfigDirectly(childPath, value, childPanel, depth + 1);
expander.Content = childPanel;
parent.Children.Add(expander);
break;
case JsonValueKind.True:
case JsonValueKind.False:
var toggle = new ToggleSwitch { Header = GetLabel(childPath, prop.Name), IsOn = value.ValueKind == JsonValueKind.True };
toggle.Toggled += (s, e) => { _changes[childPath] = toggle.IsOn; ConfigChanged?.Invoke(this, _changes); };
parent.Children.Add(toggle);
break;
case JsonValueKind.Number:
var numBox = new NumberBox
{
Header = GetLabel(childPath, prop.Name),
Value = value.GetDouble(),
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact,
MinWidth = 200
};
numBox.ValueChanged += (s, e) => { _changes[childPath] = numBox.Value; ConfigChanged?.Invoke(this, _changes); };
parent.Children.Add(numBox);
break;
case JsonValueKind.String:
if (IsSensitive(childPath))
{
var pwBox = new PasswordBox { Header = GetLabel(childPath, prop.Name), Width = 350 };
pwBox.Password = value.GetString() ?? "";
pwBox.PasswordChanged += (s, e) => { _changes[childPath] = pwBox.Password; ConfigChanged?.Invoke(this, _changes); };
parent.Children.Add(pwBox);
}
else
{
var textBox = new TextBox { Header = GetLabel(childPath, prop.Name), Text = value.GetString() ?? "", MinWidth = 300 };
textBox.TextChanged += (s, e) => { _changes[childPath] = textBox.Text; ConfigChanged?.Invoke(this, _changes); };
parent.Children.Add(textBox);
}
break;
case JsonValueKind.Array:
var arrayLabel = new TextBlock { Text = GetLabel(childPath, prop.Name), FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 8, 0, 4) };
parent.Children.Add(arrayLabel);
var arrayText = new TextBox
{
Text = value.ToString(),
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 100,
FontFamily = new FontFamily("Consolas"),
FontSize = 11
};
parent.Children.Add(arrayText);
break;
}
}
}
}

View File

@ -1,259 +0,0 @@
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).
/// <summary>
/// Minimal gateway surface QuickSend needs. Wrapping the real
/// <see cref="OpenClawGatewayClient"/> behind this interface keeps
/// <see cref="QuickSendCoordinator"/> testable without spinning up a real
/// WebSocket client.
/// </summary>
public interface IQuickSendGateway
{
bool IsConnectedToGateway { get; }
Task ConnectAsync();
Task SendChatMessageAsync(string message);
string BuildPairingApprovalFixCommands();
string BuildMissingScopeFixCommands(string missingScope);
}
/// <summary>
/// Adapter that exposes the live <see cref="OpenClawGatewayClient"/> through
/// <see cref="IQuickSendGateway"/> for the production wiring.
/// </summary>
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);
}
/// <summary>
/// Discriminated outcome of a single Send attempt. The dialog renders the
/// outcome; the coordinator never touches UI.
/// </summary>
public abstract record QuickSendOutcome
{
/// <summary>Message accepted by the gateway.</summary>
public sealed record Sent : QuickSendOutcome;
/// <summary>
/// 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.
/// </summary>
public sealed record GatewayInitializing(string Message) : QuickSendOutcome;
/// <summary>
/// Live current client genuinely reports NOT_PAIRED. Clipboard remediation
/// MUST still fire — this is the path Mike explicitly does not want
/// suppressed.
/// </summary>
public sealed record PairingRequired(string Commands) : QuickSendOutcome;
/// <summary>Live current client is missing a required operator scope.</summary>
public sealed record MissingScope(string Scope, string Commands) : QuickSendOutcome;
/// <summary>Any other failure (timeout, transport, dispose race, etc.).</summary>
public sealed record Failed(string ErrorMessage) : QuickSendOutcome;
}
/// <summary>
/// Pure (no UI, no static state) per-Send orchestrator. The dialog passes a
/// <see cref="Func{T}"/> that reads <c>App._gatewayClient</c> on every Send
/// so a swap underneath the dialog is observed before remediation decisions
/// are made.
/// </summary>
public sealed class QuickSendCoordinator
{
/// <summary>
/// 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).
/// </summary>
private readonly Func<IQuickSendGateway?> _provider;
private readonly int _connectTimeoutMs;
private readonly int _providerRetryDelayMs;
private readonly Func<int, Task> _delayAsync;
public QuickSendCoordinator(
Func<IQuickSendGateway?> provider,
int connectTimeoutMs = 3000,
int providerRetryDelayMs = 100,
Func<int, Task>? delayAsync = null)
{
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
_connectTimeoutMs = connectTimeoutMs;
_providerRetryDelayMs = providerRetryDelayMs;
_delayAsync = delayAsync ?? Task.Delay;
}
public async Task<QuickSendOutcome> 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<bool> 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);
}
}

View File

@ -9,6 +9,7 @@ using OpenClawTray.Services;
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using WinUIEx;
namespace OpenClawTray.Dialogs;
@ -18,21 +19,12 @@ namespace OpenClawTray.Dialogs;
/// </summary>
public sealed class QuickSendDialog : WindowEx
{
// 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<OpenClawGatewayClient?> _clientProvider;
private readonly QuickSendCoordinator _coordinator;
private readonly OpenClawGatewayClient _client;
private readonly TextBox _messageTextBox;
private readonly TextBox _errorDetailsTextBox;
private readonly Button _sendButton;
private bool _isSending;
private bool _isClosed;
private bool _focusRetryRunning;
private const string TitleIcon = "🦞";
private const double WindowControlsReservedWidth = 140;
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
@ -50,26 +42,18 @@ public sealed class QuickSendDialog : WindowEx
uint uFlags);
private static readonly IntPtr HWND_TOPMOST = new(-1);
private const int TitleBarHeight = 48;
private const int SW_SHOWNORMAL = 1;
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_SHOWWINDOW = 0x0040;
public QuickSendDialog(Func<OpenClawGatewayClient?> clientProvider, string? prefillMessage = null)
public QuickSendDialog(OpenClawGatewayClient client, string? prefillMessage = null)
{
_clientProvider = clientProvider ?? throw new ArgumentNullException(nameof(clientProvider));
_coordinator = new QuickSendCoordinator(() =>
{
var live = _clientProvider();
return live == null ? null : new OpenClawGatewayClientAdapter(live);
});
_client = client;
// Window setup
Title = LocalizationHelper.GetString("WindowTitle_QuickSend");
ExtendsContentIntoTitleBar = true;
this.SetWindowSize(420, 260 + TitleBarHeight);
this.SetWindowSize(420, 260);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
@ -78,9 +62,9 @@ public sealed class QuickSendDialog : WindowEx
BackdropHelper.TrySetAcrylicBackdrop((Microsoft.UI.Xaml.Window)this);
// Hotkey-launched windows can fail to foreground on Windows 10 due to
// foreground activation restrictions. Keep the existing topmost promotion.
// foreground activation restrictions. Ensure the window is topmost.
this.IsAlwaysOnTop = true;
// Build UI programmatically (simple dialog)
var root = new Grid
{
@ -146,57 +130,20 @@ public sealed class QuickSendDialog : WindowEx
Grid.SetRow(buttonPanel, 3);
root.Children.Add(buttonPanel);
var body = new Border
Content = new Border
{
Padding = new Thickness(24),
Child = root
};
var outerGrid = new Grid();
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(TitleBarHeight) });
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
var titleBar = new Grid { Padding = new Thickness(16, 0, WindowControlsReservedWidth, 0) };
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
titleStack.Children.Add(new TextBlock
{
Text = TitleIcon,
FontSize = 20,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0)
});
titleStack.Children.Add(new TextBlock
{
Text = LocalizationHelper.GetString("WindowTitle_QuickSend"),
FontSize = 13,
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
});
titleBar.Children.Add(titleStack);
Grid.SetRow(titleBar, 0);
outerGrid.Children.Add(titleBar);
Grid.SetRow(body, 1);
outerGrid.Children.Add(body);
Content = outerGrid;
SetTitleBar(titleBar);
// Focus the text box when shown without closing on transient deactivation.
// Focus the text box when shown
Activated += (s, e) =>
{
if (e.WindowActivationState != WindowActivationState.Deactivated)
{
TryBringToFront();
RequestInputFocus();
}
TryBringToFront();
RequestInputFocus();
};
Closed += (s, e) =>
{
_isClosed = true;
Logger.Info("[QuickSend] Dialog closed");
};
Closed += (s, e) => Logger.Info("[QuickSend] Dialog closed");
Logger.Info($"[QuickSend] Dialog opened (prefill={!string.IsNullOrEmpty(prefillMessage)})");
}
@ -205,9 +152,6 @@ public sealed class QuickSendDialog : WindowEx
{
try
{
if (_isClosed)
return;
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
if (hwnd == IntPtr.Zero) return;
@ -247,77 +191,64 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.Visibility = Visibility.Collapsed;
_errorDetailsTextBox.Text = string.Empty;
this.SetWindowSize(420, 260 + TitleBarHeight);
this.SetWindowSize(420, 260);
_isSending = true;
_sendButton.IsEnabled = false;
_messageTextBox.IsEnabled = false;
ShowDetails(LocalizationHelper.GetString("QuickSend_Sending"));
QuickSendOutcome outcome;
try
{
outcome = await _coordinator.SendAsync(message);
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();
}
catch (Exception ex)
{
// 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);
}
Logger.Error($"Quick send failed: {ex.Message}");
if (IsPairingRequired(ex.Message))
{
var commands = _client.BuildPairingApprovalFixCommands();
CopyTextToClipboard(commands);
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;
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}");
ShowErrorDetails($"Pairing approval required\n\n{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{pr.Commands}");
break;
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);
case QuickSendOutcome.MissingScope ms:
CopyTextToClipboard(ms.Commands);
ShowErrorDetails($"Missing scope: {ms.Scope}\n\n{ms.Commands}");
ShowErrorDetails($"Missing scope: {missingScope}\n\n{commands}");
new ToastContentBuilder()
.AddText("Quick Send permission required")
.AddText($"Missing scope '{ms.Scope}'. Identity + remediation guidance copied to clipboard.")
.AddText($"Missing scope '{missingScope}'. Identity + remediation guidance copied to clipboard.")
.Show();
Logger.Warn($"[QuickSend] Missing scope '{ms.Scope}'. Commands copied to clipboard.\n{ms.Commands}");
break;
Logger.Warn($"[QuickSend] Missing scope '{missingScope}'. Commands copied to clipboard.\n{commands}");
}
else
{
ShowErrorDetails(ex.Message);
}
case QuickSendOutcome.Failed f:
Logger.Error($"Quick send failed: {f.ErrorMessage}");
ShowErrorDetails(f.ErrorMessage);
break;
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
}
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
}
private void ShowErrorDetails(string details)
@ -326,7 +257,7 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.MinHeight = 140;
_errorDetailsTextBox.Text = details;
_errorDetailsTextBox.Visibility = Visibility.Visible;
this.SetWindowSize(520, 400 + TitleBarHeight);
this.SetWindowSize(520, 400);
// Move focus to the details box so users can immediately select/copy text.
_errorDetailsTextBox.Focus(FocusState.Programmatic);
@ -338,7 +269,37 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.MinHeight = 80;
_errorDetailsTextBox.Text = details;
_errorDetailsTextBox.Visibility = Visibility.Visible;
this.SetWindowSize(500, 320 + TitleBarHeight);
this.SetWindowSize(500, 320);
}
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)
@ -350,41 +311,54 @@ public sealed class QuickSendDialog : WindowEx
private void QueueFocusMessageInput()
{
if (_isClosed)
return;
DispatcherQueue?.TryEnqueue(FocusMessageInput);
}
private void RequestInputFocus()
{
QueueFocusMessageInput();
if (!_focusRetryRunning)
{
_focusRetryRunning = true;
_ = RetryFocusMessageInputAsync();
}
_ = RetryFocusMessageInputAsync();
}
private async Task RetryFocusMessageInputAsync()
{
var delaysMs = new[] { 60, 160, 320 };
foreach (var delay in delaysMs)
{
await Task.Delay(delay);
TryBringToFront();
QueueFocusMessageInput();
}
}
private async Task<bool> EnsureGatewayConnectedAsync(int timeoutMs = 3000)
{
if (_client.IsConnectedToGateway)
{
return true;
}
try
{
var delaysMs = new[] { 60, 160, 320 };
foreach (var delay in delaysMs)
{
await Task.Delay(delay);
if (_isClosed)
return;
TryBringToFront();
QueueFocusMessageInput();
}
await _client.ConnectAsync();
}
finally
catch
{
_focusRetryRunning = false;
// 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()

View File

@ -1,195 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using OpenClaw.Shared.Capabilities;
using OpenClawTray.Helpers;
using OpenClawTray.Services;
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WinUIEx;
namespace OpenClawTray.Dialogs;
/// <summary>
/// Privacy consent dialog shown before the first screen or camera recording.
/// Parameterized by recording type so each capability gets its own consent.
/// </summary>
public sealed class RecordingConsentDialog : WindowEx
{
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
private static readonly IntPtr HWND_TOPMOST = new(-1);
private static readonly IntPtr HWND_NOTOPMOST = new(-2);
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private readonly TaskCompletionSource<bool> _tcs = new();
private bool _consented;
public RecordingConsentDialog(RecordingType type)
{
var isScreen = type == RecordingType.Screen;
var headingKey = isScreen ? "RecordingConsent_ScreenTitle" : "RecordingConsent_CameraTitle";
var descriptionKey = isScreen ? "RecordingConsent_ScreenDescription" : "RecordingConsent_CameraDescription";
var emoji = isScreen ? "🖥️" : "📷";
Title = LocalizationHelper.GetString("RecordingConsent_WindowTitle");
this.SetWindowSize(460, 340);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
SystemBackdrop = new MicaBackdrop();
ExtendsContentIntoTitleBar = true;
// Custom title bar
var titleBar = new Grid
{
Height = 48,
Padding = new Thickness(16, 0, 140, 0)
};
titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var titleIcon = new TextBlock
{
Text = "🦞",
FontSize = 16,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 8, 0)
};
Grid.SetColumn(titleIcon, 0);
titleBar.Children.Add(titleIcon);
var titleText = new TextBlock
{
Text = LocalizationHelper.GetString("RecordingConsent_WindowTitle"),
FontSize = 13,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"]
};
Grid.SetColumn(titleText, 1);
titleBar.Children.Add(titleText);
SetTitleBar(titleBar);
// Main layout
var outerGrid = new Grid();
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
Grid.SetRow(titleBar, 0);
outerGrid.Children.Add(titleBar);
var root = new Grid
{
Padding = new Thickness(32, 16, 32, 32),
RowSpacing = 16
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// Header
var header = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 12
};
header.Children.Add(new TextBlock { Text = emoji, FontSize = 36 });
header.Children.Add(new TextBlock
{
Text = LocalizationHelper.GetString(headingKey),
Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
});
Grid.SetRow(header, 0);
root.Children.Add(header);
// Content
var content = new StackPanel { Spacing = 12 };
content.Children.Add(new TextBlock
{
Text = LocalizationHelper.GetString(descriptionKey),
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(new TextBlock
{
Text = LocalizationHelper.GetString("RecordingConsent_Privacy"),
TextWrapping = TextWrapping.Wrap,
Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
});
Grid.SetRow(content, 1);
root.Children.Add(content);
// Buttons
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
var denyButton = new Button
{
Content = LocalizationHelper.GetString("RecordingConsent_Deny")
};
denyButton.Click += (s, e) =>
{
Logger.Info($"[RecordingConsent] User denied {type} recording consent");
_consented = false;
Close();
};
buttonPanel.Children.Add(denyButton);
var allowButton = new Button
{
Content = LocalizationHelper.GetString("RecordingConsent_Allow"),
Style = (Style)Application.Current.Resources["AccentButtonStyle"]
};
allowButton.Click += (s, e) =>
{
Logger.Info($"[RecordingConsent] User allowed {type} recording consent");
_consented = true;
Close();
};
buttonPanel.Children.Add(allowButton);
Grid.SetRow(buttonPanel, 2);
root.Children.Add(buttonPanel);
Grid.SetRow(root, 1);
outerGrid.Children.Add(root);
Content = outerGrid;
Closed += (s, e) => _tcs.TrySetResult(_consented);
Logger.Info($"[RecordingConsent] {type} recording consent dialog shown");
}
public new Task<bool> ShowAsync()
{
Activate();
// Force to foreground since this may be triggered from a background context
try
{
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
if (hwnd != IntPtr.Zero)
{
// Briefly set topmost to guarantee visibility, then remove topmost flag
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
SetForegroundWindow(hwnd);
}
}
catch { /* best-effort */ }
return _tcs.Task;
}
}

View File

@ -1,133 +0,0 @@
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WinUIEx;
namespace OpenClawTray.Dialogs;
/// <summary>
/// Compact chromeless countdown overlay (3-2-1) shown before recording starts.
/// Displays as a small floating dark pill with a white countdown number.
/// </summary>
public sealed class RecordingCountdownWindow : WindowEx
{
[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
private static readonly IntPtr HWND_TOPMOST = new(-1);
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private const int GWL_STYLE = -16;
private const int GWL_EXSTYLE = -20;
private const int WS_POPUP = unchecked((int)0x80000000);
private const int WS_VISIBLE = 0x10000000;
private const int WS_EX_TOOLWINDOW = 0x00000080;
private const int WS_EX_NOACTIVATE = 0x08000000;
private const uint SWP_FRAMECHANGED = 0x0020;
private readonly TaskCompletionSource _tcs = new();
private readonly TextBlock _countdownText;
private readonly DispatcherQueueTimer _timer;
private int _remaining;
public RecordingCountdownWindow(int seconds = 3)
{
_remaining = seconds;
Title = "";
this.SetWindowSize(120, 120);
this.CenterOnScreen();
ExtendsContentIntoTitleBar = true;
IsMinimizable = false;
IsMaximizable = false;
IsResizable = false;
_countdownText = new TextBlock
{
Text = _remaining.ToString(),
FontSize = 56,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Foreground = new SolidColorBrush(Colors.White),
// Nudge up slightly to compensate for font descender space
Padding = new Thickness(0, 0, 0, 6)
};
// Solid dark circle on a fully transparent window
var pill = new Border
{
Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(230, 30, 30, 30)),
CornerRadius = new CornerRadius(60),
Width = 100,
Height = 100,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Child = _countdownText
};
Content = new Grid
{
Background = new SolidColorBrush(Colors.Transparent),
Children = { pill }
};
_timer = DispatcherQueue.CreateTimer();
_timer.Interval = TimeSpan.FromSeconds(1);
_timer.Tick += OnTick;
}
private void OnTick(DispatcherQueueTimer sender, object args)
{
_remaining--;
if (_remaining <= 0)
{
_timer.Stop();
Close();
return;
}
_countdownText.Text = _remaining.ToString();
}
public Task ShowCountdownAsync()
{
Closed += (s, e) => _tcs.TrySetResult();
// Transparent window background so only the dark circle is visible
SystemBackdrop = new TransparentTintBackdrop();
Activate();
// Strip window chrome and make topmost
try
{
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
if (hwnd != IntPtr.Zero)
{
SetWindowLong(hwnd, GWL_STYLE, WS_POPUP | WS_VISIBLE);
var exStyle = GetWindowLong(hwnd, GWL_EXSTYLE);
SetWindowLong(hwnd, GWL_EXSTYLE, exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED);
}
}
catch { /* best-effort */ }
_timer.Start();
return _tcs.Task;
}
}

View File

@ -20,7 +20,6 @@ public sealed class WelcomeDialog : WindowEx
public WelcomeDialog()
{
Title = LocalizationHelper.GetString("WindowTitle_Welcome");
ExtendsContentIntoTitleBar = true;
this.SetWindowSize(480, 440);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
@ -124,37 +123,7 @@ public sealed class WelcomeDialog : WindowEx
Grid.SetRow(buttonPanel, 2);
root.Children.Add(buttonPanel);
// Wrap content with custom titlebar
var outerGrid = new Grid();
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) };
var titleIcon = new TextBlock
{
Text = "🦞",
FontSize = 20,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0)
};
var titleTextBlock = new TextBlock
{
Text = LocalizationHelper.GetString("WindowTitle_Welcome"),
FontSize = 13,
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
};
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
titleStack.Children.Add(titleIcon);
titleStack.Children.Add(titleTextBlock);
titleBar.Children.Add(titleStack);
Grid.SetRow(titleBar, 0);
outerGrid.Children.Add(titleBar);
Grid.SetRow(root, 1);
outerGrid.Children.Add(root);
Content = outerGrid;
SetTitleBar(titleBar);
Content = root;
Closed += (s, e) => _tcs.TrySetResult(_result);

View File

@ -1,500 +0,0 @@
using OpenClaw.Shared;
using OpenClawTray.Services;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenClawTray.Helpers;
internal static class CommandCenterTextHelper
{
// Pre-compiled patterns used in RedactSupportPath / RedactSupportValue.
// Compiled once at startup; reused on every diagnostic / support-text build.
private static readonly Regex PathWindowsUserPattern = new(
@"\b[A-Za-z]:\\Users\\[^\\]+",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex PathUnixUserPattern = new(
@"/Users/[^/]+",
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueUrlHostPattern = new(
@"\b[a-z][a-z0-9+.-]*://(?:[^@\s/]+@)?([^:/\s]+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueIpPattern = new(
@"\b(?:\d{1,3}\.){3}\d{1,3}\b",
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueEmailPattern = new(
@"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueUserAtHostPattern = new(
@"\b(?<user>[A-Za-z0-9._-]+)@(?<host>[A-Za-z0-9._-]+)(?=[:\s]|$)",
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueHostAfterToPattern = new(
@"(?<=\bto\s)[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
private static readonly Regex ValueLeadingHostPattern = new(
@"^\s*[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
internal static string BuildSupportContext(GatewayCommandCenterState state)
{
var builder = new StringBuilder();
builder.AppendLine("OpenClaw Windows Tray Support Context");
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
builder.AppendLine($"Connection: {state.ConnectionStatus}");
builder.AppendLine($"Topology: {state.Topology.DisplayName}");
builder.AppendLine($"Transport: {state.Topology.Transport}");
builder.AppendLine($"Gateway URL: {RedactSupportValue(state.Topology.GatewayUrl)}");
builder.AppendLine($"Topology detail: {RedactSupportValue(state.Topology.Detail)}");
builder.AppendLine($"Gateway runtime: {RedactSupportValue(state.Runtime.DisplayText)}");
builder.AppendLine($"Update status: {RedactSupportValue(state.Update.DisplayText)}");
if (state.Tunnel != null && state.Tunnel.Status != TunnelStatus.NotConfigured)
{
builder.AppendLine($"Tunnel: {state.Tunnel.Status}");
builder.AppendLine($"Tunnel local endpoint: {RedactSupportValue(state.Tunnel.LocalEndpoint)}");
builder.AppendLine($"Tunnel remote endpoint: {RedactSupportValue(state.Tunnel.RemoteEndpoint)}");
if (!string.IsNullOrWhiteSpace(state.Tunnel.BrowserProxyLocalEndpoint) ||
!string.IsNullOrWhiteSpace(state.Tunnel.BrowserProxyRemoteEndpoint))
{
builder.AppendLine($"Tunnel browser proxy local endpoint: {RedactSupportValue(state.Tunnel.BrowserProxyLocalEndpoint)}");
builder.AppendLine($"Tunnel browser proxy remote endpoint: {RedactSupportValue(state.Tunnel.BrowserProxyRemoteEndpoint)}");
}
if (!string.IsNullOrWhiteSpace(state.Tunnel.LastError))
builder.AppendLine($"Tunnel last error: {RedactSupportValue(state.Tunnel.LastError)}");
}
builder.AppendLine($"Gateway version: {state.GatewaySelf?.ServerVersion ?? "unknown"}");
builder.AppendLine($"Gateway uptime ms: {state.GatewaySelf?.UptimeMs?.ToString() ?? "unknown"}");
builder.AppendLine($"Channels: {state.Channels.Count}");
builder.AppendLine($"Sessions: {state.Sessions.Count}");
builder.AppendLine($"Nodes: {state.Nodes.Count}");
builder.AppendLine($"Warnings: {state.Warnings.Count}");
foreach (var warning in state.Warnings.Take(10))
{
builder.AppendLine($"- {warning.Severity}: {warning.Title}");
}
builder.AppendLine($"Recent activity: {state.RecentActivity.Count}");
foreach (var item in state.RecentActivity.Take(10))
{
builder.AppendLine($"- {item.Timestamp:O} [{item.Category}] {item.Title}");
}
builder.AppendLine($"Ports: {state.PortDiagnostics.Count}");
foreach (var port in state.PortDiagnostics)
{
builder.AppendLine($"- {port.Purpose}: {port.Port} {port.StatusText} ({RedactSupportValue(port.Detail)})");
}
builder.AppendLine($"Log file: {RedactSupportPath(Logger.LogFilePath)}");
builder.AppendLine($"Diagnostics JSONL: {RedactSupportPath(DiagnosticsJsonlService.FilePath)}");
builder.AppendLine($"Settings folder: {RedactSupportPath(SettingsManager.SettingsDirectoryPath)}");
builder.AppendLine("Excluded: tokens, bootstrap tokens, command arguments, screenshots, recordings, camera data, microphone data, base64 payloads, and message payloads.");
return builder.ToString();
}
internal static string BuildDebugBundle(GatewayCommandCenterState state)
{
var builder = new StringBuilder();
builder.AppendLine("OpenClaw Windows Tray Debug Bundle");
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
builder.AppendLine();
AppendSection(builder, "Support Context", BuildSupportContext(state));
AppendSection(builder, "Port Diagnostics", BuildPortDiagnosticsSummary(state.PortDiagnostics));
AppendSection(builder, "Capability Diagnostics", BuildCapabilityDiagnosticsSummary(state));
AppendSection(builder, "Node Inventory", BuildNodeInventorySummary(state.Nodes));
AppendSection(builder, "Channel Summary", BuildChannelSummaryText(state.Channels));
AppendSection(builder, "Activity Summary", BuildActivitySummary(state.RecentActivity));
AppendSection(builder, "Extensibility Summary", BuildExtensibilitySummary(state.Channels));
return builder.ToString();
}
internal static string BuildBrowserSetupGuidance(GatewayCommandCenterState state)
{
var browserProxyPort = state.PortDiagnostics
.FirstOrDefault(p => p.Purpose.Equals("Browser proxy host", StringComparison.OrdinalIgnoreCase))
?.Port ?? 0;
return BuildBrowserSetupGuidance(browserProxyPort, state.Topology, state.Tunnel);
}
internal static string BuildBrowserSetupGuidance(
int browserProxyPort,
GatewayTopologyInfo? topology,
TunnelCommandCenterInfo? tunnel)
{
var portText = browserProxyPort is >= 1 and <= 65535
? browserProxyPort.ToString(CultureInfo.InvariantCulture)
: "<gateway-port+2>";
var gatewayHost = string.IsNullOrWhiteSpace(topology?.Host) ? "<gateway-host>" : topology.Host;
var gatewayPort = ResolveGatewayPort(topology?.GatewayUrl);
var gatewayPortText = gatewayPort is >= 1 and <= 65535
? gatewayPort.Value.ToString(CultureInfo.InvariantCulture)
: "<gateway-port>";
var lines = new List<string>
{
"OpenClaw browser proxy setup",
$"Expected local browser-control endpoint: http://127.0.0.1:{portText}/",
"",
"If the Gateway and browser are on this Windows machine:",
"1. Ensure the upstream browser plugin is enabled in the Gateway config.",
"2. Verify the browser control plane:",
" openclaw browser --browser-profile openclaw doctor",
" openclaw browser --browser-profile openclaw start",
" openclaw browser --browser-profile openclaw tabs",
"",
"If the browser is on this Windows machine but the Gateway is remote:",
"1. Run a browser-capable OpenClaw node host on this machine:",
$" openclaw node run --host {gatewayHost} --port {gatewayPortText}",
"2. Or install it as a user service:",
$" openclaw node install --host {gatewayHost} --port {gatewayPortText}",
" openclaw node start",
"3. Keep nodeHost.browserProxy.enabled=true, and configure nodeHost.browserProxy.allowProfiles only if you want to restrict profile access.",
"",
"Gateway policy and auth checks:",
"- The Gateway allowlist must permit browser.proxy for this node.",
"- Browser-control auth must match the saved Gateway token/password in Settings.",
"- Do not paste QR bootstrap tokens into the normal Gateway Token field."
};
if (topology?.UsesSshTunnel == true)
{
lines.Add("");
lines.Add("SSH tunnel mode:");
lines.Add("- Prefer the tray-managed SSH tunnel with Browser proxy bridge enabled; it forwards local-port+2 to remote-port+2 automatically.");
lines.Add($"- Manual forward shape: {BuildBrowserProxySshForwardHint(browserProxyPort, tunnel)}");
}
return string.Join(Environment.NewLine, lines);
}
internal static string BuildChannelSummaryText(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
{
var builder = new StringBuilder();
builder.AppendLine($"Channels: {BuildChannelSummary(channels)}");
foreach (var channel in channels.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- {channel.Name}: {channel.Status ?? "unknown"} ({BuildChannelDetail(channel)})");
}
return builder.ToString();
}
internal static string BuildExtensibilitySummary(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
{
var builder = new StringBuilder();
builder.AppendLine("OpenClaw extensibility surfaces");
builder.AppendLine("Channels dashboard: channels");
builder.AppendLine("Skills dashboard: skills");
builder.AppendLine("Cron / schedules dashboard: cron");
builder.AppendLine();
builder.AppendLine("Channel health currently reported to Windows:");
foreach (var channel in channels.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- {channel.Name}: {channel.Status} ({BuildChannelDetail(channel)})");
}
return builder.ToString();
}
internal static string BuildCapabilityDiagnosticsSummary(GatewayCommandCenterState state)
{
var builder = new StringBuilder();
builder.AppendLine("OpenClaw capability diagnostics");
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
builder.AppendLine();
builder.AppendLine("Windows permission surfaces:");
foreach (var permission in state.Permissions.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine($"- {permission.Name}: {permission.Status} - {permission.Detail}");
}
builder.AppendLine();
builder.AppendLine("Node command allowlist status:");
if (state.Nodes.Count == 0)
{
builder.AppendLine("- No nodes reported by gateway.");
}
foreach (var node in state.Nodes.OrderBy(n => n.DisplayName, StringComparer.OrdinalIgnoreCase))
{
var displayName = string.IsNullOrWhiteSpace(node.DisplayName) ? node.NodeId : node.DisplayName;
builder.AppendLine($"- {displayName} ({node.Platform ?? "unknown"}, {(node.IsOnline ? "online" : "offline")})");
builder.AppendLine($" declared commands: {FormatCommandList(node.Commands)}");
builder.AppendLine($" safe companion commands: {FormatCommandList(node.SafeDeclaredCommands)}");
builder.AppendLine($" privacy-sensitive opt-ins: {FormatCommandList(node.DangerousDeclaredCommands)}");
builder.AppendLine($" browser proxy commands: {FormatCommandList(node.BrowserDeclaredCommands)}");
builder.AppendLine($" Windows-specific commands: {FormatCommandList(node.WindowsSpecificDeclaredCommands)}");
builder.AppendLine($" filtered by gateway policy: {FormatCommandList(node.BlockedDeclaredCommands)}");
builder.AppendLine($" disabled in Settings: {FormatCommandList(node.DisabledBySettingsCommands)}");
builder.AppendLine($" missing safe allowlist: {FormatCommandList(node.MissingSafeAllowlistCommands)}");
builder.AppendLine($" missing privacy-sensitive allowlist: {FormatCommandList(node.MissingDangerousAllowlistCommands)}");
builder.AppendLine($" missing browser proxy allowlist: {FormatCommandList(node.MissingBrowserAllowlistCommands)}");
builder.AppendLine($" missing Mac parity: {FormatCommandList(node.MissingMacParityCommands)}");
}
builder.AppendLine();
builder.AppendLine("Rule: safe companion commands can be allowlisted for parity; privacy-sensitive commands such as camera.snap, camera.clip, and screen.record should stay explicit opt-ins.");
return builder.ToString();
}
internal static string BuildPortDiagnosticsSummary(IReadOnlyCollection<PortDiagnosticInfo> ports)
{
if (ports.Count == 0)
return "No local port diagnostics available for the current topology.";
var builder = new StringBuilder();
builder.AppendLine("OpenClaw port diagnostics");
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
foreach (var port in ports.OrderBy(p => p.Port).ThenBy(p => p.Purpose, StringComparer.OrdinalIgnoreCase))
{
var owner = port.OwningProcessId is > 0
? $" · owner {port.OwningProcessName ?? "unknown"} (PID {port.OwningProcessId})"
: "";
builder.AppendLine($"- {port.Purpose}: {port.Port} {port.StatusText}{owner} - {RedactSupportValue(port.Detail)}");
if (port.OwningProcessId is > 0)
{
builder.AppendLine($" stop hint: Stop-Process -Id {port.OwningProcessId.Value}");
}
}
return builder.ToString();
}
internal static string BuildActivitySummary(IReadOnlyCollection<CommandCenterActivityInfo> activity)
{
if (activity.Count == 0)
return "No recent OpenClaw tray activity.";
var builder = new StringBuilder();
builder.AppendLine("Recent OpenClaw tray activity");
foreach (var item in activity)
{
var details = BuildActivityDetail(item);
builder.AppendLine($"{item.Timestamp:O} [{item.Category}] {item.Title} - {details}");
}
return builder.ToString();
}
internal static string BuildNodeInventorySummary(IReadOnlyCollection<NodeCapabilityHealthInfo> nodes)
{
if (nodes.Count == 0)
return "No nodes reported by gateway.";
var builder = new StringBuilder();
builder.AppendLine("OpenClaw node inventory");
builder.AppendLine($"Generated: {DateTimeOffset.Now:O}");
builder.AppendLine();
foreach (var node in nodes.OrderBy(n => n.DisplayName, StringComparer.OrdinalIgnoreCase))
{
builder.AppendLine(BuildNodeSummary(node).TrimEnd());
builder.AppendLine($"Safe companion commands: {FormatCommandList(node.SafeDeclaredCommands)}");
builder.AppendLine($"Privacy-sensitive commands: {FormatCommandList(node.DangerousDeclaredCommands)}");
builder.AppendLine($"Browser proxy commands: {FormatCommandList(node.BrowserDeclaredCommands)}");
builder.AppendLine($"Windows-specific commands: {FormatCommandList(node.WindowsSpecificDeclaredCommands)}");
builder.AppendLine($"Filtered by gateway policy: {FormatCommandList(node.BlockedDeclaredCommands)}");
builder.AppendLine($"Missing browser proxy allowlist: {FormatCommandList(node.MissingBrowserAllowlistCommands)}");
builder.AppendLine($"Disabled in Settings: {FormatCommandList(node.DisabledBySettingsCommands)}");
builder.AppendLine($"Missing Mac parity: {FormatCommandList(node.MissingMacParityCommands)}");
builder.AppendLine();
}
return builder.ToString();
}
private static void AppendSection(StringBuilder builder, string title, string content)
{
builder.AppendLine($"## {title}");
builder.AppendLine(content.TrimEnd());
builder.AppendLine();
}
private static string BuildBrowserProxySshForwardHint(int browserProxyPort, TunnelCommandCenterInfo? tunnel)
{
if (browserProxyPort is < 1 or > 65535)
return "ssh -N -L <local-browser-port>:127.0.0.1:<remote-browser-port> <user>@<host>";
var target = string.IsNullOrWhiteSpace(tunnel?.User) || string.IsNullOrWhiteSpace(tunnel.Host)
? "<user>@<host>"
: $"{tunnel.User}@{tunnel.Host}";
var remoteBrowserPort = TryParseEndpointPort(tunnel?.BrowserProxyRemoteEndpoint) ?? browserProxyPort;
return $"ssh -N -L {browserProxyPort}:127.0.0.1:{remoteBrowserPort} {target}";
}
private static int? TryParseEndpointPort(string? endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
return null;
if (Uri.TryCreate($"tcp://{endpoint}", UriKind.Absolute, out var uri) &&
uri.Port is >= 1 and <= 65535)
{
return uri.Port;
}
var portDelimiter = endpoint.LastIndexOf(':');
return portDelimiter >= 0 &&
int.TryParse(endpoint[(portDelimiter + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var port) &&
port is >= 1 and <= 65535
? port
: null;
}
private static int? ResolveGatewayPort(string? gatewayUrl)
{
return Uri.TryCreate(gatewayUrl, UriKind.Absolute, out var uri) && uri.Port is >= 1 and <= 65535
? uri.Port
: null;
}
private static string RedactSupportPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return "not configured";
var redacted = path;
var knownFolders = new Dictionary<string, string>
{
[Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)] = "%USERPROFILE%",
[Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)] = "%APPDATA%",
[Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)] = "%LOCALAPPDATA%",
[Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)] = "%USERPROFILE%\\Documents"
};
foreach (var (folder, replacement) in knownFolders
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key))
.OrderByDescending(pair => pair.Key.Length))
{
if (redacted.StartsWith(folder, StringComparison.OrdinalIgnoreCase))
{
redacted = replacement + redacted[folder.Length..];
break;
}
}
redacted = PathWindowsUserPattern.Replace(redacted, "%USERPROFILE%");
redacted = PathUnixUserPattern.Replace(redacted, "$HOME");
return redacted;
}
private static string RedactSupportValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return "unknown";
var redacted = ValueUrlHostPattern.Replace(
value,
match => match.Value.Replace(match.Groups[1].Value, "<host>"));
redacted = ValueIpPattern.Replace(redacted, "<ip>");
redacted = ValueEmailPattern.Replace(redacted, "<email>");
redacted = ValueUserAtHostPattern.Replace(redacted, "<user>@<host>");
redacted = ValueHostAfterToPattern.Replace(redacted, "<host>");
redacted = ValueLeadingHostPattern.Replace(redacted, "<host>");
return redacted;
}
private static string BuildChannelDetail(ChannelCommandCenterInfo channel)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(channel.Type))
parts.Add(channel.Type!);
if (channel.IsLinked)
parts.Add(string.IsNullOrWhiteSpace(channel.AuthAge) ? "linked" : $"linked · {channel.AuthAge}");
if (!string.IsNullOrWhiteSpace(channel.Error))
parts.Add(channel.Error!);
if (channel.CanStart)
parts.Add("start available");
if (channel.CanStop)
parts.Add("stop available");
return parts.Count == 0 ? "no details" : string.Join(" · ", parts);
}
private static string BuildChannelSummary(IReadOnlyCollection<ChannelCommandCenterInfo> channels)
{
if (channels.Count == 0)
return "No channels reported by gateway health.";
var running = channels.Count(c => c.CanStop);
var startable = channels.Count(c => c.CanStart);
var errors = channels.Count(c => string.Equals(c.Status, "error", StringComparison.OrdinalIgnoreCase));
return $"{running}/{channels.Count} running · {startable} startable · {errors} error";
}
private static string FormatCommandList(IEnumerable<string> commands)
{
var ordered = commands
.Where(command => !string.IsNullOrWhiteSpace(command))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
return ordered.Count == 0 ? "none" : string.Join(", ", ordered);
}
private static string BuildActivityDetail(CommandCenterActivityInfo activity)
{
var details = new List<string>();
if (!string.IsNullOrWhiteSpace(activity.Details))
details.Add(activity.Details);
if (!string.IsNullOrWhiteSpace(activity.SessionKey))
details.Add($"session: {activity.SessionKey}");
if (!string.IsNullOrWhiteSpace(activity.NodeId))
details.Add($"node: {ShortId(activity.NodeId)}");
if (!string.IsNullOrWhiteSpace(activity.DashboardPath))
details.Add($"dashboard: {activity.DashboardPath}");
return details.Count == 0 ? activity.Category : string.Join(" · ", details);
}
private static string ShortId(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
return value.Length <= 12 ? value : value[..12] + "...";
}
private static string BuildNodeSummary(NodeCapabilityHealthInfo node)
{
var builder = new StringBuilder();
builder.AppendLine(string.IsNullOrWhiteSpace(node.DisplayName) ? node.NodeId : node.DisplayName);
builder.AppendLine($"Node ID: {node.NodeId}");
builder.AppendLine($"Platform: {node.Platform ?? "unknown"}");
builder.AppendLine($"Status: {(node.IsOnline ? "online" : "offline")}");
builder.AppendLine($"Capabilities: {string.Join(", ", node.Capabilities.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))}");
builder.AppendLine($"Commands: {string.Join(", ", node.Commands.OrderBy(c => c, StringComparer.OrdinalIgnoreCase))}");
if (node.DisabledBySettingsCommands.Count > 0)
builder.AppendLine($"Disabled in Settings: {string.Join(", ", node.DisabledBySettingsCommands)}");
if (node.Warnings.Count > 0)
{
builder.AppendLine("Warnings:");
foreach (var warning in node.Warnings)
{
builder.AppendLine($"- {warning.Title}: {warning.Detail}");
}
}
return builder.ToString();
}
}

View File

@ -14,6 +14,21 @@ internal static class VisualTestCapture
{
private static readonly ConcurrentDictionary<string, int> s_captureIndexes = new(StringComparer.OrdinalIgnoreCase);
public static void CaptureOnLoaded(FrameworkElement root, string surfaceName)
{
var rootDir = GetVisualTestDirectory();
if (rootDir is null)
return;
var surfaceDir = Path.Combine(rootDir, SanitizePathSegment(surfaceName));
root.Loaded += (_, _) =>
{
_ = CaptureAfterDelayAsync(root, surfaceDir, 300);
_ = CaptureAfterDelayAsync(root, surfaceDir, 1500);
_ = CaptureAfterDelayAsync(root, surfaceDir, 3500);
};
}
public static async Task CaptureAsync(FrameworkElement root, string surfaceName)
{
var rootDir = GetVisualTestDirectory();
@ -23,6 +38,12 @@ internal static class VisualTestCapture
await CaptureToDirectoryAsync(root, Path.Combine(rootDir, SanitizePathSegment(surfaceName)));
}
private static async Task CaptureAfterDelayAsync(FrameworkElement root, string surfaceDir, int delayMs)
{
await Task.Delay(delayMs);
await CaptureToDirectoryAsync(root, surfaceDir);
}
private static async Task CaptureToDirectoryAsync(FrameworkElement root, string surfaceDir)
{
try

View File

@ -4,7 +4,6 @@ using OpenClawTray.FunctionalUI.Navigation;
using OpenClawTray.Onboarding.Services;
using OpenClawTray.Onboarding.Pages;
using OpenClawTray.Onboarding.Widgets;
using OpenClawTray.Services;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
@ -21,14 +20,14 @@ public sealed class OnboardingApp : Component<OnboardingState>
public override Element Render()
{
// Seed navigation + page index from Props.CurrentRoute (used by visual tests via
// OPENCLAW_ONBOARDING_START_ROUTE; defaults to SetupWarning on normal launches).
// OPENCLAW_ONBOARDING_START_ROUTE; defaults to Welcome 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, SetupPath changed).
// Clamp pageIndex if page order changed (e.g., node mode toggled)
if (pageIndex >= pages.Length)
{
setPageIndex(pages.Length - 1);
@ -36,19 +35,12 @@ public sealed class OnboardingApp : Component<OnboardingState>
void GoNext()
{
// Re-derive pages on each call so SetupPath changes (Local vs Advanced) take effect.
var current = Props.GetPageOrder();
if (pageIndex < current.Length - 1)
if (pageIndex < pages.Length - 1)
{
Logger.Info($"[OnboardingApp] Advancing pageIndex {pageIndex}\u2192{pageIndex + 1}, next route={current[pageIndex + 1]}");
setPageIndex(pageIndex + 1);
nav.Navigate(current[pageIndex + 1]);
nav.Navigate(pages[pageIndex + 1]);
Props.NotifyPageChanged();
Props.NotifyRouteChanged(current[pageIndex + 1]);
}
else
{
Logger.Info($"[OnboardingApp] AdvanceRequested no-op: at last page (pageIndex={pageIndex}, total={current.Length})");
Props.NotifyRouteChanged(pages[pageIndex + 1]);
}
}
@ -63,70 +55,7 @@ public sealed class OnboardingApp : Component<OnboardingState>
}
}
// 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.
@ -138,14 +67,13 @@ public sealed class OnboardingApp : Component<OnboardingState>
// Page content — fixed height prevents nav bar from jumping between pages
(NavigationHost<OnboardingRoute>(nav, route => route switch
{
OnboardingRoute.SetupWarning => Component<SetupWarningPage, OnboardingState>(Props),
OnboardingRoute.LocalSetupProgress => Component<LocalSetupProgressPage, OnboardingState>(Props),
OnboardingRoute.Welcome => Component<WelcomePage>(),
OnboardingRoute.Connection => Component<ConnectionPage, OnboardingState>(Props),
OnboardingRoute.Ready => Component<ReadyPage, OnboardingState>(Props),
OnboardingRoute.Wizard => Component<WizardPage, OnboardingState>(Props),
OnboardingRoute.Permissions => Component<PermissionsPage, OnboardingState>(Props),
OnboardingRoute.Chat => Component<ChatPage, OnboardingState>(Props),
_ => TextBlock(Helpers.LocalizationHelper.GetString("Onboarding_UnknownPage")),
_ => TextBlock("Unknown page"),
}) with { Transition = NavigationTransition.SlideInOnly(
direction: SlideDirection.FromRight,
duration: TimeSpan.FromMilliseconds(400),
@ -166,11 +94,9 @@ public sealed class OnboardingApp : Component<OnboardingState>
: 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(

View File

@ -32,7 +32,6 @@ 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
@ -45,32 +44,17 @@ 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, string? identityDataPath = null)
public OnboardingWindow(SettingsManager settings)
{
_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);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
@ -86,36 +70,14 @@ public sealed class OnboardingWindow : WindowEx
_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<OnboardingRoute>(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<SetupPath>(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) &&
@ -137,50 +99,19 @@ public sealed class OnboardingWindow : WindowEx
_chatOverlay.Visibility = Visibility.Collapsed;
_chatOverlay.VerticalAlignment = VerticalAlignment.Top;
// Root grid: titlebar row + content area
// Root grid: functional UI host fills everything, overlay sits on top (except nav bar)
_rootGrid = new Grid
{
Background = GetThemeBrush("SolidBackgroundFillColorBaseBrush")
};
_rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) });
_rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
// Custom title bar — matches HubWindow treatment
var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) };
var titleIcon = new TextBlock
{
Text = "🦞",
FontSize = 20,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 10, 0)
};
var titleText = new TextBlock
{
Text = LocalizationHelper.GetString("Onboarding_Title"),
FontSize = 13,
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
};
var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
titleStack.Children.Add(titleIcon);
titleStack.Children.Add(titleText);
titleBar.Children.Add(titleStack);
Grid.SetRow(titleBar, 0);
_rootGrid.Children.Add(titleBar);
SetTitleBar(titleBar);
// Content area
var contentGrid = new Grid();
contentGrid.Children.Add(_host);
contentGrid.Children.Add(_chatOverlay);
Grid.SetRow(contentGrid, 1);
_rootGrid.Children.Add(contentGrid);
_rootGrid.Children.Add(_host);
_rootGrid.Children.Add(_chatOverlay);
Content = _rootGrid;
Closed += OnClosed;
// Size the overlay after layout — leave space for the nav bar (~84px)
// contentGrid is already in row 1 (below titlebar), so no need to subtract titlebar height
contentGrid.SizeChanged += (_, args) =>
// Size the overlay after layout — leave space for the nav bar
// Nav bar is ~60px + VStack bottom padding 20px = 80px minimum
_rootGrid.SizeChanged += (_, args) =>
{
_chatOverlay.Height = Math.Max(0, args.NewSize.Height - 84);
};
@ -494,7 +425,7 @@ public sealed class OnboardingWindow : WindowEx
{
_chatRetryButton = new Button
{
Content = LocalizationHelper.GetString("Onboarding_Retry"),
Content = "Retry",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 40, 0, 0)
@ -523,19 +454,108 @@ public sealed class OnboardingWindow : WindowEx
/// <summary>
/// Auto-sends the bootstrap kickoff message after the web chat loads.
/// Delegates to <see cref="BootstrapMessageInjector"/> so the same gated
/// kickoff fires from both the (legacy) onboarding chat overlay and from
/// post-wizard HubWindow chat navigation — guarded by
/// <see cref="SettingsManager.HasInjectedFirstRunBootstrap"/>.
/// Waits for the WebSocket to connect, then injects the message via JS.
/// Matches macOS's maybeKickoffOnboardingChat behavior.
/// </summary>
private async Task SendBootstrapMessageAsync()
{
if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return;
_bootstrapSent = true;
await BootstrapMessageInjector.InjectAsync(
script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(),
_settings);
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
}
}
/// <summary>
@ -584,17 +604,15 @@ public sealed class OnboardingWindow : WindowEx
private void OnOnboardingFinished(object? sender, EventArgs e)
{
OnWizardComplete();
_settings.Save();
Completed = true;
_state.GatewayClient = null;
OnboardingCompleted?.Invoke(this, EventArgs.Empty);
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;
@ -606,82 +624,6 @@ public sealed class OnboardingWindow : WindowEx
_state.Dispose();
}
/// <summary>
/// Unified completion handler invoked from both the Finish button (via
/// <see cref="OnOnboardingFinished"/>) and the title-bar X button (via
/// <see cref="OnClosed"/>). Idempotent — guarded by <see cref="_completionDispatched"/>.
///
/// 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.
/// </summary>
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();
}
}
/// <summary>
/// SECURITY: Validate visual test directory path to prevent directory traversal.
/// Returns null if the path is suspicious.

View File

@ -66,6 +66,34 @@ public sealed class ConnectionPage : Component<OnboardingState>
return DefaultLocalUrl;
}
/// <summary>
/// Probes common local gateway ports and returns the first reachable URL.
/// Checks the default port (18789) first, then the dev port (19001).
/// Uses a very short timeout for responsiveness.
/// </summary>
private static async Task<string> DetectLocalGatewayUrlAsync()
{
foreach (var candidate in new[] { DefaultLocalUrl, DevLocalUrl })
{
try
{
var uri = new Uri(candidate.Replace("ws://", "http://"));
using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMilliseconds(800) };
var response = await client.GetAsync($"{uri.GetLeftPart(UriPartial.Authority)}/health");
if (response.IsSuccessStatusCode)
{
Logger.Info($"[Connection] Detected local gateway at {candidate}");
return candidate;
}
}
catch
{
// Port not reachable, try next
}
}
return DefaultLocalUrl; // Fallback to default
}
private static string GetVisualTestPairingDeviceId() =>
Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_PAIRING") == "1"
? VisualTestPairingDeviceId
@ -136,14 +164,14 @@ public sealed class ConnectionPage : Component<OnboardingState>
void OnSetupCodeChanged(string code)
{
setSetupCode(code);
if (string.IsNullOrWhiteSpace(code)) return;
var result = SetupCodeDecoder.Decode(code);
if (!result.Success)
{
// Not a valid setup code — user might be still typing.
// Don't call setSetupCode here to avoid re-render that steals focus.
// Not a valid setup code — user might be still typing
if (code.Length > 2048)
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
else
@ -151,8 +179,6 @@ public sealed class ConnectionPage : Component<OnboardingState>
return;
}
// Valid setup code decoded — now update state (will re-render)
setSetupCode(code);
if (result.Url != null)
{
setUrl(result.Url);
@ -161,8 +187,7 @@ public sealed class ConnectionPage : Component<OnboardingState>
if (result.Token != null)
{
setToken(result.Token);
// Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
// Don't save as Settings.Token (causes reconnect storms on restart).
Props.Settings.Token = result.Token;
Props.Settings.BootstrapToken = result.Token;
}
setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
@ -208,13 +233,7 @@ public sealed class ConnectionPage : Component<OnboardingState>
async void TestConnection()
{
Props.Settings.GatewayUrl = url;
// Only save to Settings.Token if the user entered a manual token,
// not a decoded bootstrap token (which belongs in BootstrapToken only).
if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) ||
!string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal))
{
Props.Settings.Token = token;
}
Props.Settings.Token = token;
// When SSH mode, start the managed tunnel before health-checking the local URL.
if (mode == ConnectionMode.Ssh)
@ -482,14 +501,40 @@ public sealed class ConnectionPage : Component<OnboardingState>
catch { /* clipboard unavailable — ignore */ }
}
// Setup code row: TextField + Paste + QR buttons
// Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
cardChildren.Add(
Grid(["1*", "Auto", "Auto"], ["Auto"],
TextField(setupCode, OnSetupCodeChanged,
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
.Grid(row: 0, column: 0)
.Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
.OnGotFocus((sender, _) =>
{
if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text))
{
try
{
var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent();
if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
{
var task = content.GetTextAsync();
task.Completed = (op, status) =>
{
if (status == global::Windows.Foundation.AsyncStatus.Completed)
{
var text = op.GetResults();
tb.DispatcherQueue.TryEnqueue(() =>
{
tb.Text = text;
OnSetupCodeChanged(text);
});
}
};
}
}
catch { }
}
})
.Grid(row: 0, column: 0),
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
.VAlign(VerticalAlignment.Bottom)
.Margin(6, 0, 0, 0)
@ -754,4 +799,30 @@ public sealed class ConnectionPage : Component<OnboardingState>
.Padding(0, 12, 0, 12)
);
}
/// <summary>
/// Lightweight logger that captures the first and last error/warning for UI display.
/// Preserves the first error so reconnect noise doesn't overwrite the real cause.
/// </summary>
private sealed class ConnectionTestLogger : IOpenClawLogger
{
/// <summary>The first error captured — preserves the original cause.</summary>
public string? FirstError { get; private set; }
public string? LastError { get; private set; }
public string? LastWarn { get; private set; }
public void Info(string message) { }
public void Debug(string message) { }
public void Warn(string message)
{
LastWarn = message;
FirstError ??= message;
LastError ??= message;
}
public void Error(string message, Exception? ex = null)
{
FirstError ??= message;
LastError = message;
}
}
}

View File

@ -1,417 +0,0 @@
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;
/// <summary>
/// Page 1 of the Local fork (Phase 5).
///
/// Drives <see cref="LocalGatewaySetupEngine"/> via <see cref="App.CreateLocalGatewaySetupEngine"/>,
/// surfaces a small whitelist of user-meaningful stages, and auto-advances after a
/// 1-second pause once <see cref="LocalGatewaySetupStatus.Complete"/> is reached.
/// On <see cref="LocalGatewaySetupStatus.FailedRetryable"/> a Try again button restarts
/// the engine; on <see cref="LocalGatewaySetupStatus.FailedTerminal"/> 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.
/// </summary>
public sealed class LocalSetupProgressPage : Component<OnboardingState>
{
// Engine lives across page navigations so back/forward doesn't cancel an in-flight setup.
private static LocalGatewaySetupEngine? s_engine;
private static Task<LocalGatewaySetupState>? s_runTask;
private static bool s_advanceFiredForCompletion;
/// <summary>
/// Immutable snapshot captured per <see cref="LocalGatewaySetupEngine.StateChanged"/>
/// invocation. Records have value-equality, so storing a fresh snapshot in
/// <c>UseState</c> on every event reliably triggers a re-render — unlike the
/// previous code which stored the live <see cref="LocalGatewaySetupState"/>
/// reference (the engine mutates the same instance in place; reference-equal
/// previous/next values caused <c>UseState</c> to swallow every update past
/// the first, leaving the page stuck on stage 1 forever — Bug 2 / e2e drive).
/// </summary>
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<RenderSnapshot?>(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<Element?>();
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<Element?>
{
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);
}
/// <summary>
/// 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:&lt;phase&gt;" (e.g. "active:CreateWslInstance"),
/// "complete",
/// "retryable:&lt;message&gt;",
/// "terminal:&lt;message&gt;".
/// </summary>
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<LocalGatewaySetupPhase>(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;
}
}

View File

@ -2,7 +2,6 @@ using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Helpers;
using OpenClawTray.Onboarding.Services;
using OpenClawTray.Services;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
@ -17,16 +16,7 @@ public sealed class ReadyPage : Component<OnboardingState>
{
public override Element Render()
{
// 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);
var (launchAtLogin, setLaunchAtLogin) = UseState(false);
return ScrollView(
VStack(12,
@ -59,11 +49,7 @@ public sealed class ReadyPage : Component<OnboardingState>
// Launch at Login toggle
HStack(8,
ToggleSwitch(launchAtLogin, v =>
{
setLaunchAtLogin(v);
ApplyLaunchAtLogin(v);
}),
ToggleSwitch(launchAtLogin, v => setLaunchAtLogin(v)),
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_LaunchAtLogin"))
.FontSize(13)
.VAlign(VerticalAlignment.Center)
@ -75,34 +61,18 @@ public sealed class ReadyPage : Component<OnboardingState>
).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)
{
return Border(
VStack(8,
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_NodeModeActive"))
TextBlock("🔌 Node Mode Active")
.FontSize(14)
.FontWeight(new global::Windows.UI.Text.FontWeight(600)),
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_NodeModeActiveDetail"))
TextBlock("This PC will operate as a remote compute node. " +
"The gateway can invoke screen capture, camera, and system " +
"commands on this machine.")
.FontSize(12)
.Opacity(0.8)
.TextWrapping()

View File

@ -1,190 +0,0 @@
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;
/// <summary>
/// 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 (<see cref="OnboardingState.ExistingConfigGuard"/>
/// 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.
/// </summary>
public sealed class SetupWarningPage : Component<OnboardingState>
{
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<string>();
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);
}
}

View File

@ -0,0 +1,78 @@
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Helpers;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
namespace OpenClawTray.Onboarding.Pages;
/// <summary>
/// Page 0: Welcome &amp; Security Notice.
/// Matches macOS welcomePage() — title, subtitle, security warning card.
/// </summary>
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)
);
}
}

View File

@ -18,13 +18,6 @@ namespace OpenClawTray.Onboarding.Pages;
/// </summary>
public sealed class WizardPage : Component<OnboardingState>
{
private static readonly Regex UrlInMessagePattern = new(
@"(https?://[^\s\)\"",]+)",
RegexOptions.Compiled);
private static readonly Regex DeviceCodePattern = new(
@"(?:^|\s)(?:[Cc]ode|user_code|USER_CODE)\s*[:=]\s*([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b",
RegexOptions.Compiled);
public override Element Render()
{
// Read persisted wizard state from shared OnboardingState
@ -54,9 +47,9 @@ public sealed class WizardPage : Component<OnboardingState>
// Guard against default/undefined JsonElement
if (payload.ValueKind == JsonValueKind.Undefined || payload.ValueKind == JsonValueKind.Null)
{
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyGatewayResponse"));
setErrorMsg("Empty response from gateway");
setWizardState("error");
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyGatewayResponse"));
SaveState("error", "Empty response from gateway");
return;
}
@ -268,9 +261,9 @@ public sealed class WizardPage : Component<OnboardingState>
if (!client.IsConnectedToGateway)
{
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnectedDetail"));
setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection.");
setWizardState("error");
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnected"));
SaveState("error", "Gateway disconnected");
return;
}
@ -289,7 +282,7 @@ public sealed class WizardPage : Component<OnboardingState>
var answerValue = string.IsNullOrEmpty(stepInput) ? "true" : stepInput;
// Smart timeout: 5min for auth-related steps (device code polling), 30s for everything else
var isAuthStep = !string.IsNullOrEmpty(stepMessage) &&
var isAuthStep = !string.IsNullOrEmpty(stepMessage) &&
(stepMessage.Contains("device", StringComparison.OrdinalIgnoreCase) ||
stepMessage.Contains("authorize", StringComparison.OrdinalIgnoreCase) ||
stepMessage.Contains("login", StringComparison.OrdinalIgnoreCase) ||
@ -306,9 +299,9 @@ public sealed class WizardPage : Component<OnboardingState>
// Validate response before applying
if (response.ValueKind == JsonValueKind.Undefined || response.ValueKind == JsonValueKind.Null)
{
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyNextResponse"));
setErrorMsg("Gateway returned empty response for wizard.next");
setWizardState("error");
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorEmptyNextResponse"));
SaveState("error", "Empty wizard.next response");
}
else
{
@ -340,9 +333,9 @@ public sealed class WizardPage : Component<OnboardingState>
if (!client.IsConnectedToGateway)
{
setErrorMsg(LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnectedDetail"));
setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection.");
setWizardState("error");
SaveState("error", LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnected"));
SaveState("error", "Gateway disconnected");
return;
}
@ -471,21 +464,21 @@ public sealed class WizardPage : Component<OnboardingState>
}
else
{
inputArea = TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_NoOptionsAvailable")).FontSize(12).Opacity(0.5);
inputArea = TextBlock("No options available").FontSize(12).Opacity(0.5);
showButtons = false; // Don't allow submit with no valid selection
}
}
else if (stepType == "confirm")
{
buttonLabel1 = LocalizationHelper.GetString("Onboarding_Wizard_Yes");
buttonLabel2 = LocalizationHelper.GetString("Onboarding_Wizard_NoSkip");
buttonLabel1 = "Yes";
buttonLabel2 = "No / Skip";
}
else if (stepType == "progress")
{
// Show spinner while gateway polls for auth completion
inputArea = HStack(8,
ProgressRing().Width(24).Height(24),
TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_Waiting")).FontSize(13).Opacity(0.7)
TextBlock("Waiting...").FontSize(13).Opacity(0.7)
.VAlign(VerticalAlignment.Center)
);
showButtons = false; // Gateway auto-advances on completion
@ -495,23 +488,23 @@ public sealed class WizardPage : Component<OnboardingState>
case "complete":
displayTitle = $"✅ {LocalizationHelper.GetString("Onboarding_Wizard_Complete")}";
displayMessage = LocalizationHelper.GetString("Onboarding_Wizard_ClickNextToContinue");
displayMessage = "Click Next to continue.";
break;
case "error":
displayTitle = $"❌ {LocalizationHelper.GetString("Onboarding_Wizard_ErrorTitle")}";
displayTitle = "❌ Wizard error";
displayMessage = errorMsg;
showButtons = true;
buttonLabel1 = LocalizationHelper.GetString("Onboarding_Retry");
buttonLabel2 = LocalizationHelper.GetString("Onboarding_Wizard_SkipWizard");
buttonLabel1 = "Retry";
buttonLabel2 = "Skip Wizard";
break;
case "loading":
displayTitle = $"🔄 {LocalizationHelper.GetString("Onboarding_Connection_StatusAuthenticating")}";
displayMessage = LocalizationHelper.GetString("Onboarding_Wizard_ConnectingToGateway");
displayMessage = "Connecting to gateway...";
inputArea = HStack(8,
ProgressRing().Width(24).Height(24),
TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_ConnectionWaitDetail"))
TextBlock("Please wait while the connection is established...")
.FontSize(13).Opacity(0.7)
.VAlign(VerticalAlignment.Center)
);
@ -519,7 +512,7 @@ public sealed class WizardPage : Component<OnboardingState>
default:
displayTitle = $"🔌 {LocalizationHelper.GetString("Onboarding_Wizard_Offline")}";
displayMessage = $"{LocalizationHelper.GetString("Onboarding_Wizard_OfflineMessage")}\n\n{LocalizationHelper.GetString("Onboarding_Wizard_ClickNextToContinue")}";
displayMessage = $"{LocalizationHelper.GetString("Onboarding_Wizard_OfflineMessage")}\n\nClick Next to continue.";
break;
}
@ -530,7 +523,7 @@ public sealed class WizardPage : Component<OnboardingState>
if (!string.IsNullOrEmpty(displayMessage))
{
// URL detection — find https:// URLs in the message
var urlMatch = UrlInMessagePattern.Match(displayMessage);
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
if (urlMatch.Success)
{
var detectedUrl = urlMatch.Value;
@ -552,7 +545,9 @@ public sealed class WizardPage : Component<OnboardingState>
// Capture must contain a digit or hyphen (or be all uppercase) to avoid
// matching common English words like "below" that follow "code".
// Case-sensitive on the value to require the GitHub-style uppercase code.
var codeMatch = DeviceCodePattern.Match(displayMessage);
var codeMatch = Regex.Match(
displayMessage,
@"(?:^|\s)(?:[Cc]ode|user_code|USER_CODE)\s*[:=]\s*([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b");
if (codeMatch.Success)
{
var code = codeMatch.Groups[1].Value;
@ -586,7 +581,7 @@ public sealed class WizardPage : Component<OnboardingState>
{
if (!string.IsNullOrEmpty(displayMessage))
{
var urlMatch = UrlInMessagePattern.Match(displayMessage);
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
if (urlMatch.Success)
{
try
@ -644,3 +639,4 @@ public sealed class WizardPage : Component<OnboardingState>
.Padding(0, 8, 0, 0);
}
}

View File

@ -1,4 +1,4 @@
using OpenClaw.Shared;
using System;
namespace OpenClawTray.Onboarding.Services;
@ -10,5 +10,18 @@ public static class LocalGatewayApprover
/// <summary>
/// Checks if the gateway URL points to localhost.
/// </summary>
public static bool IsLocalGateway(string gatewayUrl) => LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl);
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;
}
}
}

View File

@ -1,50 +0,0 @@
using OpenClawTray.Services.LocalGatewaySetup;
namespace OpenClawTray.Onboarding.Services;
/// <summary>
/// Pure mapping helpers for <c>LocalSetupProgressPage</c> nav-bar policy
/// (Phase 5 final). Lives in the Services namespace (no WinUI / FunctionalUI
/// dependencies) so unit tests in <c>OpenClaw.Tray.Tests</c> can import it
/// directly via the project's selective <c>&lt;Compile Include&gt;</c> list.
/// </summary>
public static class LocalSetupProgressPolicy
{
/// <summary>
/// Maps a <see cref="LocalGatewaySetupState"/> 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 &gt; 0
/// on LocalSetupProgress because SetupWarning is page 0).
/// </summary>
public static OnboardingNextButtonState MapStatusToNextButtonState(LocalGatewaySetupState? snapshot, LocalGatewaySetupStatus status)
=> MapStatusToNextButtonState(snapshot != null, status);
/// <summary>
/// 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 <see cref="LocalGatewaySetupState"/> reference,
/// so it passes <c>hasSnapshot</c> + <c>status</c> directly. The original
/// reference-typed overload is preserved for back-compat with existing tests.
/// </summary>
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,
};
}
}

View File

@ -1,131 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using OpenClawTray.Services.LocalGatewaySetup;
namespace OpenClawTray.Onboarding.Services;
/// <summary>
/// Pure helpers for <c>LocalSetupProgressPage</c>'s stage-list rendering
/// (Phase 5). Lives in the Services namespace (no WinUI / FunctionalUI
/// dependencies) so unit tests in <c>OpenClaw.Tray.Tests</c> can import
/// it directly via the project's selective <c>&lt;Compile Include&gt;</c> 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 <see cref="LocalGatewaySetupEngine.StateChanged"/>
/// with the same mutating <see cref="LocalGatewaySetupState"/> instance,
/// so reference-equality in <c>UseState</c> suppressed re-renders.
/// 2. The stage-state computation depended on <see cref="LocalGatewaySetupPhase.Failed"/>'s
/// ordinal, but on failure the engine pins <c>Phase = Failed</c> (the highest
/// ordinal), losing the position of the last running phase. This helper
/// threads <c>lastRunningPhase</c> explicitly so failure rendering is
/// stable across the engine's full phase set.
/// </summary>
public static class LocalSetupProgressStageMap
{
public enum StageState
{
Pending,
Active,
Complete,
Failed,
}
public sealed record VisibleStage(string LabelKey, LocalGatewaySetupPhase[] Phases);
/// <summary>
/// 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.
/// </summary>
public static readonly IReadOnlyList<VisibleStage> 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 }),
};
/// <summary>
/// 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 <see cref="LocalGatewaySetupState.History"/>).
/// </summary>
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;
}
/// <summary>
/// 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. <see cref="LocalGatewaySetupPhase.NotStarted"/>
/// or <see cref="LocalGatewaySetupPhase.Complete"/>).
/// </summary>
public static int IndexOfStageForPhase(LocalGatewaySetupPhase phase)
{
for (int i = 0; i < VisibleStages.Count; i++)
{
if (VisibleStages[i].Phases.Contains(phase))
return i;
}
return -1;
}
/// <summary>
/// True when the page should render the inline error / retry row
/// (FailedRetryable or FailedTerminal). All other statuses collapse it.
/// </summary>
public static bool ShouldShowErrorRow(LocalGatewaySetupStatus status)
=> status == LocalGatewaySetupStatus.FailedRetryable
|| status == LocalGatewaySetupStatus.FailedTerminal;
/// <summary>
/// True when the inline error row should expose a Try Again button —
/// only on FailedRetryable. FailedTerminal forces Back-out.
/// </summary>
public static bool ShouldShowRetryButton(LocalGatewaySetupStatus status)
=> status == LocalGatewaySetupStatus.FailedRetryable;
}

View File

@ -1,123 +0,0 @@
using System.Text.Json;
using OpenClaw.Shared;
using OpenClawTray.Services;
using OpenClawTray.Services.LocalGatewaySetup;
namespace OpenClawTray.Onboarding.Services;
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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).
/// </summary>
public bool HasExistingConfiguration() => GetSummary().HasAny;
/// <summary>
/// Returns a detailed breakdown of which configuration components exist.
/// Sync — reads settings (in-memory), device-key files, and setup-state.json.
/// </summary>
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);
}
/// <summary>
/// Async-enriched summary that also probes WSL for the OpenClawGateway distro.
/// </summary>
public async Task<ExistingConfigurationSummary> 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;
}
}
/// <summary>
/// Breakdown of which existing configuration components were found.
/// </summary>
public sealed record ExistingConfigurationSummary(
bool HasToken,
bool HasBootstrapToken,
bool HasNonDefaultGatewayUrl,
bool HasOperatorDeviceToken,
bool HasNodeDeviceToken,
bool HasCompletedOrRunningSetupState,
bool HasWslDistro)
{
/// <summary>True if any configuration component exists.</summary>
public bool HasAny =>
HasToken || HasBootstrapToken || HasNonDefaultGatewayUrl
|| HasOperatorDeviceToken || HasNodeDeviceToken
|| HasCompletedOrRunningSetupState || HasWslDistro;
}

View File

@ -16,7 +16,7 @@ public sealed class OnboardingState : IDisposable
/// <summary>
/// The currently displayed route. Updated by OnboardingApp on navigation.
/// </summary>
public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.SetupWarning;
public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.Welcome;
/// <summary>
/// Raised when the current route changes to or from the Chat page.
@ -31,55 +31,6 @@ public sealed class OnboardingState : IDisposable
/// </summary>
public ConnectionMode Mode { get; set; } = ConnectionMode.Local;
/// <summary>
/// Forked-onboarding setup path (Phase 5). Null until the user picks a path
/// on <see cref="OnboardingRoute.SetupWarning"/>. While null, the nav-bar
/// "Next" button is disabled on the SetupWarning page.
/// </summary>
public SetupPath? SetupPath { get; set; }
/// <summary>
/// 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).
/// </summary>
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");
}
/// <summary>
/// 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 <see cref="NavBarStateChanged"/>;
/// <see cref="OnboardingApp"/> consults this for routes that opt in (currently
/// only <see cref="OnboardingRoute.LocalSetupProgress"/>) and falls back to its
/// legacy logic everywhere else.
/// </summary>
public OnboardingNextButtonState NextButtonState { get; private set; } = OnboardingNextButtonState.Default;
/// <summary>
/// Raised when <see cref="NextButtonState"/> changes so <see cref="OnboardingApp"/>
/// can re-render the nav bar.
/// </summary>
public event EventHandler? NavBarStateChanged;
/// <summary>
/// Sets <see cref="NextButtonState"/> and raises <see cref="NavBarStateChanged"/>
/// if the value actually changed.
/// </summary>
public void SetNextButtonState(OnboardingNextButtonState state)
{
if (NextButtonState == state) return;
NextButtonState = state;
NavBarStateChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Whether the onboarding chat page should be shown.
/// </summary>
@ -111,69 +62,38 @@ public sealed class OnboardingState : IDisposable
/// <summary>Wizard error message if in error state.</summary>
public string? WizardError { get; set; }
/// <summary>
/// Guard that detects existing tray configuration.
/// Set by <see cref="OnboardingWindow"/> after construction.
/// Null when not available (startup auto-onboarding or env-override paths).
/// </summary>
public OnboardingExistingConfigGuard? ExistingConfigGuard { get; set; }
/// <summary>
/// Set to true by <see cref="SetupWarningPage"/> warn-and-confirm flow
/// before advancing to the local setup path. Required by
/// <see cref="LocalSetupProgressPage"/> defense-in-depth guard and the
/// <see cref="LocalGatewaySetupEngineFactory"/> fail-closed check.
/// </summary>
public bool ReplaceExistingConfigurationConfirmed { get; set; }
public OnboardingState(SettingsManager settings)
{
Settings = settings;
}
/// <summary>
/// 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
/// (<see cref="SetupPath"/>) determines whether page 1 is the local-setup
/// progress page or the legacy advanced Connection page.
/// Returns the page order based on the selected mode and chat preference,
/// matching the macOS onboarding flow.
/// </summary>
public OnboardingRoute[] GetPageOrder()
{
// 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)
// Node mode: skip Wizard and Chat — node clients can't use operator RPCs
if (Settings.EnableNodeMode)
{
return [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready];
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],
};
}
if (path == Onboarding.Services.SetupPath.Local)
return (Mode, ShowChat) switch
{
// 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],
// 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],
};
}
@ -210,41 +130,10 @@ public enum ConnectionMode
public enum OnboardingRoute
{
SetupWarning,
LocalSetupProgress,
Welcome,
Connection,
Wizard,
Permissions,
Chat,
Ready,
}
/// <summary>
/// Forked-onboarding setup path picked on <see cref="OnboardingRoute.SetupWarning"/>.
/// </summary>
public enum SetupPath
{
/// <summary>User chose "Set up locally" — run the WSL gateway setup engine.</summary>
Local,
/// <summary>User chose "Advanced setup" — fall through to the legacy ConnectionPage.</summary>
Advanced,
}
/// <summary>
/// Per-page nav-bar Next button state override (Phase 5 final). Pages set this on
/// <see cref="OnboardingState.SetNextButtonState"/> to opt out of the default
/// "always visible+enabled (Disabled only on SetupWarning until path chosen)"
/// behavior. <see cref="OnboardingApp"/> consults this for routes that opt in
/// (currently only <see cref="OnboardingRoute.LocalSetupProgress"/>).
/// </summary>
public enum OnboardingNextButtonState
{
/// <summary>Use legacy nav-bar logic — visible+enabled unless route-specific defaults apply.</summary>
Default,
/// <summary>Next button collapsed entirely (e.g., LocalSetupProgress Idle state).</summary>
Hidden,
/// <summary>Next button visible but disabled (e.g., LocalSetupProgress Running / FailedRetryable / FailedTerminal).</summary>
VisibleDisabled,
/// <summary>Next button visible and enabled (e.g., LocalSetupProgress Complete during the 1s pre-auto-advance window).</summary>
VisibleEnabled,
}

View File

@ -6,7 +6,8 @@ using OpenClaw.Shared;
namespace OpenClawTray.Onboarding.Services;
/// <summary>
/// Decodes upstream OpenClaw setup codes into gateway URL and bootstrap token fields.
/// Decodes base64url-encoded setup codes into gateway URL and token.
/// Extracted from ConnectionPage for testability.
/// </summary>
public static class SetupCodeDecoder
{
@ -23,10 +24,10 @@ public static class SetupCodeDecoder
string json;
try
{
// Base64url decode: replace URL-safe chars, add padding
var b64 = setupCode.Trim().Replace('-', '+').Replace('_', '/');
var pad = b64.Length % 4;
if (pad > 0)
b64 += new string('=', 4 - pad);
if (pad > 0) b64 += new string('=', 4 - pad);
json = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
@ -40,17 +41,12 @@ public static class SetupCodeDecoder
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return new DecodeResult(false, Error: "Setup code JSON must be an object");
var doc = JsonDocument.Parse(json);
string? url = null;
string? token = null;
if (doc.RootElement.TryGetProperty("url", out var urlProp))
{
if (urlProp.ValueKind != JsonValueKind.String)
return new DecodeResult(false, Error: "Invalid gateway URL in setup code");
var decoded = urlProp.GetString() ?? "";
if (!string.IsNullOrEmpty(decoded))
{
@ -62,18 +58,12 @@ public static class SetupCodeDecoder
if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp))
{
if (tokenProp.ValueKind != JsonValueKind.String)
return new DecodeResult(false, Error: "Invalid bootstrap token in setup code");
var decoded = tokenProp.GetString() ?? "";
if (decoded.Length > 512)
return new DecodeResult(false, Error: "Bootstrap token exceeds 512 character limit");
if (!string.IsNullOrEmpty(decoded))
if (decoded.Length <= 512)
token = decoded;
// Token exceeding 512 chars is silently ignored (not set)
}
if (url == null && token == null)
return new DecodeResult(false, Error: "Setup code must include a gateway URL or bootstrap token");
return new DecodeResult(true, Url: url, Token: token);
}
catch (JsonException ex)

View File

@ -1,215 +0,0 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Shared;
using OpenClawTray.Services;
namespace OpenClawTray.Onboarding.Services;
/// <summary>
/// Testable, UI-free recovery rules for gateway-backed onboarding wizard flows.
/// </summary>
public interface IWizardGateway
{
bool IsConnectedToGateway { get; }
event EventHandler<ConnectionStatus>? StatusChanged;
Task<JsonElement> 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<ConnectionStatus>? StatusChanged
{
add => _client.StatusChanged += value;
remove => _client.StatusChanged -= value;
}
public Task<JsonElement> SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) =>
_client.SendWizardRequestAsync(method, parameters, timeoutMs);
}
/// <summary>
/// Mutable recovery guard stored by reference in FunctionalUI state. Do not replace this
/// with UseState&lt;bool&gt;: render closures must observe current fields synchronously.
/// </summary>
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<JsonElement> RestartWizardAsync(
WizardRecoveryGuardState guard,
Action clearWizardSessionState,
Func<Task<JsonElement>> startWizardAsync)
{
guard.ResetForManualRestart();
clearWizardSessionState();
return await startWizardAsync();
}
public static async Task<WizardRecoveryResult> TryRecoverAsync(
Exception exception,
IWizardGateway? client,
WizardRecoveryGuardState guard,
WizardRequestContext requestContext,
Func<Task<JsonElement>> 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);
}
}
/// <summary>
/// Waits up to <paramref name="maxPollCount"/> poll intervals for the gateway to
/// (re-)connect. Returns true if connected at exit, false on timeout. The
/// <paramref name="delayAsync"/> delegate is injected so unit tests can run instantly.
/// Pass <paramref name="cancellationToken"/> to abort polling early (e.g., on app shutdown
/// or page navigation away); throws <see cref="OperationCanceledException"/> if cancelled.
/// </summary>
public static async Task<bool> WaitForConnectionAsync(
IWizardGateway? client,
int maxPollCount = 30,
Func<Task>? 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;
}
/// <summary>
/// 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 <see cref="WaitForConnectionAsync"/> first so IsConnectedToGateway is true
/// when this method runs.
/// </summary>
public static async Task<(bool Resumed, JsonElement Payload)> TryResumeWithSessionAsync(
string? sessionId,
IWizardGateway? client,
Func<string, Task<JsonElement>> sendWizardNextNoAnswerAsync,
Func<Task<JsonElement>> 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);
}
}

View File

@ -1,49 +0,0 @@
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<string> 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<string> 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<string> optionValues) =>
RequiresSelection(stepType) && !HasValidSelection(stepType, stepInput, optionValues);
public static bool TryBuildAnswerValue(string stepType, string stepInput, IReadOnlyCollection<string> 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);
}

View File

@ -0,0 +1,32 @@
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
namespace OpenClawTray.Onboarding.Widgets;
public record FeatureRowProps(string Icon, string Title, string Subtitle);
/// <summary>
/// Icon + title + subtitle row for the Ready page.
/// </summary>
public sealed class FeatureRow : Component<FeatureRowProps>
{
public override Element Render()
{
return HStack(12,
TextBlock(Props.Icon)
.FontSize(20)
.Width(28)
.HAlign(HorizontalAlignment.Center),
VStack(2,
TextBlock(Props.Title)
.FontSize(14)
.FontWeight(new global::Windows.UI.Text.FontWeight(600)),
TextBlock(Props.Subtitle)
.FontSize(12)
.Opacity(0.7)
)
);
}
}

View File

@ -0,0 +1,22 @@
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using static OpenClawTray.FunctionalUI.Factories;
namespace OpenClawTray.Onboarding.Widgets;
/// <summary>
/// Reusable theme-aware card with rounded corners and padding.
/// Props: the child <see cref="Element"/> to render inside the card.
/// </summary>
public sealed class OnboardingCard : Component<Element>
{
public override Element Render()
{
return Border(
Props
)
.CornerRadius(12)
.BackgroundResource("CardBackgroundFillColorDefaultBrush")
.Padding(20, 20, 20, 20);
}
}

View File

@ -0,0 +1,26 @@
using System;
namespace OpenClawTray.Onboarding.Widgets;
public enum WizardStepType
{
Note,
Text,
Confirm,
Select,
MultiSelect,
Progress,
Action,
}
public record WizardStepProps(
string Id,
string Title,
string Message,
WizardStepType Type,
string[]? Options = null,
string? InitialValue = null,
string? Placeholder = null,
bool Sensitive = false,
Action<string>? OnSubmit = null
);

View File

@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
namespace OpenClawTray.Onboarding.Widgets;
/// <summary>
/// Dynamic wizard step renderer that adapts UI based on <see cref="WizardStepType"/>.
/// Used by the onboarding wizard to render steps received from the gateway RPC protocol.
/// </summary>
public sealed class WizardStepView : Component<WizardStepProps>
{
public override Element Render()
{
var body = Props.Type switch
{
WizardStepType.Note => RenderNote(),
WizardStepType.Text => RenderText(),
WizardStepType.Confirm => RenderConfirm(),
WizardStepType.Select => RenderSelect(),
WizardStepType.MultiSelect => RenderMultiSelect(),
WizardStepType.Progress => RenderProgress(),
WizardStepType.Action => RenderAction(),
_ => RenderNote(),
};
return body
.HAlign(HorizontalAlignment.Center)
.MaxWidth(460);
}
private Element Header() =>
VStack(8,
TextBlock(Props.Title)
.FontSize(20)
.FontWeight(new global::Windows.UI.Text.FontWeight(700))
.HAlign(HorizontalAlignment.Center),
TextBlock(Props.Message)
.FontSize(14)
.Opacity(0.7)
.HAlign(HorizontalAlignment.Center)
.TextWrapping()
);
private Element RenderNote() =>
VStack(16, Header());
private Element RenderText()
{
var (value, setValue) = UseState(Props.InitialValue ?? "");
Element input = Props.Sensitive
? PasswordBox(value, v => setValue(v), placeholderText: Props.Placeholder)
: TextField(value, v => setValue(v), placeholder: Props.Placeholder, header: null);
return VStack(16,
Header(),
Border(
VStack(12, input).Padding(16)
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
Button("Submit", () => Props.OnSubmit?.Invoke(value))
.HAlign(HorizontalAlignment.Center)
.Disabled(string.IsNullOrWhiteSpace(value))
);
}
private Element RenderConfirm()
{
return VStack(16,
Header(),
HStack(12,
Button("Yes", () => Props.OnSubmit?.Invoke("Yes")),
Button("No", () => Props.OnSubmit?.Invoke("No"))
).HAlign(HorizontalAlignment.Center)
);
}
private Element RenderSelect()
{
var options = Props.Options ?? [];
var initialIndex = Props.InitialValue != null
? Array.IndexOf(options, Props.InitialValue)
: -1;
var (selected, setSelected) = UseState(initialIndex);
return VStack(16,
Header(),
Border(
VStack(4,
options.Select((opt, i) =>
RadioButton(opt, selected == i, _ => setSelected(i), groupName: Props.Id)
).ToArray()
).Padding(16)
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
Button("Submit", () =>
{
if (selected >= 0 && selected < options.Length)
Props.OnSubmit?.Invoke(options[selected]);
})
.HAlign(HorizontalAlignment.Center)
.Disabled(selected < 0)
);
}
private Element RenderMultiSelect()
{
var options = Props.Options ?? [];
var (selections, setSelections) = UseState(new HashSet<int>());
var toggles = options.Select((opt, i) =>
{
var isChecked = selections.Contains(i);
return HStack(8,
CheckBox(isChecked, _ =>
{
var next = new HashSet<int>(selections);
if (isChecked) next.Remove(i); else next.Add(i);
setSelections(next);
}),
TextBlock(opt).FontSize(13)
.VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center)
);
}).ToArray();
return VStack(16,
Header(),
Border(
VStack(6, toggles).Padding(16)
).CornerRadius(8).BackgroundResource("CardBackgroundFillColorDefaultBrush"),
Button("Submit", () =>
{
var chosen = selections
.Where(i => i >= 0 && i < options.Length)
.OrderBy(i => i)
.Select(i => options[i]);
Props.OnSubmit?.Invoke(string.Join(",", chosen));
})
.HAlign(HorizontalAlignment.Center)
.Disabled(selections.Count == 0)
);
}
private Element RenderProgress()
{
return VStack(16,
Header(),
TextBlock("⏳ Processing…")
.FontSize(14)
.Opacity(0.6)
.HAlign(HorizontalAlignment.Center)
);
}
private Element RenderAction()
{
return VStack(16,
Header(),
Button(Props.InitialValue ?? "Run", () => Props.OnSubmit?.Invoke("action"))
.HAlign(HorizontalAlignment.Center)
);
}
}

View File

@ -49,21 +49,17 @@
<ItemGroup>
<InternalsVisibleTo Include="OpenClaw.Tray.UITests" />
<InternalsVisibleTo Include="OpenClaw.Tray.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.1" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
<PackageReference Include="WinUIEx" Version="2.9.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.7" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Updatum" Version="1.3.4" />
<PackageReference Include="NAudio.Wasapi" Version="2.3.0" />
<PackageReference Include="org.k2fsa.sherpa.onnx" Version="1.13.0" />
<PackageReference Include="Zeroconf" Version="3.6.11" />
<PackageReference Include="ZXing.Net" Version="0.16.10" />
</ItemGroup>

View File

@ -1,103 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Page
x:Class="OpenClawTray.Pages.AboutPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Padding="24" Spacing="16" HorizontalAlignment="Stretch">
<TextBlock x:Uid="AboutPage_TextBlock_10" Text="About" Style="{StaticResource TitleTextBlockStyle}"/>
<!-- App Info Card -->
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Orientation="Horizontal" Spacing="16">
<TextBlock Text="🦞" FontSize="48" VerticalAlignment="Center"/>
<StackPanel Spacing="4" VerticalAlignment="Center">
<TextBlock x:Uid="AboutPage_TextBlock_19" Text="OpenClaw Hub" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Uid="VersionText" x:Name="VersionText" Text="v0.1.0"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Uid="AboutPage_TextBlock_23" Text=".NET 10 / WinUI 3 / WinAppSDK 1.8"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Gateway Info Card -->
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock x:Uid="AboutPage_TextBlock_35" Text="Gateway Info" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<Grid ColumnSpacing="12" RowSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock x:Uid="AboutPage_TextBlock_48" Grid.Row="0" Grid.Column="0" Text="Version:"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="GatewayVersionText" Grid.Row="0" Grid.Column="1" Text="—"/>
<TextBlock x:Uid="AboutPage_TextBlock_52" Grid.Row="1" Grid.Column="0" Text="Model:"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="GatewayModelText" Grid.Row="1" Grid.Column="1" Text="—"/>
<TextBlock x:Uid="AboutPage_TextBlock_56" Grid.Row="2" Grid.Column="0" Text="Auth mode:"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="GatewayAuthText" Grid.Row="2" Grid.Column="1" Text="—"/>
<TextBlock x:Uid="AboutPage_TextBlock_60" Grid.Row="3" Grid.Column="0" Text="Uptime:"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="GatewayUptimeText" Grid.Row="3" Grid.Column="1" Text="—"/>
</Grid>
</StackPanel>
</Border>
<!-- Debug Section -->
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="12">
<TextBlock x:Uid="AboutPage_TextBlock_72" Text="Debug" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Uid="OpenLogButton" x:Name="OpenLogButton" Content="Open Log File"
Click="OnOpenLogClick"/>
<Button x:Uid="OpenConfigButton" x:Name="OpenConfigButton" Content="Open Config Folder"
Click="OnOpenConfigClick"/>
<Button x:Uid="CopySupportButton" x:Name="CopySupportButton" Content="Copy Support Context"
Click="OnCopySupportClick"/>
<Button x:Uid="CheckUpdatesButton" x:Name="CheckUpdatesButton" Content="Check for Updates"
Click="OnCheckUpdatesClick"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Links Section -->
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16">
<StackPanel Spacing="8">
<TextBlock x:Uid="AboutPage_TextBlock_91" Text="Links" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_92" Content="Documentation → openclaw.ai/docs"
Click="OnDocumentationClick"/>
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_94" Content="GitHub → github.com/openclaw/openclaw-windows-node"
Click="OnGitHubClick"/>
<HyperlinkButton x:Uid="AboutPage_HyperlinkButton_96" Content="Dashboard → openclaw://dashboard"
Click="OnDashboardClick"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Page>

View File

@ -1,129 +0,0 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OpenClawTray.Windows;
using System;
using System.Diagnostics;
using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer;
namespace OpenClawTray.Pages;
public sealed partial class AboutPage : Page
{
private HubWindow? _hub;
public AboutPage()
{
InitializeComponent();
}
public void Initialize(HubWindow hub)
{
_hub = hub;
TryLoadGatewayInfo();
}
public void RefreshGatewayInfo() => TryLoadGatewayInfo();
private void TryLoadGatewayInfo()
{
var self = _hub?.LastGatewaySelf;
if (_hub?.CurrentStatus == OpenClaw.Shared.ConnectionStatus.Connected && self != null)
{
GatewayVersionText.Text = self.VersionText;
GatewayModelText.Text = self.Protocol.HasValue ? $"protocol v{self.Protocol}" : "unknown";
GatewayAuthText.Text = string.IsNullOrWhiteSpace(self.AuthMode) ? "unknown" : self.AuthMode;
GatewayUptimeText.Text = self.UptimeText;
}
else
{
GatewayVersionText.Text = "—";
GatewayModelText.Text = "—";
GatewayAuthText.Text = "—";
GatewayUptimeText.Text = "—";
}
}
private void OnOpenLogClick(object sender, RoutedEventArgs e)
{
try
{
var logPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OpenClawTray", "openclaw-tray.log");
Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to open log file: {ex.Message}");
}
}
private void OnOpenConfigClick(object sender, RoutedEventArgs e)
{
try
{
var configPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenClawTray");
Process.Start(new ProcessStartInfo(configPath) { UseShellExecute = true });
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to open config folder: {ex.Message}");
}
}
private async void OnCopySupportClick(object sender, RoutedEventArgs e)
{
try
{
var context = $"OpenClaw Hub v0.1.0\n"
+ $"OS: {Environment.OSVersion}\n"
+ $"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n"
+ $"Connection: {_hub?.CurrentStatus}\n"
+ $"Gateway: {_hub?.Settings?.GetEffectiveGatewayUrl() ?? "n/a"}\n";
var dataPackage = new WinDataTransfer.DataPackage();
dataPackage.SetText(context);
WinDataTransfer.Clipboard.SetContent(dataPackage);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to copy support context: {ex.Message}");
}
}
private void OnCheckUpdatesClick(object sender, RoutedEventArgs e)
{
_hub?.CheckForUpdatesAction?.Invoke();
}
private void OnDocumentationClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://openclaw.ai/docs") { UseShellExecute = true });
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to open docs: {ex.Message}");
}
}
private void OnGitHubClick(object sender, RoutedEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo("https://github.com/openclaw/openclaw-windows-node") { UseShellExecute = true });
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to open GitHub: {ex.Message}");
}
}
private void OnDashboardClick(object sender, RoutedEventArgs e)
{
_hub?.OpenDashboardAction?.Invoke(null);
}
}

Some files were not shown because too many files have changed in this diff Show More