Compare commits

...

31 Commits

Author SHA1 Message Date
Mike Harsh
581f78d276
feat: add WSL local gateway onboarding
Some checks failed
Build and Test / test (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Build and Test / build (win-arm64) (push) Has been cancelled
Build and Test / build (win-x64) (push) Has been cancelled
Build and Test / build-msix (ARM64, win-arm64) (push) Has been cancelled
Build and Test / build-msix (x64, win-x64) (push) Has been cancelled
Build and Test / build-extension (arm64) (push) Has been cancelled
Build and Test / build-extension (x64) (push) Has been cancelled
Add WSL local gateway setup and onboarding flow, with isolated validation and a fixed fresh Tray launch path for WSL validation.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 11:50:52 -04:00
AlexAlves87
57ebebc725
feat: normalize exec approval command identity
Normalize command identity for exec approvals and fail closed on PowerShell EncodedCommand abbreviations, including -en.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 19:26:48 -04:00
Copilot
bcd1e633e6
fix(quicksend): preserve focus workaround with custom titlebar (#285)
Keeps the Quick Send custom titlebar styling while preserving the Windows hotkey foreground/topmost retry path and avoiding close-on-deactivation data loss.\n\nValidation: local ARM64 build passed; Shared tests 1319 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 19:04:39 -04:00
github-actions[bot]
56d956d723
fix(security): handle PowerShell EncodedCommand abbreviations (#270)
Some checks are pending
Build and Test / test (push) Waiting to run
Build and Test / build (win-arm64) (push) Blocked by required conditions
Build and Test / build (win-x64) (push) Blocked by required conditions
Build and Test / build-msix (ARM64, win-arm64) (push) Blocked by required conditions
Build and Test / build-msix (x64, win-x64) (push) Blocked by required conditions
Build and Test / build-extension (arm64) (push) Blocked by required conditions
Build and Test / build-extension (x64) (push) Blocked by required conditions
Build and Test / release (push) Blocked by required conditions
Handles PowerShell EncodedCommand aliases and separator forms, including -e, so approval evaluation remains fail-closed.\n\nValidation: local ARM64 build passed; Shared tests 1319 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:12:46 -04:00
github-actions[bot]
6e8a9d72ad
perf: precompile redaction and UI regexes (#286)
Precompiles reusable regexes in redaction/UI helpers and keeps QuickSend-specific overlap out of this PR.\n\nValidation: local ARM64 build passed; Shared tests 1296 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:12:41 -04:00
github-actions[bot]
ad120cdf70
fix(security): stop leaking capability exception messages (#291)
Sanitizes node capability error responses while preserving details in local logs and updates tests for generic responses.\n\nValidation: local ARM64 build passed; Shared tests 1296 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:12:36 -04:00
Christine Yan
43873c1005
fix: enforce min window size and pin navigation sidebar (#293)
Resolved conflict with current master, preserving navigation pane persistence while keeping the wider minimum size and dynamic pane width.\n\nValidation: local ARM64 build passed; Shared tests 1296 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:12:33 -04:00
Christine Yan
adcccc9b56
feat: video capture frontend — consent, notifications, activity stream & settings (#292)
* feat: add recording state tracking to NodeService

Add IsScreenRecording/IsCameraRecording properties and RecordingStateChanged
event to NodeService. Wrap OnScreenRecord and OnCameraClip handlers to set
state and raise events before/after async recording calls.

This enables downstream UI components (tray icon, toasts, activity log) to
react to recording lifecycle changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add toast notifications for screen and camera recording

Show toasts on recording start, completion, and failure for both screen
recording and camera clips. Extract reusable ShowToast helper and add
localized strings for all 5 locales (en-us, fr-fr, zh-cn, zh-tw, nl-nl).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: log recording events to activity stream

Add recording start/complete events with emoji indicators (🔴/) to the
activity stream. Render emoji in a separate TextBlock element to prevent
color emoji clipping by the card's CornerRadius clip mask.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add recording consent dialog before first recording

Show a standalone WindowEx consent dialog the first time an agent
requests screen or camera recording. Consent is tracked separately
per recording type (ScreenRecordingConsentGiven, CameraRecordingConsentGiven)
so users can allow screen recording without granting camera access.
The dialog uses extend-into-titlebar styling, Mica backdrop, and
SetForegroundWindow to ensure visibility.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add privacy settings UI and polish consent dialog

- Add Privacy section to Settings with screen/camera recording toggles
- Settings toggles auto-refresh when consent changes externally
- Fix consent dialog z-order with HWND_TOPMOST technique
- Fix button width (MinWidth instead of fixed Width)
- Add SettingsManager.Saved event for cross-component reactivity
- Allow button uses AccentButtonStyle for consistency
- Remove misleading 'only asked once' from privacy text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: serialize consent dialogs and settings saves to prevent races

- Add SemaphoreSlim guard in EnsureRecordingConsentAsync so concurrent
  recording requests coalesce onto a single consent dialog per type
- Add lock around SettingsManager.Save() to prevent concurrent file writes
- Update privacy toggle text in all 5 locales to clarify that enabling
  skips future consent prompts (e.g. 'Allow screen recording without prompting')

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add 3-2-1 countdown overlay before recording starts

Show a translucent topmost countdown window (3 → 2 → 1) before screen
and camera recordings begin, similar to Windows Snipping Tool. Gives users
clear visual indication that recording is about to start.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(video): harden recording consent persistence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: settings dirty-state guard, consent dialog copy, and tests

- Add dirty-state guard to SettingsPage: external consent saves no
  longer overwrite unsaved user edits on the Settings page
- Update consent dialog description in all 5 locales to explicitly
  state that the choice persists until changed in Settings
- Add 4 focused tests for settings save thread safety, Saved event,
  consent persistence, and consent revocation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Christine Yan <christineyan@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Scott Hanselman <scott@hanselman.com>
2026-05-07 17:05:17 -04:00
Régis Brid
b0ba9affa2
audio: Whisper STT + Piper TTS as MCP-callable node capabilities (#288)
* Add Windows STT transcribe capability

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* stt: privacy hardening, localization, and test coverage

Review-driven cleanup on top of the initial stt.transcribe capability.
No behavior change for successful invocations.

Privacy:
* SttCapability no longer echoes the caller-supplied language tag in the
  "Invalid language tag" error, and no longer interpolates the underlying
  exception's Message into "Transcribe failed". Both could end up in the
  recent-activity stream and BuildSupportBundle output, which can be
  shared off-device. Full detail still goes to the local logger.
* App.OnNodeInvokeCompleted now sanitizes failed-invoke details for
  privacy-sensitive commands (stt.transcribe, camera.snap/clip,
  screen.snapshot/record). Recent activity and support bundles record
  only "privacy-sensitive | <ms> | error" instead of the raw error
  string. Non-privacy-sensitive commands keep the error text since it is
  useful for diagnostics and does not carry mic/camera args.
* Models.cs PermissionDiagnostics microphone detail now mentions
  stt.transcribe instead of "future voice features", so users hitting
  0x800455A0 see microphone in their permissions checklist as relevant.

Refactors for testability (no behavior change):
* New Services/NodeInvokeActivityFormatter.cs owns GetPrivacyClass and
  BuildDetails. App.OnNodeInvokeCompleted delegates to it.
* New Services/NodeCapabilityGating.cs owns the optional-capability
  predicates. NodeService.RegisterCapabilities calls into it instead of
  inlining "_settings?.NodeXxxEnabled" checks. Privacy-sensitive
  defaults stay off; everything else stays default-on.
* Both helpers are linked into OpenClaw.Tray.Tests.

Localization:
* SettingsWindow.xaml gains x:Uid for every TTS and STT control. The
  literal Text/Header/PlaceholderText values are kept as dev-time
  fallbacks, matching the SettingsTokenTextBox and SettingsMcpDescription
  pattern already in the file.
* en-us, fr-fr, nl-nl, zh-cn, and zh-tw .resw files gain matching
  entries for the 14 new TTS/STT keys. Brand names (ElevenLabs),
  command names (tts.speak, stt.transcribe, gateway.nodes.allowCommands,
  MSIX), BCP-47 tags, and the eleven_multilingual_v2 model identifier
  are kept verbatim across all locales.
* SettingsMcpDescription.Text in all five locales now lists "microphone"
  and "speakers" alongside camera/screen/canvas so the local MCP-server
  description reflects the full Phase 1 + Phase 2 voice surface.

Tests:
* Two new privacy regression tests in CapabilityTests verify that an
  invalid language and a thrown handler exception never leak their text
  into the response error.
* New NodeInvokeActivityFormatterTests pin the privacy-class table, the
  sanitized details for privacy-sensitive failures, and the full
  ActivityStreamService.BuildSupportBundle path.
* New NodeCapabilityGatingTests pin that tts.speak and stt.transcribe
  default off (including for null settings) and that the two capabilities
  are independent consent surfaces.
* New SettingsWindowLocalizationCoverageTests parses SettingsWindow.xaml
  and asserts every new TTS/STT x:Uid resolves to the expected
  .Header/.Text/.Content/.PlaceholderText keys in en-us.
* ActivityStreamServiceTests and NodeInvokeActivityFormatterTests now
  share a non-parallel xUnit collection because ActivityStreamService is
  a static singleton; running both classes in parallel could otherwise
  cause flaky support-bundle assertions.
* NodeCapabilityGatingTests cleans up its temp settings directories.

Cleanup:
* Drop "Phase 2" wording from SpeechToTextService.cs; the resw section
  comments referring to "Phase 1 TTS / Phase 2 STT" are likewise
  reworded to plain "TTS / STT settings". Phase numbering is a planning
  artifact and should not appear in the codebase.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
  --no-restore  (1173 passed, 20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
  --no-restore  (465 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove fake/sample data from 6 UI pages

Replace constructor-injected sample data with empty/loading states:
- UsagePage: remove fabricated provider costs and daily data
- SessionsPage: remove 3 fake AI conversation sessions
- NodesPage: remove fake Desktop-PC/MacBook-Pro nodes
- ChannelsPage: remove fake Telegram/WhatsApp channels
- SkillsPage: remove fake skills and stale 'API not yet wired' warning
- CronPage: remove fake cron jobs, stale warning, fix hardcoded defaults

All pages now show proper empty states until real gateway data arrives.
The Skills and Cron APIs were already fully wired; the warnings were
simply outdated and misleading.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: add voice/audio support with local Whisper STT

Add full voice interaction capabilities to the Windows node:

Core audio pipeline:
- NAudio WASAPI microphone capture with MTA thread initialization
- Energy-based voice activity detection with hysteresis
- Whisper.net speech-to-text with multi-threaded inference
- Pre-buffer to capture speech onset before VAD triggers
- Auto-download of Whisper models from HuggingFace

Voice overlay window:
- Modern WinUI 3 floating window with Mica backdrop and custom title bar
- Chat-style transcript bubbles with segment consolidation
- Real-time audio level visualization
- Start/Stop, Mute, and Settings controls

STT node capability:
- stt.listen and stt.status MCP commands for agent-initiated listening
- Follows existing capability pattern (like TTS)

Voice settings page:
- Model size selection (tiny/base/small) with download management
- Language selection (auto-detect + 9 languages)
- Silence timeout slider
- TTS voice picker with Windows neural voice enumeration
- ElevenLabs provider configuration
- Voice preview button

Integration:
- Tray menu Voice item
- Ctrl+Alt+Shift+V global hotkey for push-to-talk
- Deep links: openclaw://voice, openclaw://voice-stop
- Gateway chat responses shown in voice overlay
- TTS response playback with mic muting to prevent echo
- Capabilities page STT toggle
- Hub navigation Voice & Audio page

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Merge master into user/rbrid/stt-capability

Master refactored 8 separate windows into a unified Hub app (#272), which
removed src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml(.cs) and
WebChatWindow.xaml.cs. Node-capability toggles now live in
Pages/CapabilitiesPage as a code-built list (one icon + label per
capability) instead of an XAML page with x:Uid-localized headers.

Conflict resolution and re-integration:

* Accepted master's deletion of SettingsWindow.xaml, SettingsWindow.xaml.cs,
  and WebChatWindow.xaml.cs. The TTS/STT controls and code-behind that this
  branch added to those files are obsolete with the new Hub UI.

* Pages/CapabilitiesPage.xaml.cs gains a Speech-to-Text toggle alongside the
  existing Camera/Canvas/Screen/Location/TTS toggles, plus 'stt' in the
  active-capabilities summary string. This is the natural minimal alignment
  with the new pattern: one capability = one entry in the toggle list.

* The TTS provider / ElevenLabs key/voice/model UI that this branch had
  added is dropped because master removed the corresponding settings
  surface entirely. The backend services (TextToSpeechService,
  ElevenLabsTextToSpeechClient) and the SettingsManager keys are intact;
  the values can be set via direct settings.json edit until a new UI
  surface lands.

* Resolved 5 .resw conflicts (en-us, fr-fr, nl-nl, zh-cn, zh-tw) by taking
  master's content. All TTS/STT resource keys this branch had added are
  removed because the controls referencing them are gone. The earlier
  SettingsMcpDescription update (adding 'microphone' and 'speakers' to
  the capability list) is outside the conflict region and is preserved.

* Deleted tests/OpenClaw.Tray.Tests/SettingsWindowLocalizationCoverageTests.cs.
  It pinned that 14 specific x:Uids on SettingsWindow.xaml had matching
  resw entries; the controls and the file no longer exist.

Refactors from this branch survived the auto-merge cleanly:
* App.xaml.cs OnNodeInvokeCompleted still delegates to
  NodeInvokeActivityFormatter for privacy-class scrubbing.
* NodeService.RegisterCapabilities still calls NodeCapabilityGating
  predicates for every optional capability, including TTS and STT.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
  --no-restore  (1183 passed, 20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
  --no-restore  (418 passed; restore required first because master's
  Tray.Tests now links GatewayDiscoveryService.cs which needs Zeroconf)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* stt/tts: refill settings UI gaps after the unified Hub merge

Master's Hub refactor (#272) removed the per-capability detail UI that
previously lived on SettingsWindow. The capability backends are intact but
have no in-app surface anymore: STT had no way to set the BCP-47 language
tag, and TTS had no way to pick the provider, ElevenLabs API key, voice
ID, or model without hand-editing settings.json.

CapabilitiesPage.xaml gains two new detail cards beneath the capability
toggle grid, mirroring the existing McpCard pattern (visible only when
the capability is enabled):

* SttCard:
    - Language TextBox bound to SttLanguage.
    - Commits on LostFocus or Enter.
    - Empty input restores the "en-US" default rather than persisting "".
    - Validates with SttCapability.NormalizeLanguageTag before saving so a
      typo in Settings cannot ship a broken default to the WinRT recognizer.
    - Status text never echoes the user-supplied tag back on the failure
      path; only the local UI affordance shows it (the activity stream
      / support bundle path was already privacy-scrubbed by an earlier
      commit on this branch).

* TtsCard:
    - Provider ComboBox (Windows built-in / ElevenLabs).
    - ElevenLabs sub-panel becomes visible only when that provider is
      selected. Holds API key (PasswordBox), voice ID, and model.
    - API key handling: when a key is already saved we render a fixed
      mask sentinel ("••••••••") instead of any plaintext. Saving the
      form treats the sentinel as "keep current key" so the user can
      change voice ID / model without retyping the key, and rotation
      requires explicitly typing a new key. The on-disk DPAPI encryption
      done by SettingsManager is unchanged.
    - All ElevenLabs fields commit on LostFocus.

SttCapability.NormalizeLanguageTag is promoted from private to public so
the UI validates against exactly the rule the wire protocol applies. No
behavior change for the capability itself.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
  --no-restore  (1183 passed, 20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
  --no-restore  (418 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: extend privacy class + tests for stt.listen and stt.status

NodeInvokeActivityFormatter.GetPrivacyClass now classifies any stt.*
command as privacy-sensitive, not just stt.transcribe. This catches
stt.listen (microphone capture) and stt.status (engine internals)
under the same scrubbing rules in the activity stream / support
bundle, and keeps the rule simple ("anything in the stt namespace").

Tests added:
* GetPrivacyClass: stt.listen, stt.status, stt.future-command rows.
* PrivacySensitive_FailedInvoke_OmitsErrorTextFromDetails: theory
  rows for stt.listen and stt.status alongside the existing
  stt.transcribe / camera.* / screen.* coverage.
* SttCapabilityTests: full coverage of the unified surface
  - Listen: timeoutMs clamps (below min, above max), default language
    "auto", invalid language rejected without echo, handler not
    wired, handler exception sanitized to "Listen failed", segments +
    engine metadata round-trip, cancellation.
  - Status: handler not wired, handler exception sanitized to
    "Status failed", per-engine readiness round-trip with download
    progress.
  - NormalizeLanguageTag: BCP-47 tags + "auto" sentinel
    (case-insensitive, normalized to lowercase) accepted; underscore
    / spaces / "automatic" rejected.
* SettingsRoundTripTests: round-trips SttEngine, SttModelName,
  SttSilenceTimeout, VoiceTtsEnabled, VoiceAudioFeedback through
  SettingsData.ToJson / FromJson.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1266 passed,
  20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (425 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: STM, locale audit, and coverage tests for STT/TTS card

* Added E:\OpenClawWindowsNode\Audio_STM.md — full STRIDE analysis
  of the merged audio surface (assets, trust boundaries, per-component
  threats, cross-references to code + tests, follow-up backlog).
* Promoted every new STT/TTS card string in CapabilitiesPage.xaml to
  x:Uid + resw entries across all five locales (en-us, fr-fr, nl-nl,
  zh-cn, zh-tw): engine picker labels, language input + help, "More
  voice settings…" link, TTS provider picker, ElevenLabs sub-panel
  fields. Brand names (ElevenLabs), the "auto" BCP-47 sentinel, and
  the eleven_multilingual_v2 model identifier are kept verbatim and
  registered as InvariantOrDeferred in LocalizationValidationTests.
* Added CapabilitiesPageLocalizationCoverageTests — pins every new
  STT/TTS x:Uid against expected resw key suffixes (.Text, .Header,
  .Content, .PlaceholderText) so a future hardcoded-string regression
  fails fast.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1266 passed,
  20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (461 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: extract SttEngineSelector + tests for engine selection rules

The engine-selection logic that NodeService.OnSttTranscribeAsync /
OnSttListenAsync / OnSttStatusAsync inline-implemented is now a pure
helper in Services/SttEngineSelector.cs and is consumed identically
from all three handlers. No behavior change.

Selector rules (pinned by SttEngineSelectorTests, 21 cases):
* Whisper preference + Whisper ready → Whisper, no fallback.
* Whisper preference + Whisper NOT ready + WinRT ready → WinRT,
  fallbackReason="whisper-model-not-ready". Happy degradation while
  the model downloads on first launch.
* Whisper preference + neither ready → keep Whisper preference,
  fallbackReason="whisper-and-winrt-unavailable". Dispatch fails;
  the user's preference is reported unchanged so stt.status is
  honest about what they asked for.
* WinRT preference + WinRT ready → WinRT, no fallback.
* WinRT preference + WinRT ready + Whisper ALSO ready → still WinRT.
  Critical invariant: explicit user choice is never silently upgraded
  to Whisper when the model finishes downloading.
* WinRT preference + WinRT NOT ready → keep WinRT,
  fallbackReason="winrt-unavailable". Same invariant: do not fall
  back to Whisper without explicit user opt-in.
* null/empty/whitespace/unknown engine string → treat as Whisper
  preference. A typo in settings.json must not hard-fail STT.
* Case- and whitespace-insensitive parsing of "whisper" / "winrt".

Engine identifier constants are mirrored locally on
SttEngineSelector.SharedConstants (free of cross-assembly deps);
MirroredConstantsMatchSttCapability pins they stay in sync.

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1266 passed,
  20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (482 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: security review fixes from STM walkthrough

Findings from the post-merge security review (full review recorded in
the session at files/security-review.md and reflected in the STM
follow-up backlog):

CRITICAL (1 fixed, 1 deferred):
* I-1 — UI now warns that selecting WinRT honors the Windows Online
  speech recognition toggle and may upload audio to Microsoft when
  that toggle is on. CapabilitiesPage SttEngineHint text updated to
  steer users to Whisper for fully local processing.
* S-4 / T-1 — DEFERRED: SHA-256 verification of the Whisper model
  (download AND load time) requires embedding canonical hashes for
  tiny / base / small from HuggingFace. Tracked as a Critical
  pre-GA follow-up in Audio_STM.md section 6, not blocking this
  merge. (Existing TLS + system trust chain remains the only check.)

HIGH (3 fixed):
* S-3 / D-1 — NodeService.OnSttListenAsync now enforces a 1-second
  cooldown between successive stt.listen invocations. Imperceptible
  to a real user but throttles a hostile loop from a compromised
  gateway. Throws InvalidOperationException("Listen rate limit")
  which the SttCapability sanitization wraps as "Listen failed".
* D-7 — AudioPipeline.CleanupCapture now wraps event-detach,
  capture.Dispose, and CTS dispose in independent try/catch blocks
  so a failure in one step doesn't leak the NAudio WasapiCapture
  COM object (which would hold the mic LED lit until process exit).
  Also added CleanupCapture() calls in StartAsync's two catch
  branches so the mic is released after a failed start.
* I-2 — VoiceOverlayWindow audit confirmed no transcript text reaches
  ActivityStreamService. Status: PIN, no code change needed.

MEDIUM (1 fixed):
* NEW-1 — TtsCapability previously returned \$"Speak failed: {ex.Message}",
  which can leak ElevenLabs key prefixes from 401 responses or
  device names from OS audio errors into the support bundle.
  Now returns a fixed "Speak failed" matching the SttCapability
  pattern. NodeInvokeActivityFormatter.GetPrivacyClass also now
  classifies tts.* as privacy-sensitive (was metadata) so failed-
  invoke details are uniformly scrubbed.

PIN (no change needed, confirmed by review):
* T-3 — SttModelName path-traversal: WhisperModelManager validates
  against the {tiny, base, small} allow-list before any Path.Combine.
* I-4 — ElevenLabs key DPAPI-encrypted at rest.
* I-5 — ElevenLabs key UI shows masked sentinel; plaintext never
  re-rendered after save.
* I-8 / PI-5 — stt.status response carries no PII (only readiness
  strings, engine name, capability flags, numeric download progress).
* PI-3 — Validation/handler errors don't echo caller input or
  exception text across stt.* and now tts.* as well.

Test additions:
* Speak_HandlerException_DoesNotLeakExceptionMessageIntoError —
  pins the new TTS privacy invariant with an "ElevenLabs 401:
  invalid key sk-secret-prefix" payload.
* Speak_ReturnsError_WhenHandlerThrows updated to assert the exact
  sanitized "Speak failed" message instead of leaking ex.Message.
* GetPrivacyClass theory rows now cover tts.speak and
  tts.future-command as privacy-sensitive (was metadata).

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1271
  passed, 20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (483 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: drop WinRT SpeechRecognizer + SAPI fallback; Whisper-only

Both legacy stacks are removed; SttCapability now dispatches every
stt.* call to a single Whisper engine via VoiceService. When the
Whisper model is not yet downloaded, handlers return a clear error
pointing the caller at the Voice Settings page download button —
there is no automatic fallback engine.

Rationale (from the discussion with Ranjesh):
* WinRT SpeechRecognizer is an old API that fails to activate in
  unpackaged tray builds (the long-standing 0x800455A0 issue) and,
  when the OS Online speech recognition toggle is on, may upload
  audio to Microsoft cloud — at odds with our local-first posture.
* System.Speech (desktop SAPI) is even older and has no value over
  Whisper for any modern scenario.
* Carrying two engines complicated the merge with no real upside
  now that Whisper.net runs reliably on every supported PC.

Removed:
* src/OpenClaw.Tray.WinUI/Services/SpeechToText/SpeechToTextService.cs
  (the WinRT + SAPI engine).
* src/OpenClaw.Tray.WinUI/Services/SttEngineSelector.cs (no engines
  to select between).
* tests/OpenClaw.Tray.Tests/SttEngineSelectorTests.cs.
* System.Speech NuGet package reference (was duplicated; both
  copies removed).
* SttEngine setting (SettingsData + SettingsManager round-trip).
* SttCapability.EngineWinRt and DefaultEngine constants.
* SttTranscribeResult.EngineFallbackReason and
  SttListenResult.EngineFallbackReason — no fallback to report.
* CapabilitiesPage Engine ComboBox + the engine-related UI strings
  in all five locales.
* The "Windows built-in may upload audio" caveat (no longer relevant).

Simplified:
* SttStatusResult: replaced PreferredEngine/EffectiveEngine plus
  per-engine readiness blocks with a single Engine + Readiness pair
  (engine is always "whisper" today; the field stays so a future
  engine doesn't break the wire).
* NodeService.OnSttTranscribeAsync / OnSttListenAsync /
  OnSttStatusAsync: dropped selector logic + WinRT marshalling.
  When VoiceService.IsWhisperReady is false, throw clear
  "Whisper model not downloaded" — wrapped to "Transcribe failed"
  / "Listen failed" by SttCapability's privacy sanitizer.
* CapabilitiesPage STT card hint surfaces model download state
  ("Whisper model is ready" / "downloading" / "not downloaded —
  open More voice settings…").
* McpToolBridge curated descriptions: drop engineFallbackReason
  field and the per-engine blocks from stt.status.

Tests:
* CapabilityTests.Status_ReturnsEngineReadiness rewritten for the
  flat shape; now also asserts no language/path strings appear in
  the JSON (tightens PI-5 enforcement).
* SettingsRoundTripTests: dropped SttEngine field assertions.
* CapabilitiesPageLocalizationCoverageTests: dropped engine ComboBox
  Uids from the contract list.
* LocalizationValidationTests: removed the engine ComboBox keys
  from the InvariantOrDeferred allow-list (no longer needed; the
  invariants list now only protects "auto", "ElevenLabs", and
  "eleven_multilingual_v2").

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1271 passed,
  20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (456 passed)

Audio_STM.md and Audio_FollowUps.md updated to reflect the engine
removal (smaller test-seam refactor surface; I-1 "WinRT online
speech caveat" follow-up is retired).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: add Piper TTS provider via Sherpa-ONNX

Adds a third TTS provider, "piper", that runs Piper voices fully
locally on this PC through the official Sherpa-ONNX .NET binding
(org.k2fsa.sherpa.onnx 1.13.0). No cloud egress; the voice model
downloads once to %LOCALAPPDATA%\OpenClawTray\models\piper\<voice-id>\
and is reused across calls.

Backend (OpenClaw.Shared/Audio/PiperVoiceManager.cs):
* Curated catalog of 6 starter voices (en-US ×2, en-GB, fr-FR, de-DE,
  zh-CN) sourced from the sherpa-onnx tts-models GitHub release
  tarballs — these are repackaged Piper voices that include the
  language-specific espeak-ng-data, so the user only downloads one
  archive per voice instead of model + tokens + espeak separately.
* Download with progress callback; extraction via OS-bundled tar.exe
  (Win10 1803+); atomic per-voice directory layout; cleanup of
  partial files on failure or cancellation.
* IsVoiceDownloaded / GetVoiceSize / DeleteVoice for the (forthcoming)
  Voice Settings page UI.
* TODO marker for SHA-256 verification (Audio_FollowUps.md §2).

Tray service (OpenClawTray/Services/TextToSpeech/PiperTextToSpeechClient.cs):
* Wraps SherpaOnnx.OfflineTts; loads one voice at a time and reuses
  the loaded model across calls (load is the expensive ~200-500 ms
  step). Single-flight gate prevents concurrent generates from
  racing the same TTS instance.
* Inference runs on a background Task so cancellation can race the
  synthesis.
* Converts Sherpa's 32-bit float PCM samples to a standard 16-bit
  PCM mono WAV blob the WinUI MediaPlayer can play with no further
  transcoding.

Wiring (OpenClaw.Tray.WinUI/Services/TextToSpeech/TextToSpeechService.cs):
* Third branch in SpeakAsync's provider dispatch. SpeakWithPiperAsync
  resolves the voice from args.VoiceId or settings.TtsPiperVoiceId,
  fails with a "voice not downloaded" error pointing the user at
  Voice Settings if the file isn't present, and otherwise reuses the
  cached PiperTextToSpeechClient (rebuilds it only when the voice id
  changes).
* TextToSpeechService.PiperVoices exposed so the Voice Settings page
  can drive download / delete from the same instance.

UI (OpenClaw.Tray.WinUI/Pages/CapabilitiesPage.xaml + .xaml.cs):
* Added Piper as the first ComboBoxItem on the TTS provider picker
  ("Piper (local ML, recommended)"). Resw entries across all 5
  locales (en-us, fr-fr, nl-nl, zh-cn, zh-tw).
* UpdateTtsCard reads TtsProvider with a 3-way switch (piper /
  windows / elevenlabs); unknown / null defaults to Piper.

Capability + settings:
* TtsCapability.PiperProvider = "piper" wire constant.
* SettingsData.TtsPiperVoiceId / SettingsManager.TtsPiperVoiceId,
  default "en_US-amy-low" (~50 MB, smallest English voice).
  Round-trip preserved through Save/Load.

Tests:
* SettingsRoundTripTests asserts TtsPiperVoiceId persists.
* CapabilitiesPageLocalizationCoverageTests pins the new
  CapabilitiesPage_TtsProviderPiper x:Uid against en-us.
* PiperVoiceManager + PiperTextToSpeechClient have no unit tests
  yet — same blocker as the rest of the audio engine layer
  (Audio_FollowUps.md §1: needs interface extraction first).

Audio_FollowUps.md §3 updated with a "Status update — basic Piper
plumbing landed" subsection enumerating exactly what shipped and
what remains (Voice download UI, manager tests, SHA-256 verification,
spike validation).

Validation:
* .\build.ps1
* dotnet test tests/OpenClaw.Shared.Tests --no-restore  (1271 passed,
  20 skipped)
* dotnet test tests/OpenClaw.Tray.Tests --no-restore  (462 passed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: voice download UI, Piper-as-default, first-listen polish

User-visible

* New Piper voice download panel on the Voice & Audio page
  (catalog of 6 voices, download with progress, delete, preview).
* Piper is now the default TTS provider for fresh installs.
* Read responses aloud toggle now drives every chat reply,
  not only voice-overlay sessions.
* Voice Overlay's Settings button opens the Voice & Audio page
  (was a no-op stub).
* First Whisper auto-download surfaces a status line in the
  Voice Overlay so the user knows the silent ~140 MB fetch is
  why nothing is being transcribed yet.
* Speech Model card refreshes its 'Model ready / Download
  required' status whenever the page becomes visible, even if
  NodeService hasn't wired its VoiceService yet.
* Stale 'Windows built-in' fallback text removed from the
  Speech-to-Text card description (5 locales). Whisper has been
  the only engine since ff11467.
* Width bumps so labels no longer truncate (the Speech Model
  size combo, the Provider combo).
* Dropped 'STT' jargon from the Language ComboBox header.
* Fixed misleading '~50-80 MB each' Piper size copy (real range
  is ~25-150 MB depending on quality).

Plumbing

* New SettingsRequested event on VoiceOverlayWindow; App hooks
  it to ShowHub('voice').
* TtsCapability.ResolveProvider falls back to Piper.
* App.OnNotificationReceived no longer gates TTS on
  VoiceMode != Inactive.
* VoiceSettingsPage.UpdateModelStatus queries the file system
  via WhisperModelManager directly so it works before
  NodeService finishes lazy-init of VoiceService.
* VoiceService.InitializeAsync fires DiagnosticMessage events
  around silent VAD/Whisper auto-downloads.

Tests: Shared 1271 / Tray 462 (default-provider asserts updated).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: address rubber-duck review (Highs #2-#5, Mediums #6-#8, Low #9)

High #2: Reuse a singleton TextToSpeechService for chat replies
* App.SpeakResponseAsync now goes through NodeService.TextToSpeech
  (a new public accessor on the existing _textToSpeechService field)
  instead of constructing a fresh service per call. Cached Piper
  client is reused across replies; the service-internal _playbackGate
  + _activePlayer now actually serialize back-to-back replies, and
  Interrupt=true takes effect.

High #3: Per-provider VoiceId routing
* New TtsWindowsVoiceId setting (round-tripped via SettingsManager
  + SettingsData; SettingsRoundTripTests assert it).
* SpeakResponseAsync no longer passes _settings.TtsElevenLabsVoiceId
  as a generic VoiceId; the per-provider Speak* paths each look up
  their own setting (TtsPiperVoiceId / TtsWindowsVoiceId /
  TtsElevenLabsVoiceId).
* SpeakWithWindowsAsync falls back to TtsWindowsVoiceId when
  args.VoiceId is blank.
* VoiceSettingsPage.OnWindowsVoiceChanged writes TtsWindowsVoiceId
  (was overwriting TtsElevenLabsVoiceId, a real cross-provider bug).

High #4: stt.listen returns a complete utterance, not the first segment
* New AudioPipeline.UtteranceTranscribed event fires once per silence-
  bounded utterance with all Whisper segments aggregated and an
  immutable Segments snapshot.
* VoiceService bubbles it as UtteranceCompleted.
* ListenOnceAsync subscribes to UtteranceCompleted (drops the
  per-fragment accumulator) so multi-segment utterances no longer
  return truncated text.

High #5: Voice Overlay submits one chat message per utterance
* OnTranscriptionReceived keeps the per-fragment streaming bubble
  update; chat submission moved to a new OnUtteranceCompleted
  handler so the gateway sees one message per spoken utterance.

Medium #6: Per-asset cancellation tokens in VoiceSettingsPage
* Split _downloadCts into _whisperDownloadCts and _piperDownloadCts
  so starting a Piper download no longer cancels an in-flight
  Whisper download (and vice versa).

Medium #7: Preflight tar.exe before Piper download
* PiperVoiceManager.EnsureExtractorAvailable runs a fast
  `tar --version` check before any network I/O. Downlevel Windows
  users now get a clear actionable error instead of a wasted ~50-150
  MB download that would later fail at extraction.

Medium #8: Refresh stale MCP tool descriptions
* stt.transcribe / stt.listen / stt.status now describe the single
  Whisper engine surface (no preferredEngine / effectiveEngine /
  engineFallbackReason); stt.listen description explicitly notes
  the result is the full silence-bounded utterance.
* tts.speak description includes `piper` in the provider list and
  notes the fresh-install default.
* Updated McpToolBridgeTests assertion for the new shape.

Low #9: Per-asset single-flight in download managers
* Both WhisperModelManager and PiperVoiceManager wrap their
  Download*Async in a static ConcurrentDictionary<string,Task> keyed
  on the canonical asset ID. Concurrent calls for the same asset
  await the same in-flight Task instead of racing on the same .tmp
  file. Failed downloads remove themselves from the table so a fresh
  retry isn't blocked.

Tests: Shared 1271 / Tray 462. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: SHA-256 verification of Whisper models and Piper voices

Critical (rubber-duck #1) — fail-closed integrity check before install.

* New `Sha256` field on `WhisperModelInfo` and `PiperVoiceInfo`.
* All 9 catalog entries (3 Whisper models + 6 Piper voices) carry a
  pinned lowercase-hex SHA-256, captured against the live HuggingFace
  and sherpa-onnx GitHub releases on 2026-05-05.
* Download core methods now:
    1. Refuse outright if the catalog entry has no pinned hash
       (`InvalidOperationException`).
    2. Compute SHA-256 of the temp file BEFORE the atomic rename
       (Whisper) or BEFORE the tar extraction (Piper).
    3. On mismatch, throw `System.Security.SecurityException`,
       delete the temp file, and let the catch block tear down any
       half-installed directory. Sanitized message — does NOT echo
       the actual hash (no confirmation oracle).
* New `AssetHashPinningTests` enforces that every catalog entry has
  a 64-hex-char SHA-256 and an https URL — future additions that
  forget the hash now break the build.

Audio_FollowUps.md §2 updated:
* Status block at the top documents what landed today.
* Pre-public-release TODO list trimmed to: independent re-verification
  of the pinned hashes, on-load verification (not just on download),
  and a future signed-manifest format so updates don't require a tray
  rebuild. The original detailed design notes are preserved as the
  spec for that next iteration.

Tests: Shared 1275 / Tray 462. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: Download Model button works without VoiceService

OnDownloadClick previously routed through VoiceService.DownloadModelAsync,
which silently no-op'd whenever _voiceService was null — and _voiceService
is only constructed inside NodeService.RegisterCapabilities (which runs on
Connect / StartLocalOnly, and only when NodeSttEnabled is true). A user
who toggled STT on without reconnecting, or who hadn't enabled MCP-only
mode, would tap Download and see nothing happen.

Construct a WhisperModelManager directly from
SettingsManager.SettingsDirectoryPath and download via that. Same
on-disk result as the VoiceService auto-download path, but available
regardless of NodeService lifecycle state. Same SHA-256 verification
applies (the manager owns it).

Tests: Tray 462 (no change in surface).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ux: Companion rename, expanded NavView memory, right-click opens Hub

Three coordinated tweaks based on the morning UX review.

1. Right-click on the tray icon now opens BOTH the popup quick-menu
   AND the companion app window. ShowHub gained an `activate` flag;
   for this code path we call ShowHub(activate:false) so the Hub
   surfaces via AppWindow.Show(activateWindow:false) and the popup
   (which is light-dismiss) stays the foreground window. Without this
   the Hub's Activate() would steal focus and dismiss the popup.

2. NavigationView pane mode is now expanded by default and remembered
   across sessions. PaneDisplayMode flipped from Auto to Left, and a
   new HubNavPaneOpen setting (default true) is round-tripped via
   SettingsManager / SettingsData. PaneOpening / PaneClosing handlers
   on HubWindow persist the user's last toggle. SettingsRoundTripTests
   covers the new field.

3. Renamed the mascot from 'Molty' to 'Companion' across the surface:

   User-facing strings:
   * VoiceOverlayWindow Title and header text → `Companion Voice`.
   * VoiceSettingsPage section header → `🔊 Companion Voice`.
   * Both Preview-button sample texts (Windows + Piper) now say
     `Hello! This is your Companion speaking.`.

   Code identifiers (HomePage):
   * MoltyRing → CompanionRing
   * MoltyProgressRing → CompanionProgressRing
   * UpdateMoltyRing → UpdateCompanionRing
   * Comment `<!-- Molty mascot -->` → `<!-- Companion mascot -->`

   `grep -i molty src/` returns zero hits.

Tests: Shared 1275 / Tray 462. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ux: rubber-duck #2 — restore minimized Hub on right-click; pin pane default

Two findings from the second rubber-duck pass.

Medium: ShowHub(activate:false) was a no-op when the Hub was previously
minimized. AppWindow.Show(activateWindow:false) does not restore minimized
windows. Detect OverlappedPresenter.State == Minimized first and
Restore(activateWindow:false) so the window actually surfaces behind the
popup, then call Show.

Low: regression test for HubNavPaneOpen migration. Settings files written
before this field existed must deserialize to true (NavView expanded).
Added an explicit FromJson(\"{}\") assertion plus pinned the field's
default in MissingFields_UseDefaults and BackwardCompatibility_OldSettings*
so a future refactor can't silently flip new installs to a collapsed pane.

Tests: Tray 463 (one new). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: deep-link parser strips trailing slash before query (#-)

The Windows shell canonicalizes openclaw://send?args=... to
openclaw://send/?args=... before handing it to us. The previous
implementation called TrimEnd('/') on the WHOLE remainder before
splitting off the query, so the trailing slash before the '?' was
never trimmed and Path came out as 'send/' instead of 'send'.

Trim the slash from the path SEGMENT after splitting off the query.

Three new theory cases pin the regression for send / agent / activity
deep links — categories that all carry query parameters in the
launcher canonicalized form. Existing TrailingSlash test (no query)
still passes with the new placement.

Credit to the parallel Copilot session for catching this.

Tests: Shared 1275 / Tray 466 (3 new). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: don't drop final utterance on stop or timeout; bound transcription queue; normalize BCP-47

Three coordinated STT pipeline fixes from the latest review.

#1 (High) — Buffered speech was discarded on Stop/Timeout
* AudioPipeline.StopAsync used to call _cts.Cancel() BEFORE flushing,
  and the flush passed the canceled token straight into Whisper.net
  (which honored cancel and dropped the final utterance). Reordered
  to: stop capture -> flush with a fresh CancellationToken.None ->
  cancel _cts -> cleanup. Adds an overrideToken parameter on
  TranscribeSamplesAsync so the flush can opt out of the pipeline cancel.
* VoiceService.ListenOnceAsync used to throw TimeoutException as soon
  as the linkedCts fired, even when speech was actively buffered. It
  now waits on Task.WhenAny(utteranceTcs, timeoutSentinel), and on
  timeout it gives pipeline.StopAsync up to 2 s to flush — only then
  reports timeout. stt.transcribe inherits this fix.

#3 (Medium) — Whisper.net language mismatch
* SpeechToTextService.NormalizeForWhisper trims BCP-47 input down to
  the 2-letter ISO 639-1 primary subtag that Whisper.net's WithLanguage
  call expects. `en-US` -> `en`, `zh-Hans-CN` -> `zh`, garbage
  -> `auto`. Capability validator + MCP docs continue to advertise
  the wider BCP-47 shape (no breaking change for callers); this fixes
  the gap to Whisper.
* Result.Language now echoes the normalized form so the caller sees
  what Whisper actually used.

#4 (Medium) — Unbounded transcription queue
* Each VAD-bounded segment fired `_ = Task.Run(TranscribeSamplesAsync)`
  with no in-flight cap. SpeechToTextService gates Whisper work but
  callbacks accumulate behind the gate, each holding a sample buffer.
  Now bounded with Interlocked counter + MaxConcurrentTranscriptions
  cap (2). Excess segments are dropped with a clear DiagnosticMessage
  rather than silently queued — better UX than getting stale utterances
  arriving minutes after the user stopped speaking.

Tests: Shared 1291 / Tray 466 (16 new normalizer tests). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: SHA-256 verification of Silero VAD model

Closes the inconsistency the buddy review flagged: Whisper and Piper
download paths are hash-pinned and fail closed on mismatch, but the
Silero VAD download path (VoiceService.DownloadVadModelAsync) was just
HTTPS + system trust chain — no integrity verification before File.Move
into the models directory.

* New SileroVadModelManifest holds the URL, SHA-256, and approximate
  size as public constants in OpenClaw.Shared.Audio. Hash captured from
  the upstream raw URL on 2026-05-05; same pre-public-release re-verify
  TODO as the other manifests (Audio_FollowUps.md §2).
* DownloadVadModelAsync now hashes the temp file with SHA-256 BEFORE
  the atomic rename. On mismatch it throws SecurityException and the
  catch block tears down the .tmp file. Sanitized error — does not echo
  the actual hash (no confirmation oracle).
* AssetHashPinningTests gains a SileroVadModel_HasPinnedSha256 case so
  a future renaming/forgetting of the constant trips the build.

Tests: Shared 1292 (1 new). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: bring skill.md back in sync with capability registry

The SkillMdDriftTests pinning test was failing — 14 commands present in
McpToolBridge.KnownCommands had no matching ### heading in skill.md:

* The 4 new entries this branch added: stt.transcribe, stt.listen,
  stt.status, tts.speak.
* 10 pre-existing app.* entries (app.navigate, app.status, app.sessions,
  app.agents, app.nodes, app.config.get, app.settings.get, app.settings.set,
  app.menu, app.search) that already drifted before the audio work.

Fixing them all in one pass so the test goes green and stays green.
Each new section follows the existing format: H3 heading, brief
description, JSON-shaped param block, return shape.

Privacy + provider notes added for stt.* and tts.* so agent readers
understand: stt.* is local Whisper only and requires NodeSttEnabled,
tts.* defaults to Piper (local neural).

Tests: SkillMdDriftTests now passes. Shared 1292 / Tray 466. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ux: throttle Whisper/Piper download progress UI; wire Re-download button

Two manual-test follow-ups on the Voice Settings page.

* Throttle progress UI updates to >=150 ms intervals on both the
  Whisper and Piper download paths. The streaming downloads emit a
  progress callback every ~80 KB chunk, so a 466 MB model produces
  ~5,800 dispatcher hops (Progress<T> + DispatcherQueue.TryEnqueue
  doubled the load). The dispatcher queue saturated and the app
  appeared frozen mid-download. Coalescing limits the rate to a few
  updates per second, with a forced final 100% report so the user
  never sees a stuck "99%" right before "Model ready". Also dropped
  the redundant inner DispatcherQueue.TryEnqueue (Progress<T> already
  marshals to the captured UI SyncContext).

* Re-download button now actually re-downloads. WhisperModelManager
  short-circuits DownloadModelAsync when the file is already present,
  so OnDownloadClick now calls the existing DeleteModel(modelName)
  first when the file is on disk. Net effect: delete -> fresh fetch
  -> SHA-256 re-verify -> atomic rename. Same on-disk result.

Tests: Shared 1292 / Tray 466 (no test surface change). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* i18n: localize VoiceSettingsPage and VoiceOverlayWindow surfaces

Closes the buddy review's last finding. The new voice UI was English-only
hard-coded in both XAML and code-behind, while the rest of the tray (and
the freshly redone CapabilitiesPage STT/TTS card) reads from .resw via
x:Uid + LocalizationHelper.GetString.

Coverage:

* VoiceSettingsPage.xaml — every user-facing TextBlock / Header /
  ComboBoxItem / Button content / placeholder gets x:Uid (page title,
  card headers, STT toggle, model + language combos, voice chat
  controls, all 3 TTS provider items, Piper download/delete/preview,
  ElevenLabs slot, privacy note).
* VoiceOverlayWindow.xaml — header text, status badge, empty state,
  status text, start/stop label, mute + settings tooltips.
* VoiceSettingsPage.xaml.cs and VoiceOverlayWindow.xaml.cs — runtime
  status messages (download progress, model-ready, preview failures,
  pipeline state transitions, mute/listen state) now read from
  LocalizationHelper.GetString. Format strings use Lf(...) so {0}/{1}
  placeholders are honored under CurrentCulture.

Translations pinned for en-us / fr-fr / nl-nl / zh-cn / zh-tw —
~95 new keys per locale (475 total resw entries). Translations are
best-effort; native speakers should review pre-public-release.

LocalizationValidationTests:
* AllLocales_HaveExactlySameKeysAsEnUs 
* Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales 
  (added VoiceSettingsPage_StatusError + ElevenLabs sample-ID
  placeholder keys to the InvariantOrDeferred list — they're
  intentionally identical across locales)

Build green. Shared 1292 / Tray 466.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: stt.transcribe is now a true fixed-duration capture

Closes the buddy review's stt.transcribe finding. The handler used to
adapt SttTranscribeArgs into SttListenArgs and call ListenOnceAsync,
which inherited VAD-based silence shutdown — so a 5 000 ms request
would return after 1 s if the user stopped speaking. The advertised
contract (skill.md, McpToolBridge) promises bounded fixed-duration
capture, not silence-bounded.

Implementation:

* AudioPipeline.CaptureFixedDurationAsync — new top-level method that
  starts WASAPI capture, accumulates every resampled+gain-applied
  16 kHz mono sample into _fixedCaptureBuffer for exactly durationMs
  (or until cancellation), then returns the buffer. OnDataAvailable
  branches on a new _fixedCaptureMode flag and bypasses the VAD path
  entirely in this mode.

* VoiceService.TranscribeFixedDurationAsync — wraps
  CaptureFixedDurationAsync + SpeechToTextService.TranscribeAsync and
  returns SttTranscribeResult directly. Empty buffer (cancelled
  immediately or no audio) returns transcribed=false rather than
  throwing.

* NodeService.OnSttTranscribeAsync now calls TranscribeFixedDurationAsync
  instead of bouncing through ListenOnceAsync.

stt.listen behavior is unchanged.

Tests: Shared 1292 / Tray 466. Build green. (No new tests — exercising
this path requires a real WASAPI device. The capture/transcribe
boundary is tightly coupled to NAudio + Whisper.net, which were the
test seams already deferred to Audio_FollowUps.md §1.)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* i18n: localize VoiceOverlayWindow root window title

Adds x:Uid="VoiceOverlayWindow" on the WindowEx root, plus the
VoiceOverlayWindow_winexWindowEx_2.Title key in all 5 locale resw
files. Listed in InvariantOrDeferredResourceKeys so the parity test
allows the title to read identical "Companion Voice" in every locale —
matches the existing convention for ChatWindow / HubWindow /
CanvasWindow / TrayMenuWindow.

The visible header text and runtime status messages were already
localized; this just closes the gap on the actual OS-level window
title (alt-tab, taskbar).

Build green. Shared 1292 / Tray 466.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: gate stt.* on file presence, not in-memory load state

The MCP / wire-side stt.transcribe and stt.listen entry points
short-circuited with "Whisper model not downloaded" whenever
_voiceService.IsWhisperReady was false. That property reads
SpeechToTextService.IsModelLoaded — which is true only after the
model has been LOADED INTO MEMORY by EnsureInitializedAsync.

On a freshly-launched tray (or any state where the user hasn't
opened the Voice Overlay yet), the .bin file is on disk but the
model isn't loaded. The pre-flight check rejected the call before
the inner TranscribeFixedDurationAsync / ListenOnceAsync could run
EnsureInitializedAsync to load it lazily.

Net result: every first MCP STT call after launch failed with a
misleading "model not downloaded" error, even though the file was
right there.

Switch the pre-flight check to IsModelDownloaded (file on disk).
The lazy load happens inside the inner call as it always did.

Verified end-to-end via the local MCP HTTP server: tools/call
stt.transcribe with maxDurationMs:5000 returned a real transcript
("Hello, how is everybody doing?") on first invocation after a
fresh tray launch.

Tests: Shared 1292 / Tray 466. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* ux: voice UI testing round — localization, shutdown, and Capabilities cleanup

Three buckets of fixes from this afternoon's manual testing pass.

i18n: dot-suffix lookup bug in code-behind
* LocalizationHelper.GetString(X.Text) returns the raw key when the
  resource name has a dot — XAML x:Uid resolution interprets the
  trailing .Text as a property suffix, but direct programmatic
  lookup doesn't, so the resource map can't find it. Six call sites
  were displaying literal keys like "VoiceOverlayWindow_StatusBadge.Text"
  in the running UI.
* Added six dot-free code-only keys (BadgeReady, StatusReadyMessage,
  ButtonStartListening, ButtonDownloadModel, PiperButtonDownloadVoice,
  PreviewVoiceButtonContent) translated across all 5 locales, and
  swapped the call sites in VoiceOverlayWindow.xaml.cs and
  VoiceSettingsPage.xaml.cs to use them.

audio: Voice Overlay "Failed to encode audio features" on Stop
* Mid-encode interruptions from Whisper.net don't surface as a clean
  OperationCanceledException — they bubble up as misleading errors
  like "Failed to encode audio features." Pressing Stop while a
  transcription Task.Run was in-flight produced exactly that toast.
* AudioPipeline.StopAsync now drains in-flight transcriptions for up
  to 3 s before cancelling \_cts, so the user's last utterance has a
  chance to actually complete.
* TranscribeSamplesAsync's catch block suppresses errors when
  \_isStopping or the cancel token is set — those are expected
  shutdown-induced interruptions, not user-visible failures. Also
  sanitized the diagnostic toast (no raw ex.Message).

Capabilities page rework
* Removed the redundant Language TextBox + label + help + status
  block. The Voice & Audio page already owns the language picker via
  a curated ComboBox (the textbox accepted any string and silently
  failed validation on garbage like "foobar", which was a paper cut).
* "More voice settings…" hyperlink stays as the deep-link.
* Speech-to-Text card hint now reads file presence directly via a
  fresh WhisperModelManager rooted at SettingsManager.SettingsDirectoryPath
  (instead of hub.VoiceServiceInstance?.IsWhisperReady, which is null
  on a freshly-launched tray and reads "loaded into memory" rather
  than "file on disk"). Same trick used by VoiceSettingsPage's
  UpdateModelStatus.
* Updated the Capabilities help text in all 5 locales to say "Two-letter
  ISO 639-1 code (e.g. en, fr, ja)" instead of "BCP-47 tag (e.g. en-US,
  fr-FR, ja-JP)" — matches what NormalizeForWhisper actually accepts
  (region is stripped). (Help text is now only consumed by the language
  picker on Voice & Audio, but the resw key was renamed/repurposed to
  match.)
* Dropped the now-orphan SttLanguageLabel/TextBox/Help resw entries
  from all 5 locales, the CapabilitiesPageLocalizationCoverageTests
  catalog, and the LocalizationValidationTests invariant list.

Tests: Shared 1292 / Tray 460 (6 fewer cases — the
CapabilitiesPageLocalizationCoverageTests theory shrank by 3 keys ×
2 non-en locales). Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* i18n: align VoiceOverlayWindow root x:Uid with WindowEx convention

The Title key in resw was VoiceOverlayWindow_winexWindowEx_2.Title, but
the root x:Uid was just "VoiceOverlayWindow" — so WinUI's auto-derived
property-suffix lookup (Window-typed elements get the _winexWindowEx_2
suffix) couldn't find a match and the title fell back to the XAML
default. Aligned the x:Uid to "VoiceOverlayWindow_winexWindowEx_2",
matching the existing pattern used by ChatWindow / HubWindow /
CanvasWindow / TrayMenuWindow.

(Also: the buddy's parallel "trailing whitespace in resw" finding is
already addressed by subsequent commits — XmlDocument.Save normalized
the formatting; `Get-Content | -match '\s+\$'` returns 0 on every
locale today.)

Build green. Tray 460.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* privacy: stop leaking ex.Message into voice UI status text

The voice settings handlers and the Voice Overlay's start/stop catch
were formatting raw exception messages straight into user-facing UI
status text (and from there potentially into screenshots, error toasts,
support bundles, the activity stream). ex.Message can carry URLs,
local paths, hash digests, HTTP body fragments, or other implementation
detail that the user shouldn't see.

Seven call sites updated:
* VoiceSettingsPage.xaml.cs — Whisper download error, Piper download
  failure, Piper delete failure, Piper preview failure, Windows voice
  enumeration failure, Windows preview failure (6 sites).
* VoiceOverlayWindow.xaml.cs — overlay start/stop catch (1 site).

For each: full ex (message + type + stack) is logged via Logger.Error
or _logger.Error; the UI shows a generic localized message that ends in
"(see Debug log)" so users know where the detail lives.

Resw side:
* Six error-string keys in all 5 locales had their {0} format
  placeholders replaced with self-contained generic messages
  (translated, not just placeholder-stripped).
* VoiceSettingsPage_StatusError dropped from
  LocalizationValidationTests.InvariantOrDeferredResourceKeys — it
  used to be flagged invariant because the placeholder made every
  locale identical; with real translations it now varies and shouldn't
  be exempt.

Tests: Tray 460. Build green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* audio: include stt.listen + stt.status in DangerousCommands

These two commands were already wired up in NodeService and advertised by
SttCapability, but the gateway's Windows platform-default policy hides any
command that isn't either platform-default (system.*, browser.proxy) or in
the node's DangerousCommands opt-in list. Only stt.transcribe was in that
list, so chat agents only saw stt.transcribe even when NodeSttEnabled was
on.

Adding stt.listen and stt.status lets them get the same explicit gateway
opt-in treatment as stt.transcribe, so once the operator allows them in
gateway.nodes.allowCommands they flow through to the agent's tools list.

Verified end-to-end: after re-pair, chat reports the full 24-command list
including stt.listen, stt.status, and tts.speak.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(audio): isolate shared download cancellation

Keep Whisper model and Piper voice single-flight downloads alive when one caller cancels its wait, and cover retry/cancellation behavior with focused tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(tray): keep right-click to context menu only

Restore tray right-click behavior so it opens only the menu instead of also showing the companion hub.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(voice): allow local overlay without node pairing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ranjesh Jaganathan <ranjeshj@microsoft.com>
Co-authored-by: Scott Hanselman <scott@hanselman.com>
2026-05-07 16:22:18 -04:00
github-actions[bot]
568cdeb058
perf: vectorize ASCII control/whitespace scan in ExecEnvSanitizer.IsBlocked (#289)
Replace the per-character IsControl/IsWhiteSpace loop with:
1. span.IndexOfAnyInRange('\x00', '\x20') — a single vectorized (SIMD)
   pass that detects all ASCII control chars (0x01–0x1F) and space (0x20).
2. span.IndexOf('\x7F') — catches DEL, which lies outside the range above.
3. A short fallback loop restricted to chars > 0x7F — non-ASCII
   control/whitespace (rare; env var names are almost always ASCII).

The original three-case vectorized IndexOfAny(['=','\0','\r','\n']) is
kept as-is; the new range scan replaces only the subsequent foreach loop.

IsBlocked is called on every environment variable supplied with a
system.run command, so the hot path (ASCII-only names that clear all
checks) now runs in O(n/SIMD_width) instead of O(n * 2 calls/char).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 13:16:57 -04:00
AlexAlves87
e832229a9e
feat: wire WebView2 native↔SPA bridge in CanvasWindow (#259)
Adds BridgeMessageReceived + PostBridgeMessage to CanvasWindow following
the same pattern as WebChatWindow (c7630fa), closing the CanvasWindow
item on the #191 checklist. Removes SendA2UIMessageAsync, ResetA2UIAsync,
and their heuristic ExecuteScriptAsync helpers; both had no active callers
and are replaced by the bridge. IsTrustedBridgeSource accepts only
_trustedGatewayOrigin and openclaw-canvas.local. Source-scan test added
in TrayMenuWindowMarkupTests.

Co-authored-by: AlexAlves87 <alexalves87@github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:19:15 -04:00
Scott Hanselman
2154e97afc Merge origin/master after quick-win PRs 2026-05-07 11:02:01 -04:00
github-actions[bot]
7a6a8a5bbb
chore(deps): bump Microsoft.Windows.SDK.BuildTools to 10.0.28000.1839 (#282)
Update all three direct SDK.BuildTools references from 10.0.26100.4654
to 10.0.28000.1839 to align with the transitive requirement introduced
by OpenClawTray.FunctionalUI's indirect dependency.

Without this bundle update, Dependabot PR #268 (which only updated
OpenClawTray.FunctionalUI.csproj) causes NU1605 downgrade errors on
OpenClaw.Tray.WinUI and OpenClaw.Tray.UITests because TreatWarningsAsErrors
is enabled in tests/Directory.Build.props.

Supersedes Dependabot PR #268.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 10:56:09 -04:00
github-actions[bot]
b5d78c1b1c
refactor: extract BaseOptions constant in SshTunnelCommandLine (#290)
The six individual sb.Append() calls that set fixed SSH connection
options (-o BatchMode=yes, ExitOnForwardFailure=yes, ServerAliveInterval,
ServerAliveCountMax, TCPKeepAlive, -N) are replaced by a single const
string BaseOptions passed to the StringBuilder constructor.

Benefits:
- The full set of SSH connection options is visible in one place,
  making it easy to review the connection policy or adjust an option
  without scanning the Append chain.
- The compiler folds all the string literals at compile time (no
  runtime allocation or concatenation for the static portion).
- BuildArguments is shorter and the dynamic parts (port forwards,
  user@host) stand out more clearly.

No functional change; all existing SshTunnelCommandLineTests pass.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 10:56:04 -04:00
Scott Hanselman
95e2ee7b4a Merge branch 'master' of https://github.com/shanselman/openclaw-windows-hub 2026-05-06 17:42:07 -06:00
Ranjesh
2fcfe76abc
fix: connection stability — stop node reconnect storms, fix bootstrap token handling (#287)
Some checks are pending
Build and Test / release (push) Blocked by required conditions
Build and Test / test (push) Waiting to run
Build and Test / build (win-arm64) (push) Blocked by required conditions
Build and Test / build (win-x64) (push) Blocked by required conditions
Build and Test / build-msix (ARM64, win-arm64) (push) Blocked by required conditions
Build and Test / build-msix (x64, win-x64) (push) Blocked by required conditions
Build and Test / build-extension (arm64) (push) Blocked by required conditions
Build and Test / build-extension (x64) (push) Blocked by required conditions
* fix: connection stability — stop node reconnect storms, fix bootstrap token handling

Critical fixes for connection management bugs introduced in PR #272:

1. Node reconnect storm during pairing (WindowsNodeClient)
   - Added ShouldAutoReconnect() override with _pairingBlocked flag
   - Flag survives OnDisconnected() (which clears _isPendingApproval)
   - Added rate-limit detection for terminal auth errors
   - Marked _pairingBlocked/_rateLimited as volatile for thread safety
   - Clear _rateLimited on successful hello-ok (transient, not permanent)

2. Backoff jitter (WebSocketClientBase)
   - Added 0-25% random jitter to prevent thundering herd when
     operator + node clients reconnect simultaneously

3. Client leak on reinitialize (App.xaml.cs)
   - Added _gatewayClient?.Dispose() before creating new client
   - Old clients were keeping reconnect loops alive as zombies

4. Bootstrap token not saved as Settings.Token
   - Setup code decoder no longer persists bootstrap to Settings.Token
   - Prevents reconnect storms on app restart with stale bootstrap token
   - TestConnection skips writing bootstrap value to Settings.Token
   - InitializeGatewayClient falls back to BootstrapToken for bootstrap flow

5. Token PasswordBox → TextBox
   - Users can see what they pasted (SetupWizardWindow + ConnectionPage)

6. Clear stale tray data on disconnect
   - Sessions/channels/nodes/models cleared when disconnected/error
   - Tray menu no longer shows old data alongside 'Disconnected'

7. Onboarding UX fixes
   - Removed disruptive auto-paste-on-focus from setup code field
   - Setup code state only updates on valid decode (prevents focus loss)
   - Added 'Relaunch First-Run Setup' button to Debug page

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: increase PowerShell echo test timeout to 30s for slow CI runners

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 09:13:55 -06:00
Christine Yan
584a19fadd
Agent events UI cleanup and redesign (#284)
Overhaul the Agent Events page with persistent caching, event deduplication, resolved stream classification, expandable event cards, and clearer summaries/badges.\n\nIncludes a maintainer follow-up to ensure assistant events expand to full text and raw JSON remains hidden for assistant/error/lifecycle streams.\n\nCo-authored-by: Christine Yan <christineyan@microsoft.com>\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 21:16:11 -07:00
Scott Hanselman
4237065ed0 Document agent validation worktree guidance
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 17:55:28 -07:00
Scott Hanselman
b356697e02 Merge PR #277: standardize titlebars
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 16:44:19 -07:00
Scott Hanselman
a957861077 fix: align canvas titlebar reload accessibility
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 16:42:49 -07:00
Scott Hanselman
32830a0527 fix: refresh tray menu sizing on DPI changes
Size the tray menu against the target cursor monitor DPI instead of the hidden window's stale size, and invalidate cached flyout geometry when DPI or rasterization scale changes. This keeps the first tray menu open after a display-scale change from rendering with stale compressed measurements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 13:57:33 -07:00
Christine Yan
82408b8d7f fix: bring existing Canvas window to front on tray menu click
Previously, clicking Canvas in the tray menu when the window was already
open did nothing because Activate() was only called when creating a new
window. Move Activate() outside the creation guard so it always runs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 15:56:53 -04:00
Copilot
e75d9bc1d9
fix: restore consistent v0.68.3 SHA in repo-assist.lock.yml (#278)
Some checks failed
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
Build and Test / test (push) Has been cancelled
Build and Test / release (push) Has been cancelled
Build and Test / build (win-arm64) (push) Has been cancelled
Build and Test / build (win-x64) (push) Has been cancelled
Build and Test / build-msix (ARM64, win-arm64) (push) Has been cancelled
Build and Test / build-msix (x64, win-x64) (push) Has been cancelled
Build and Test / build-extension (arm64) (push) Has been cancelled
Build and Test / build-extension (x64) (push) Has been cancelled
* Initial plan

* fix: revert repo-assist setup action SHA from v0.71.3 to v0.68.3 for consistency

Agent-Logs-Url: https://github.com/openclaw/openclaw-windows-node/sessions/3b723d17-00bc-44a9-89d7-76bf9a9f9d1a

Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com>
2026-05-05 11:00:55 -07:00
Scott Hanselman
e743469e2e Focus chat input when popup opens
Focuses the chat WebView when the popup is shown and asks the loaded chat document to focus the first visible textbox-style input so users can type immediately. Adds a regression test covering both the show and navigation success paths.

Fixes #279

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:39:58 -07:00
github-actions[bot]
324669d8e4
Expand URL risk and browser proxy tests
Add coverage for HttpUrlRiskEvaluator boundary cases and BrowserProxy capability path/port/query behavior.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:26:43 -07:00
github-actions[bot]
0b66d5a10c
Expand token sanitizer tests
Expand TokenSanitizer coverage and simplify ExecApprovalPolicy.Save() to serialize the same defensive snapshot used by GetPolicyData().\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:25:05 -07:00
Christine Yan
3b5c60e93a Add accessibility metadata to Canvas titlebar reload button
Add AutomationId (CanvasTitlebarReloadButton) and accessible name
(Reload Canvas) to the icon-only reload button in the Canvas window
titlebar. This enables UI automation discovery and screen reader
announcement for the button.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 13:24:17 -04:00
github-actions[bot]
1615554ba3
Skip DPAPI settings test off Windows
Add a WindowsFactAttribute for Windows-only tray tests and use it for the DPAPI settings-secret test so non-Windows runs skip the unsupported API cleanly.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:20:30 -07:00
github-actions[bot]
a1ef5e67f3
Fix canvas jsonl path check on non-Windows
Skip the handle-resolved-path containment check when GetFinalPathFromHandle returns an empty value on non-Windows, while preserving the earlier symlink-resolution guard.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:18:58 -07:00
Ranjesh
f0704907f8
Remove fake/sample data from 6 UI pages
Replace constructor-injected sample data with empty/loading states across Usage, Sessions, Nodes, Channels, Skills, and Cron pages. Skills and Cron APIs were already wired; this removes stale warnings and misleading placeholder data.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 09:58:28 -07:00
Christine Yan
bf62a3d57e fix: standardize titlebar treatment across all windows
- Add ExtendsContentIntoTitleBar + custom titlebar to OnboardingWindow,
  SetupWizardWindow, and WelcomeDialog to match HubWindow/CanvasWindow
- Standardize titlebar height (48px), padding, emoji (FontSize 20),
  and title text (FontSize 13, CaptionTextBlockStyle) across all windows
- CanvasWindow: update height 40->48px, emoji size 14->20, add FontSize 13
- CanvasWindow: move reload button inline next to title in separate grid
  column for proper click handling within titlebar drag region
- Fix OnboardingWindow chat overlay sizing to use contentGrid.SizeChanged
  instead of rootGrid to avoid double-subtracting titlebar height

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 11:48:29 -04:00
187 changed files with 27133 additions and 1685 deletions

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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
# - github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
#
# 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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
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@07c7335cd76c4d4d9f00dd7874f85ff55ed71f24 # v0.71.3
uses: github/gh-aw-actions/setup@ba90f2186d7ad780ec640f364005fa24e797b360 # v0.68.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}

2
.gitignore vendored
View File

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

View File

@ -0,0 +1,31 @@
# Aaron: actual fixes for PR #274 bugs 2 and 3
## Bug 2 — tray quick-chat broken
Traced tray left-click to `InitializeTrayIcon()` -> `_trayIcon.Selected += OnTrayIconSelected` -> `OnTrayIconSelected()` -> `ShowChatWindow()`. The quick-chat path did use `ShowChatWindow`, but it resolved only `settings.Token` while the working operator client resolves `settings.Token`, `settings.BootstrapToken`, then stored `DeviceIdentity.DeviceToken` via `GatewayCredentialResolver`.
Changes:
- `App.ShowChatWindow()` and chat pre-warm now use the same `GatewayCredentialResolver` pattern as the operator client.
- `ShowChatWindow()` calls `ChatWindow.RefreshCredentials()` on every tray click, including newly-created windows.
- `ChatWindow.RefreshCredentials()` always rebuilds the URL and navigates initialized WebView2 to it; it no longer returns early when the same stale URL is cached.
- Added diagnostic logs: `[ChatWindow] Quick-chat credentials resolved from ...` and `[ChatWindow] Refreshing to ...`.
- Applied Mattingly Bug 4 handoff: bootstrap injection now runs from `ChatWindow` after successful WebView navigation.
Manual validation for Mike: click tray icon; tail `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` and look for `[ChatWindow] Refreshing to ...`, then verify chat loads without login loop.
## Bug 3 — pairing toast notification storm
Searched toast paths and traced pairing notifications through `WindowsNodeClient` direct `PairingStatusChanged` emitters (`pairing.requested`, `pairing.resolved`, `NOT_PAIRED`, and `hello-ok`) plus tray toasts in `App.OnPairingStatusChanged()` and `App.OnNodeStatusChanged()`.
Changes:
- Routed all `WindowsNodeClient` pairing emitters through `EmitPairingStatusOnTransition()`; duplicates now log `[NODE] Suppressing duplicate pairing status event: ...`.
- Added a toast-boundary 30-second dedupe in `App.ShowToast(builder, toastTag, deviceId)`, keyed by `(toastTag, deviceId)`.
- Tagged node pairing pending/paired/rejected and node-connected toasts.
- Suppressed the node-connected toast if a node-paired toast was just shown for the same device.
- Added diagnostic logs: `[ToastDeduper] Showing toast tag=... deviceId=...` and `[ToastDeduper] Suppressed duplicate toast tag=... deviceId=...`.
Manual validation for Mike: complete pairing; expect exactly one node-paired toast and log line `[ToastDeduper] Showing toast tag=node-paired deviceId=...`; duplicates should log suppression.
## Validation
Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`.

View File

@ -0,0 +1,45 @@
# Mattingly: actual fixes for PR #274 bugs 1, 4, 5
## Bug 1 — chat window auto-launch on Finish
Changed `OnboardingWindow.OnWizardComplete()` to ignore `WizardLifecycleState == "complete"`. The signal now is: the window is completing from `OnboardingRoute.Ready` and `StartupSetupState.RequiresSetup(settings, identityDataPath)` is false. That is the path the Finish button actually takes: `Ready` page Finish -> `OnboardingState.Complete()` -> `OnOnboardingFinished()` -> `OnWizardComplete()`.
Log to validate: `[OnboardingWindow] OnWizardComplete launching chat`.
## Bug 4 — BOOTSTRAP.md kickoff injection
Hardened `BootstrapMessageInjector`:
- Traverses shadow DOM for Lit UI controls.
- Probes and logs visible control count: `[OpenClaw] Bootstrap probe controls=N`.
- Supports `textarea`, text inputs, contenteditable, and role=textbox.
- Uses native value setters so controlled inputs see the value.
- Clicks Send/form-submit/Enter fallbacks.
- Does **not** burn `HasInjectedFirstRunBootstrap` when the script returns `no-input`; the gate is only persisted on `sent`.
Aaron still needs to move the call site to after successful chat navigation because current `App.ShowChatWindow()` can see `TryGetScriptExecutor()==null` when the WebView2 is still initializing.
Exact handoff line for Aaron in `ChatWindow.xaml.cs` NavigationCompleted success branch after `RequestChatInputFocus();`:
```csharp
OpenClawTray.Services.BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask();
_ = OpenClawTray.Services.BootstrapMessageInjector.InjectAsync(exec, ((App)Microsoft.UI.Xaml.Application.Current).Settings, initialDelayMs: 500);
```
If `App.Settings` is not exposed, add an internal property returning `_settings`, or route the existing `_settings` from `App.ShowChatWindow()` into a ChatWindow method. The important point is that the call must happen inside `NavigationCompleted` when `e.IsSuccess` is true.
## Bug 5 — autostart default/toggle
Changed `ReadyPage` to render the toggle ON as a safety default, then sync to `Settings.AutoStart` on mount and immediately call `AutoStartManager.SetAutoStart()` so a user who never toggles still gets the Run-key. The toggle handler still persists settings and updates the Run-key immediately.
Changed `AutoStartManager.SetAutoStart()` to use `Registry.CurrentUser.CreateSubKey(...)` instead of `OpenSubKey(...)`, so it can create the Run key/value when missing instead of silently returning.
Manual registry validation:
```powershell
Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name OpenClawTray -ErrorAction SilentlyContinue
```
## Validation
Ran `./build.ps1`: passed. Per fast-loop directive, skipped `dotnet test`.

View File

@ -0,0 +1,58 @@
# Mattingly — PR #274 finish should open Hub chat
## Audit
Command requested: `grep -rn "launching chat\|ShowChatWindow\|ShowHub\|OnWizardComplete" src/OpenClaw.Tray.WinUI` (run with ripgrep equivalent because `rg` was not on PATH in PowerShell; Copilot rg tool was used against the same tree).
HEAD before this fix: `8c68111 Launch hub chat after onboarding`.
Matches found:
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:498` — tray icon click calls `ShowChatWindow()`.
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:501``ShowChatWindow()` method.
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:542``ShowChatWindow` deferred-show warning string.
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:644` — tray menu `openchat` calls `ShowChatWindow()`.
- `src/OpenClaw.Tray.WinUI/App.xaml.cs:562,581,647,652,654,710,1043,1855,2809,2928,3048,3101,3603,4265``ShowHub(...)` method/call sites.
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:587` — Finish event calls `OnWizardComplete()`.
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:596` — X/Closed path calls `OnWizardComplete()`.
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:620` — single `OnWizardComplete()` implementation.
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:649` — required diagnostic log line.
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs:650,658,660,667,671,675,679` — deferred Hub chat launch helper.
- Documentation/comment-only references in `ChatWindow.xaml.cs`, `HubWindow.xaml.cs`, `VoiceOverlayWindow.xaml.cs`, and `OnboardingState.cs`.
The literal old string `launching chat` has no remaining source match in this worktree.
## Diagnosis
The log Mike captured (`[OnboardingWindow] OnWizardComplete launching chat`) corresponds to the pre-`8c68111` body of `OnboardingWindow.OnWizardComplete` in `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs`, the only wizard-completion implementation. In the current clean worktree, `8c68111` did change that exact method to log `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab` and call `App.ShowHub("chat")`.
I did not find a second `OnWizardComplete`, overload, post-finish hook, or hidden `ShowHub` fallback to `ChatWindow`. `App.ShowHub(...)` creates a `HubWindow` when `_hubWindow` is null/closed, sets state, navigates, and activates it. The remaining `ShowChatWindow()` calls are tray quick-chat entry points, not wizard finish paths.
The prior fix therefore did not take in the live run because that run was not executing source/binaries containing `8c68111` (or was launched from another stale build/worktree). To make the wizard finish path more robust and easier to verify, this follow-up keeps the exact required log line and dispatches `ShowHub("chat")` at low priority after the wizard close event settles, so the Hub opens after the wizard finishes closing and cannot lose an ordering fight to wizard teardown.
## Changes
- `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs`
- Keeps the required log line: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`.
- Replaces the inline post-finish call with `ShowHubChatAfterWizardClose()`.
- The helper dispatches `App.ShowHub("chat")` on the UI dispatcher at low priority, with a direct fallback if enqueue fails.
- Adds an explicit warning if `Application.Current` is not the tray `App`.
- Updates stale bootstrap comment from `App.ShowChatWindow()` to HubWindow chat navigation.
- `src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs`
- Updates stale route comment to say the Ready path launches the Hub chat tab, not the old chat window.
- `src/OpenClaw.Tray.WinUI/Services/BootstrapMessageInjector.cs`
- Updates stale comment to describe HubWindow chat page injection instead of post-wizard `App.ShowChatWindow()`.
## Validation
- `git pull --rebase fork feat/wsl-gateway-clean` before commit: already up to date.
- `./build.ps1`: passed.
- Tests intentionally not run per active directive: NO tests, incremental `./build.ps1` only.
## Verification log line
Mike should verify this exact line on the next finish run:
`[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`

View File

@ -0,0 +1,21 @@
# Mattingly: Finish opens HubWindow chat
## Summary
Onboarding completion from Ready now launches the full HubWindow directly on the Chat tab instead of the standalone quick-chat ChatWindow.
## Changes
- `src\OpenClaw.Tray.WinUI\App.xaml.cs`
- Made `ShowHub(string? navigateTo = null, bool activate = true)` internal so onboarding can reuse the existing hub-opening path.
- `src\OpenClaw.Tray.WinUI\Onboarding\OnboardingWindow.cs`
- Replaced `ShowChatWindow()` completion launch with `ShowHub("chat")`.
- Added diagnostic log: `[OnboardingWindow] OnWizardComplete launching HubWindow on chat tab`.
- `src\OpenClaw.Tray.WinUI\Pages\ChatPage.xaml.cs`
- Wired `BootstrapMessageInjector.InjectAsync` into the Hub chat WebView2 `NavigationCompleted` success path, matching the standalone `ChatWindow` gated injection behavior.
## Validation
- Ran `./build.ps1` successfully after the code change.
- Per active session directive, did not run tests after the fix.
## Architectural notes
- Hub already exposes tag-based navigation through `NavigateTo("chat")`; `ShowHub("chat")` selects the existing NavigationView item and navigates to `ChatPage`.
- Bootstrap injection remains wired in both standalone `ChatWindow` and Hub `ChatPage`; the existing global `Settings.HasInjectedFirstRunBootstrap` gate ensures only one path injects.

View File

@ -22,5 +22,8 @@ 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

@ -177,6 +177,7 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t
| **Canvas** | `canvas.present`, `canvas.hide`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.push`, `canvas.a2ui.pushJSONL`, `canvas.a2ui.reset` | Display and control a WebView2 window |
| **Screen** | `screen.snapshot`, `screen.record` | Capture screenshots and fixed-duration MP4 screen recordings |
| **Camera** | `camera.list`, `camera.snap`, `camera.clip` | Enumerate cameras and capture still photos or short video clips |
| **Speech-to-text** | `stt.transcribe` | Capture audio from the default microphone for a bounded duration and return transcribed text. Default-off; opt-in via Settings. When enabled, advertised to both gateway callers (subject to gateway allowlist) and local MCP clients (subject to bearer token). |
| **Location** | `location.get` | Return Windows geolocation when permission is available |
| **Device** | `device.info`, `device.status` | Return Windows host/app metadata and lightweight status |
| **Text-to-speech** | `tts.speak` | Speak text aloud through Windows speech synthesis, or ElevenLabs when configured |

View File

@ -61,6 +61,7 @@ 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
@ -112,6 +113,40 @@ 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

@ -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`, 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`, `stt.transcribe`, 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,6 +364,7 @@ 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.
@ -424,6 +425,7 @@ 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.
@ -441,7 +443,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`, and `screen.record` only when you explicitly want to allow privacy-sensitive camera or screen capture.
> Add `camera.snap`, `camera.clip`, `screen.record`, and `stt.transcribe` only when you explicitly want to allow privacy-sensitive camera, screen, or microphone 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

@ -0,0 +1,369 @@
# OpenClaw Windows local gateway: WSL-owner Q&A
This document is the structured record of the questions we asked Craig Loewen
(WSL) about the Windows OpenClaw local-gateway design, and Craig's answers.
It is the canonical "why does the architecture look like this?" reference
for the Windows local-gateway PR.
Companion: [`docs/wsl-owner-validation.md`](wsl-owner-validation.md)
describes the resulting design as it ships.
**Status legend:** ✅ Answered (verbatim or paraphrased Craig answer
recorded). 🟡 Open.
**Source:** Craig Loewen's review of the prototype `wsl-owner-open-issues.md`
(2026-05-04). His answers are summarized authoritatively in
`.squad/decisions.md` under "Decision: Craig Loewen's WSL Answers
(Authoritative)" and underpinned the Phase 3 plan revision in
`.squad/decisions-archive.md`. The architecture statements below are
paraphrased; Mike's relayed verbatim Q&A lives in the squad decisions thread,
not in the public PR.
The design is built on three coupled choices:
1. **Distribution model:** create a dedicated `OpenClawGateway` instance from
the Store Ubuntu-24.04 package and configure it post-install — no custom
OpenClaw rootfs.
2. **Networking model:** loopback only between the Windows tray and the
gateway in WSL — no WSL-IP fallback, no `lan`/`auto` bind.
3. **Lifecycle model:** instance-scoped `wsl --terminate OpenClawGateway` for
repair; user-systemd plus a tray-owned keepalive for liveness; no global
`wsl --shutdown` and no global `.wslconfig` mutation.
The goal remains a low-maintenance implementation that uses the public
OpenClaw Linux installer unchanged and does not maintain a custom OpenClaw
Linux distribution.
## Final shape
1. The Windows tray verifies WSL/WSL2 availability.
2. The tray creates a dedicated WSL2 instance named `OpenClawGateway` from
the Store Ubuntu-24.04 package:
```powershell
wsl.exe --install Ubuntu-24.04 `
--name OpenClawGateway `
--location "$env:LOCALAPPDATA\OpenClawTray\wsl" `
--no-launch `
--version 2
```
3. The tray launches the instance as root and applies OpenClaw-owned
configuration:
- create the `openclaw` user;
- create `/home/openclaw/.openclaw`, `/opt/openclaw`,
`/var/lib/openclaw`, and `/var/log/openclaw`;
- write `/etc/wsl.conf` and `/etc/wsl-distribution.conf`;
- set the default user to `openclaw` via
`wsl --manage OpenClawGateway --set-default-user openclaw`;
- terminate only `OpenClawGateway` so WSL config takes effect.
4. The tray runs the public OpenClaw Linux installer inside the instance:
`https://openclaw.ai/install-cli.sh` with prefix `/opt/openclaw`. No
forked or patched gateway installer.
5. The tray uses upstream OpenClaw CLI/service commands to configure and
start the gateway.
6. The tray calls upstream `openclaw qr --json`, consumes the upstream
setup-code/bootstrap-token handoff, and pairs Windows tray operator and
Windows tray node sessions; both device tokens land in
`%APPDATA%\OpenClawTray\device-key-ed25519.json`.
## Issue 1: Ubuntu Store package + post-install configuration
### Q1.1 — Is `wsl --install Ubuntu-24.04 --name OpenClawGateway --location ... --no-launch --version 2` a supported primitive for a Windows app creating a dedicated app-owned WSL instance?
**Status:** ✅ Answered.
**Craig:** Yes — supportable. This is the canonical primitive for an
app-owned WSL instance.
**Implication:** `LocalGatewaySetup.cs` issues exactly this command. The
clean port removed `--web-download`, `--from-file`, and any rootfs-import
fallback.
### Q1.2 — Is it acceptable to treat the install as successful when post-conditions pass, even if the `wsl --install` process itself hangs or exits unclearly?
**Status:** ✅ Answered.
**Craig:** **Trust the exit code.** The hang-fallback pattern from the
prototype is not needed.
**Implication:** The clean engine treats `wsl --install` exit 0 as the
success signal, and additionally confirms `OpenClawGateway` appears in
`wsl --list --quiet` to defend against the "winget-style" failure mode where
exit 0 reports success without registering a distro (see Q1.3). Non-zero
exit ⇒ install failure; no postcondition-on-hang path.
### Q1.3 — Should we prefer generic `Ubuntu`, explicit `Ubuntu-24.04`, `--web-download`, `--from-file`, or another source for the default path?
**Status:** ✅ Answered.
**Craig:** Use **explicit `Ubuntu-24.04`**, not generic `Ubuntu`. No
`--web-download` and no `--from-file` are needed.
**Implication:** The clean install command is pinned to `Ubuntu-24.04`. The
prototype's "generic `Ubuntu` channel was more reliable on this dev machine"
observation is not a basis for a final product default.
Empirical confirmation (2026-05-04, 20-iter harness on Windows 10.0.26200,
WSL 2.6.3.0): `wsl --install Ubuntu-24.04 --name <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

@ -0,0 +1,384 @@
# OpenClaw Windows local gateway: WSL design validation
This document describes the WSL design that ships in this PR. It reflects Craig
Loewen's authoritative review of `docs/wsl-owner-open-issues.md` (verbatim Q&A
reproduced inline in that companion doc). Where the prototype enumerated
options, this version states the chosen design.
The current scope is:
- A dedicated app-owned **Ubuntu-24.04** WSL2 instance named `OpenClawGateway`,
created from the standard Ubuntu Store package and then configured by the
Windows tray.
- The public OpenClaw Linux installer (`https://openclaw.ai/install-cli.sh`)
runs unchanged inside that instance with prefix `/opt/openclaw`.
- **Loopback-only** local networking (`http://localhost:18789`) between the
Windows tray and the gateway.
- Repair / restart via instance-scoped `wsl --terminate OpenClawGateway`.
- Diagnostics on failure pointed at <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,6 +27,7 @@
<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

@ -0,0 +1,326 @@
<#
.SYNOPSIS
Dev-loop helper: kill backup/wipe state optionally wipe WSL distro build x64 (optionally) launch tray.
.DESCRIPTION
Consolidates the full dev-reset cycle used during OpenClaw tray development.
Idempotent: no error if nothing is running, state dirs are absent, or the WSL
distro is not registered.
Process kills are always by PID (Stop-Process -Id). Name-based kills are
forbidden in this repo.
WSL file operations use 'wsl bash -c' never \\wsl$\ paths (which trigger
Windows permission prompts via the 9P protocol).
.PARAMETER WipeWslDistro
Also unregister the OpenClawGateway WSL distro (wsl --unregister).
Default: off (preserve the distro).
.PARAMETER CaptureDir
If set, exports OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR=<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

@ -0,0 +1,388 @@
# reset-openclaw-wsl-validation-state.ps1
#
# Exact-target destructive cleanup for OpenClaw-owned WSL validation state.
#
# Safety guarantees enforced by this script:
# 1. Without -ConfirmDestructiveClean, the script runs in DRY-RUN mode and
# reports what it WOULD do; it never mutates state.
# 2. The only WSL distro this script will ever touch is the production
# constant "OpenClawGateway". Any other distro name is rejected.
# 3. Destructive operations are preceded by a copy of the user's
# %APPDATA%\OpenClawTray and %LOCALAPPDATA%\OpenClawTray identity
# directories to a timestamped backup location (printed to console).
# 4. The script never calls `wsl --shutdown`. It uses
# `wsl --terminate OpenClawGateway` only.
# 5. The script never reads or writes \\wsl$ / \\wsl.localhost paths.
[CmdletBinding()]
param(
[string]$OutputDir = (Join-Path (Get-Location) "artifacts\wsl-gateway-validation\reset"),
[string]$BackupRoot,
[string]$AppDataRoot,
[string]$LocalAppDataRoot,
[string]$InstallLocation,
[switch]$CleanInstallLocation,
[switch]$ConfirmDestructiveClean,
[switch]$KeepRunningProcesses,
[switch]$PassThruJson
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Production-locked WSL distro name (Phase 3 constant). This script will
# refuse to act on any other distro, even via -DistroName overrides
# (which are intentionally absent).
$script:OpenClawDistroName = "OpenClawGateway"
$startedAt = Get-Date
$timestamp = $startedAt.ToString("yyyyMMddHHmmss")
if ([string]::IsNullOrWhiteSpace($BackupRoot)) {
$BackupRoot = Join-Path (Get-Location) "artifacts\reset-backups\$timestamp"
}
$result = [ordered]@{
script = "reset-openclaw-wsl-validation-state"
startedAt = $startedAt.ToString("o")
finishedAt = $null
outputDir = $OutputDir
backupRoot = $BackupRoot
distroName = $script:OpenClawDistroName
installLocation = $InstallLocation
appDataRoot = $AppDataRoot
localAppDataRoot = $LocalAppDataRoot
destructiveConfirmed = [bool]$ConfirmDestructiveClean
dryRun = -not $ConfirmDestructiveClean
targets = [ordered]@{}
steps = @()
}
function Add-ResetStep {
param(
[string]$Name,
[string]$Status,
[string]$Message,
[hashtable]$Data = @{}
)
$script:result.steps += [ordered]@{
name = $Name
status = $Status
message = $Message
data = $Data
timestamp = (Get-Date).ToString("o")
}
}
function Invoke-CapturedCommand {
param(
[string]$Name,
[string]$FilePath,
[string[]]$ArgumentList,
[string]$WorkingDirectory = (Get-Location).Path,
[switch]$IgnoreExitCode
)
$stepDir = Join-Path $OutputDir "commands"
New-Item -ItemType Directory -Force -Path $stepDir | Out-Null
$safeName = $Name -replace "[^a-zA-Z0-9_.-]", "-"
$stdout = Join-Path $stepDir "$safeName.stdout.txt"
$stderr = Join-Path $stepDir "$safeName.stderr.txt"
Push-Location $WorkingDirectory
try {
& $FilePath @ArgumentList > $stdout 2> $stderr
$exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE }
}
finally {
Pop-Location
}
Add-ResetStep $Name "Completed" "Command completed with exit code $exitCode." @{
file = $FilePath
arguments = ($ArgumentList -join " ")
exitCode = $exitCode
stdout = $stdout
stderr = $stderr
}
if ($exitCode -ne 0 -and -not $IgnoreExitCode) {
throw "$Name failed with exit code $exitCode. See $stdout and $stderr."
}
}
function Backup-Directory {
param(
[string]$Path,
[string]$Label
)
if (-not (Test-Path -LiteralPath $Path)) {
Add-ResetStep "backup-$Label" "Skipped" "$Path does not exist."
return
}
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
$leaf = Split-Path -Leaf $Path
$destination = Join-Path $BackupRoot "$Label-$leaf"
if ($result.dryRun) {
Add-ResetStep "backup-$Label" "DryRun" "Would copy $Path to $destination, then remove the original." @{
source = $Path
destination = $destination
}
return
}
if (Test-Path -LiteralPath $destination) {
$destination = Join-Path $BackupRoot ("{0}-{1:yyyyMMddHHmmss}" -f "$Label-$leaf", (Get-Date))
}
# Copy first so the user can recover even if removal fails partway.
Copy-Item -LiteralPath $Path -Destination $destination -Recurse -Force
Remove-Item -LiteralPath $Path -Recurse -Force
Add-ResetStep "backup-$Label" "Completed" "Backed up $Path to $destination, then removed the original." @{
source = $Path
destination = $destination
}
}
function Assert-DestructiveTargetIsAllowed {
# Hard-lock: this script will only ever touch the production OpenClawGateway distro.
# No override flag exists. If $script:OpenClawDistroName is ever something else,
# the script must refuse to run regardless of dry-run mode.
if ($script:OpenClawDistroName -ne "OpenClawGateway") {
throw "Refusing to run: distro name is locked to 'OpenClawGateway' but resolved to '$($script:OpenClawDistroName)'."
}
}
function Get-PortOwnerSnapshot {
param([string]$Label)
$port = 18789
try {
$connections = @(Get-NetTCPConnection -LocalPort $port -ErrorAction Stop)
$snapshot = @($connections | ForEach-Object {
[ordered]@{
localAddress = $_.LocalAddress
localPort = $_.LocalPort
state = $_.State.ToString()
owningProcess = $_.OwningProcess
}
})
}
catch {
$snapshot = @()
}
$snapshotPath = Join-Path $OutputDir "port-18789-$Label.json"
$snapshot | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $snapshotPath -Encoding UTF8
Add-ResetStep "port-snapshot-$Label" "Completed" "Captured TCP listener snapshot for port 18789." @{
path = $snapshotPath
ownerCount = @($snapshot).Count
}
return $snapshot
}
function Get-WslDistros {
$output = & wsl.exe --list --quiet 2>$null
if ($LASTEXITCODE -ne 0 -or $null -eq $output) {
return @()
}
return @($output | ForEach-Object { ($_ -replace "`0", "").Trim() } | Where-Object { $_ })
}
function Get-OpenClawProcesses {
return @(Get-Process | Where-Object { $_.ProcessName -like "OpenClaw*" })
}
function Add-TargetSummary {
param(
[object[]]$Processes,
[string[]]$Distros,
[string]$AppDataPath,
[string]$LocalAppDataPath,
[string]$InstallLocationPath,
[object[]]$PortOwners
)
$script:result.targets = [ordered]@{
processes = @($Processes | ForEach-Object {
[ordered]@{
pid = $_.Id
name = $_.ProcessName
path = $_.Path
}
})
distroExists = ($Distros -contains $script:OpenClawDistroName)
distroName = $script:OpenClawDistroName
appDataPath = $AppDataPath
appDataExists = Test-Path -LiteralPath $AppDataPath
localAppDataPath = $LocalAppDataPath
localAppDataExists = Test-Path -LiteralPath $LocalAppDataPath
installLocationPath = $InstallLocationPath
installLocationExists = (-not [string]::IsNullOrWhiteSpace($InstallLocationPath)) -and (Test-Path -LiteralPath $InstallLocationPath)
installLocationCleanupRequested = [bool]$CleanInstallLocation
port18789OwnersBefore = @($PortOwners)
outputDir = $OutputDir
backupRoot = $BackupRoot
}
Add-ResetStep "target-summary" "Completed" "Captured OpenClaw-owned reset targets." @{
processCount = @($Processes).Count
distroExists = [bool]$script:result.targets.distroExists
appDataExists = [bool]$script:result.targets.appDataExists
localAppDataExists = [bool]$script:result.targets.localAppDataExists
installLocationExists = [bool]$script:result.targets.installLocationExists
}
}
function Assert-CleanPostCondition {
param(
[string]$AppDataPath,
[string]$LocalAppDataPath,
[string]$InstallLocationPath
)
if ($result.dryRun) {
Add-ResetStep "postconditions" "Skipped" "Postconditions are skipped during dry-run."
return
}
$remainingProcesses = @(Get-OpenClawProcesses)
if (-not $KeepRunningProcesses -and $remainingProcesses.Count -gt 0) {
throw "OpenClaw processes are still running after reset: $(@($remainingProcesses | ForEach-Object { $_.Id }) -join ', ')"
}
$remainingDistros = @(Get-WslDistros)
if ($remainingDistros -contains $script:OpenClawDistroName) {
throw "WSL distro '$($script:OpenClawDistroName)' is still registered after reset."
}
if (Test-Path -LiteralPath $AppDataPath) {
throw "AppData path still exists after reset: $AppDataPath"
}
if (Test-Path -LiteralPath $LocalAppDataPath) {
throw "LocalAppData path still exists after reset: $LocalAppDataPath"
}
if ($CleanInstallLocation -and -not [string]::IsNullOrWhiteSpace($InstallLocationPath) -and (Test-Path -LiteralPath $InstallLocationPath)) {
throw "Install location still exists after reset: $InstallLocationPath"
}
$wslListAfterPath = Join-Path $OutputDir "wsl-list-after.txt"
& wsl.exe --list --verbose > $wslListAfterPath 2>&1
$script:result.targets.port18789OwnersAfter = @(Get-PortOwnerSnapshot -Label "after")
Add-ResetStep "postconditions" "Passed" "OpenClaw-owned state reset postconditions passed." @{
wslListAfter = $wslListAfterPath
}
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
try {
Assert-DestructiveTargetIsAllowed
if ([string]::IsNullOrWhiteSpace($AppDataRoot)) {
$AppDataRoot = $env:APPDATA
$result.appDataRoot = $AppDataRoot
}
if ([string]::IsNullOrWhiteSpace($LocalAppDataRoot)) {
$LocalAppDataRoot = $env:LOCALAPPDATA
$result.localAppDataRoot = $LocalAppDataRoot
}
$appData = Join-Path $AppDataRoot "OpenClawTray"
$localAppData = Join-Path $LocalAppDataRoot "OpenClawTray"
$processes = @(Get-OpenClawProcesses)
$distros = @(Get-WslDistros)
$portOwnersBefore = @(Get-PortOwnerSnapshot -Label "before")
Add-TargetSummary -Processes $processes -Distros $distros -AppDataPath $appData -LocalAppDataPath $localAppData -InstallLocationPath $InstallLocation -PortOwners $portOwnersBefore
if ($result.dryRun) {
Add-ResetStep "mode" "DryRun" "No state will be changed. Pass -ConfirmDestructiveClean to reset OpenClaw-owned state."
Write-Host "DRY-RUN: pass -ConfirmDestructiveClean to actually reset OpenClaw-owned state."
}
else {
Add-ResetStep "mode" "Confirmed" "OpenClaw-owned state reset is enabled for this run."
Write-Host "Backups will be written under: $BackupRoot"
}
if ($processes.Count -eq 0) {
Add-ResetStep "stop-openclaw-processes" "Skipped" "No OpenClaw processes are running."
}
elseif ($KeepRunningProcesses) {
Add-ResetStep "stop-openclaw-processes" "Skipped" "Keeping running OpenClaw processes because -KeepRunningProcesses was set." @{
pids = @($processes | ForEach-Object { $_.Id })
}
}
elseif ($result.dryRun) {
Add-ResetStep "stop-openclaw-processes" "DryRun" "Would stop running OpenClaw processes by PID." @{
pids = @($processes | ForEach-Object { $_.Id })
}
}
else {
foreach ($process in $processes) {
Stop-Process -Id $process.Id -Force
}
Add-ResetStep "stop-openclaw-processes" "Completed" "Stopped running OpenClaw processes by PID." @{
pids = @($processes | ForEach-Object { $_.Id })
}
}
$hasGatewayDistro = $distros -contains $script:OpenClawDistroName
$wslListPath = Join-Path $OutputDir "wsl-list-before.txt"
& wsl.exe --list --verbose > $wslListPath 2>&1
Add-ResetStep "capture-wsl-list" "Completed" "Captured WSL distro list." @{ path = $wslListPath }
if (-not $hasGatewayDistro) {
Add-ResetStep "unregister-$($script:OpenClawDistroName)" "Skipped" "WSL distro '$($script:OpenClawDistroName)' is not registered."
}
elseif ($result.dryRun) {
Add-ResetStep "unregister-$($script:OpenClawDistroName)" "DryRun" "Would terminate and unregister only the '$($script:OpenClawDistroName)' WSL distro." @{ distroName = $script:OpenClawDistroName }
}
else {
# Exact-target only: --terminate <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

@ -0,0 +1,941 @@
<#
.SYNOPSIS
Validate the OpenClaw WSL gateway local-setup product code path end-to-end.
.DESCRIPTION
Phase 6 clean port. Drives the WinUI3 tray app from launch through the
forked onboarding (SetupWarningPage -> "Set up locally" -> LocalSetupProgressPage)
so the *product* code path that runs
wsl --install Ubuntu-24.04 --name OpenClawGateway --location <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

@ -0,0 +1,70 @@
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

@ -0,0 +1,390 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
/// <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

@ -0,0 +1,28 @@
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

@ -0,0 +1,52 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
internal static class SingleFlightDownload
{
public static Task RunAsync(
ConcurrentDictionary<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

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Whisper.net;
using Whisper.net.Ggml;
namespace OpenClaw.Shared.Audio;
/// <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

@ -0,0 +1,108 @@
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

@ -0,0 +1,223 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Audio;
/// <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

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

View File

@ -404,8 +404,10 @@ 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 (!IsPathWithinRoot(finalPath, tempRoot))
if (!string.IsNullOrEmpty(finalPath) && !IsPathWithinRoot(finalPath, tempRoot))
{
Logger.Warn($"{command}: jsonlPath file handle resolves outside temp directory: {finalPath}");
throw new InvalidOperationException("jsonlPath must resolve within the system temp directory");

View File

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

View File

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

View File

@ -0,0 +1,339 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClaw.Shared.Capabilities;
/// <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: {ex.Message}");
v2Result = ExecApprovalV2Result.ValidationFailed("Handler exception");
}
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: {ex.Message}");
return Error("Execution failed");
}
}
@ -614,7 +614,7 @@ public class SystemCapability : NodeCapabilityBase
catch (Exception ex)
{
Logger.Error("execApprovals.set failed", ex);
return Error($"Failed to update policy: {ex.Message}");
return Error("Failed to update policy");
}
}

View File

@ -10,6 +10,11 @@ public sealed class TtsCapability : NodeCapabilityBase
public const string SpeakCommand = "tts.speak";
public const string WindowsProvider = "windows";
public const string ElevenLabsProvider = "elevenlabs";
/// <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];
@ -30,7 +35,7 @@ public sealed class TtsCapability : NodeCapabilityBase
: requestedProvider;
return string.IsNullOrWhiteSpace(provider)
? WindowsProvider
? PiperProvider
: provider.Trim().ToLowerInvariant();
}
@ -81,8 +86,14 @@ 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: {ex.Message}");
return Error("Speak failed");
}
}

View File

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

View File

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -19,15 +21,25 @@ public class DeviceIdentity
private PublicKey? _publicKey;
private string? _deviceId;
private string? _deviceToken;
private string[]? _deviceTokenScopes;
private string? _nodeDeviceToken;
private string[]? _nodeDeviceTokenScopes;
private static readonly SignatureAlgorithm Ed25519Algorithm = SignatureAlgorithm.Ed25519;
public string DeviceId => _deviceId ?? throw new InvalidOperationException("Device not initialized");
public string PublicKeyBase64Url => _publicKey != null ? Base64UrlEncode(_publicKey.Export(KeyBlobFormat.RawPublicKey)) : throw new InvalidOperationException("Device not initialized");
public string? DeviceToken => _deviceToken;
public IReadOnlyList<string>? DeviceTokenScopes => _deviceTokenScopes;
public string? NodeDeviceToken => _nodeDeviceToken;
public IReadOnlyList<string>? NodeDeviceTokenScopes => _nodeDeviceTokenScopes;
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null)
public static string? TryReadStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
TryReadStoredDeviceTokenForRole(dataPath, "operator", logger);
public static string? TryReadStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null)
{
var tokenRole = ParseDeviceTokenRole(role);
var keyPath = Path.Combine(dataPath, "device-key-ed25519.json");
if (!File.Exists(keyPath))
{
@ -37,7 +49,11 @@ public class DeviceIdentity
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(keyPath));
if (doc.RootElement.TryGetProperty(nameof(DeviceKeyData.DeviceToken), out var deviceToken) &&
var tokenPropertyName = tokenRole == DeviceTokenRole.Node
? nameof(DeviceKeyData.NodeDeviceToken)
: nameof(DeviceKeyData.DeviceToken);
if (doc.RootElement.TryGetProperty(tokenPropertyName, out var deviceToken) &&
deviceToken.ValueKind == JsonValueKind.String)
{
var value = deviceToken.GetString();
@ -62,6 +78,9 @@ public class DeviceIdentity
public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger = null) =>
!string.IsNullOrWhiteSpace(TryReadStoredDeviceToken(dataPath, logger));
public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) =>
!string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger));
public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null)
{
@ -103,6 +122,9 @@ 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]}...");
}
@ -310,11 +332,41 @@ public class DeviceIdentity
/// Store the device token received after pairing approval
/// </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
@ -326,6 +378,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");
@ -337,6 +390,48 @@ 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)
{
@ -346,12 +441,21 @@ 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,14 +249,8 @@ public class ExecApprovalPolicy
var dir = Path.GetDirectoryName(_policyFilePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
var data = new ExecPolicyData
{
DefaultAction = _defaultAction,
Rules = _rules
};
var json = JsonSerializer.Serialize(data, _jsonOptions);
var json = JsonSerializer.Serialize(GetPolicyData(), _jsonOptions);
File.WriteAllText(_policyFilePath, json);
}
catch (Exception ex)

View File

@ -0,0 +1,70 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Architectural barrier produced by PR3.
// Equivalent to ExecHostValidatedRequest in the macOS reference, extended with resolution outputs.
// No module from PR4 onward may accept ValidatedRunRequest as direct input (research doc 05 line 439).
// Rail 15: a single canonical representation reused across evaluation, logging, prompting, execution.
public sealed class CanonicalCommandIdentity
{
// ── Normalization outputs ─────────────────────────────────────────────────
// Argv exactly as produced by PR2 (no trimming; coding contract process-argv-semantics).
public IReadOnlyList<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

@ -0,0 +1,85 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Either a CanonicalCommandIdentity (IsResolved=true) or a typed denial (IsResolved=false).
// Produced by ExecApprovalV2Normalizer; consumed by the coordinator pipeline (PR7).
public sealed class ExecApprovalV2NormalizationOutcome
{
public bool IsResolved { get; }
public CanonicalCommandIdentity? Identity { get; }
public ExecApprovalV2Result? Error { get; }
private ExecApprovalV2NormalizationOutcome(CanonicalCommandIdentity identity)
{
IsResolved = true;
Identity = identity;
}
private ExecApprovalV2NormalizationOutcome(ExecApprovalV2Result error)
{
IsResolved = false;
Error = error;
}
public static ExecApprovalV2NormalizationOutcome Ok(CanonicalCommandIdentity identity)
=> new(identity);
public static ExecApprovalV2NormalizationOutcome Fail(ExecApprovalV2Result error)
=> new(error);
}
// Rail 18 steps 2-4: normalize command form → resolve executable → build canonical identity.
// Stateless — safe to call concurrently.
public static class ExecApprovalV2Normalizer
{
public static ExecApprovalV2NormalizationOutcome Normalize(ValidatedRunRequest request)
{
var argv = request.Argv;
var cwd = request.Cwd;
var env = request.Env as IReadOnlyDictionary<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

@ -0,0 +1,501 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenClaw.Shared.ExecApprovals;
// Resolved identity of a single executable token.
// Shape mirrors macOS ExecCommandResolution struct.
public readonly record struct ExecCommandResolution(
string RawExecutable,
string? ResolvedPath,
string ExecutableName,
string? Cwd);
// The three resolution functions required by the pipeline.
// resolve() → singular, for state machine
// ResolveForAllowlist() → multi-segment, fail-closed, for allowlist matching
// ResolveAllowAlwaysPatterns() → UX suggestions for prompt
internal static class ExecCommandResolver
{
// Windows executable extensions, tried in order for basename search.
private static readonly string[] s_extensions = [".exe", ".cmd", ".bat", ".com"];
// ── Public API ───────────────────────────────────────────────────────────
// Singular resolution of the primary executable for the state machine.
// Returns null if the command is empty or resolution is impossible.
// Unwraps transparent env prefixes (no modifiers).
internal static ExecCommandResolution? Resolve(
IReadOnlyList<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

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

View File

@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace OpenClaw.Shared.ExecApprovals;
// Strips `env [OPTIONS] [VAR=VAL...] COMMAND [ARGS...]` so the true executable can be resolved.
// Fail-closed: returns null when any unknown flag is encountered or the command cannot be safely
// unwrapped. Mirrors ExecEnvInvocationUnwrapper in the windows-app reference.
internal static class ExecEnvInvocationUnwrapper
{
internal const int MaxWrapperDepth = 4;
private static readonly Regex s_envAssignment =
new(@"^[A-Za-z_][A-Za-z0-9_]*=", RegexOptions.Compiled);
// Strips one level of `env` wrapper.
// Returns the remaining argv starting at the real COMMAND token, or null on any ambiguity.
internal static IReadOnlyList<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

@ -0,0 +1,38 @@
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Option grammar of the POSIX `env` command.
// Mirrors the constants in the windows-app reference (ExecEnvOptions.cs).
internal static class ExecEnvOptions
{
// Options that consume the next argument as their value (or use inline = form).
internal static readonly HashSet<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

@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
namespace OpenClaw.Shared.ExecApprovals;
// Single-level shell wrapper detection for the V2 exec approval pipeline.
// Differs from the legacy ExecShellWrapperParser.Expand (BFS multi-level, string-based).
// This normalizer operates on argv (IReadOnlyList<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,9 +90,20 @@ internal static class ExecEnvSanitizer
if (name.IndexOfAny(['=', '\0', '\r', '\n']) >= 0)
return true;
foreach (var c in name)
// Vectorized scan: any char in [0x00, 0x20] covers all ASCII control characters
// (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++)
{
if (char.IsControl(c) || char.IsWhiteSpace(c))
var c = name[i];
if (c > '\x7F' && (char.IsControl(c) || char.IsWhiteSpace(c)))
return true;
}

View File

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

View File

@ -0,0 +1,25 @@
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

@ -236,9 +236,18 @@ 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 ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.",
"Speak text aloud on the Windows node. Args: text (string, required), provider ('piper'|'windows'|'elevenlabs', optional — falls back to the configured TtsProvider setting, default 'piper' for fresh installs), voiceId (string, optional — overrides the per-provider configured voice), model (string, optional, ElevenLabs only), interrupt (bool, default false — interrupts any in-progress playback). Returns { spoken, provider, contentType, durationMs }.",
// app.*
["app.navigate"] =

View File

@ -5,6 +5,8 @@ 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;
@ -13,6 +15,19 @@ 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;
@ -25,4 +40,9 @@ 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 only for camera clips with audio or future voice features.",
Detail = "Required for camera clips with audio and for stt.transcribe speech-to-text capture.",
SettingsUri = "ms-settings:privacy-microphone"
},
new()
@ -1019,7 +1019,7 @@ public static class CommandCenterCommandGroups
public static readonly FrozenSet<string> SafeCompanionCommandSet =
SafeCompanionCommands.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static readonly string[] DangerousCommands =
public static readonly string[] CommonDangerousCommands =
[
"camera.snap",
"camera.clip",
@ -1027,6 +1027,14 @@ 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);
@ -1235,7 +1243,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 or screen recording access for this node.",
Detail = $"{blocked} {(node.MissingDangerousAllowlistCommands.Count == 1 ? "is" : "are")} declared but filtered by gateway policy. Leave blocked unless you explicitly want camera, microphone, or screen recording access for this node.",
RepairAction = "Copy opt-in guidance",
CopyText = BuildDangerousCommandOptInGuidance(node.MissingDangerousAllowlistCommands)
});
@ -1522,12 +1530,27 @@ public class AgentEventInfo
public string FormattedTime => Timestamp.ToString("HH:mm:ss.fff");
public string StreamUpper => Stream.ToUpperInvariant();
/// <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 => Stream.ToLowerInvariant() switch
public string BadgeColorHex => ResolvedStream switch
{
"tool" => "#FFDC781E", // Orange
"tool" => "#FFB45D3A", // Burnt sienna
"assistant" => "#FF28A050", // Green
"error" => "#FFC83232", // Red
"lifecycle" => "#FF3C78C8", // Blue
@ -1546,17 +1569,23 @@ public class AgentEventInfo
if (!string.IsNullOrEmpty(Summary)) return Summary;
try
{
var s = Stream.ToLowerInvariant();
var s = ResolvedStream;
if (s == "tool" && Data.ValueKind == JsonValueKind.Object)
{
var name = Data.TryGetProperty("name", out var n) ? n.GetString() : null;
var title = Data.TryGetProperty("title", out var ti) ? ti.GetString() : null;
var phase = Data.TryGetProperty("phase", out var p) ? p.GetString() : null;
if (name != null) return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
var status = Data.TryGetProperty("status", out var st) ? st.GetString() : null;
// Prefer title (richer) over just name
if (title != null)
return phase != null ? $"🔧 {title} ({phase})" : $"🔧 {title}";
if (name != null)
return phase != null ? $"🔧 {name} ({phase})" : $"🔧 {name}";
}
if (s == "assistant" && Data.ValueKind == JsonValueKind.Object)
{
var text = Data.TryGetProperty("text", out var t) ? t.GetString() : null;
if (text != null) return text.Length > 120 ? text[..120] + "…" : text;
if (text != null) return text.Length > 300 ? text[..300] + "…" : text;
}
if (s == "error" && Data.ValueKind == JsonValueKind.Object)
{
@ -1566,8 +1595,11 @@ public class AgentEventInfo
}
if (s == "lifecycle" && Data.ValueKind == JsonValueKind.Object)
{
var state = Data.TryGetProperty("state", out var st) ? st.GetString() : null;
if (state != null) return $"⚡ {state}";
var state = Data.TryGetProperty("state", out var st) ? st.GetString()
: Data.TryGetProperty("livenessState", out var ls) ? ls.GetString() : null;
var phase = Data.TryGetProperty("phase", out var ph) ? ph.GetString() : null;
if (state != null)
return phase != null ? $"⚡ {state} ({phase})" : $"⚡ {state}";
}
}
catch { }
@ -1577,18 +1609,51 @@ public class AgentEventInfo
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
{
return JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
_cachedDataJson = JsonSerializer.Serialize(Data, new JsonSerializerOptions { WriteIndented = true });
}
catch
{
return Data.ToString() ?? "{}";
_cachedDataJson = Data.ToString() ?? "{}";
}
return _cachedDataJson;
}
}
}

View File

@ -15,6 +15,13 @@
<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>

View File

@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@ -18,6 +19,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
private const string OperatorRole = "operator";
private const string OperatorPlatform = "windows";
private const string OperatorDeviceFamily = "desktop";
private static readonly Regex s_pairingRequestIdRegex = new("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled);
private static readonly string[] s_operatorScopes =
[
"operator.admin",
@ -53,6 +55,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
private readonly object _pendingChatSendLock = new();
private readonly object _sessionsLock = new();
private readonly DeviceIdentity _deviceIdentity;
private readonly string _currentGatewayUrl;
private string _mainSessionKey = "main";
private string? _operatorDeviceId;
private string[] _grantedOperatorScopes = Array.Empty<string>();
@ -72,15 +75,22 @@ public class OpenClawGatewayClient : WebSocketClientBase
private bool _agentFileGetUnsupported;
private bool _operatorReadScopeUnavailable;
private bool _pairingRequiredAwaitingApproval;
private string? _pairingRequiredRequestId;
private bool _authFailed;
private readonly bool _useBootstrapHandoffAuth;
private readonly bool _tokenIsBootstrapToken;
private readonly bool _bootstrapPairAsNode;
/// <summary>True when the gateway reported "pairing required" for this device.</summary>
public bool IsPairingRequired => _pairingRequiredAwaitingApproval;
/// <summary>Safe requestId returned in structured pairing-required details, when present.</summary>
public string? PairingRequiredRequestId => _pairingRequiredRequestId;
/// <summary>True when the device signature was rejected in all supported modes.</summary>
public bool IsAuthFailed => _authFailed;
/// <summary>The gateway auth token used for this connection.</summary>
public string ConnectAuthToken => _connectAuthToken;
private IReadOnlyList<UserNotificationRule>? _userRules;
private bool _preferStructuredCategories = true;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, TaskCompletionSource<JsonElement>> _pendingWizardResponses = new();
@ -178,21 +188,20 @@ public class OpenClawGatewayClient : WebSocketClientBase
public IReadOnlyList<string> GrantedOperatorScopes => _grantedOperatorScopes;
public bool IsConnectedToGateway => IsConnected;
public OpenClawGatewayClient(
string gatewayUrl,
string token,
IOpenClawLogger? logger = null,
bool useBootstrapHandoffAuth = false)
public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null, bool tokenIsBootstrapToken = false, bool bootstrapPairAsNode = false)
: base(gatewayUrl, token, logger)
{
_useBootstrapHandoffAuth = useBootstrapHandoffAuth;
_tokenIsBootstrapToken = tokenIsBootstrapToken;
_bootstrapPairAsNode = bootstrapPairAsNode;
_currentGatewayUrl = gatewayUrl;
var dataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR")
?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenClawTray");
_deviceIdentity = new DeviceIdentity(dataPath, _logger);
_deviceIdentity.Initialize();
_connectAuthToken = _deviceIdentity.DeviceToken ?? _token;
_connectAuthToken = _deviceIdentity.DeviceToken ?? (_tokenIsBootstrapToken ? string.Empty : _token);
}
public async Task DisconnectAsync()
@ -290,6 +299,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
if (!IsConnected)
throw new InvalidOperationException("Gateway connection is not open");
_logger.Info($"[GatewayClient] Sending frame: {method}");
var requestId = Guid.NewGuid().ToString();
var completion = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingWizardResponses[requestId] = completion;
@ -604,13 +614,14 @@ public class OpenClawGatewayClient : WebSocketClientBase
{
var requestId = Guid.NewGuid().ToString();
TrackPendingRequest(requestId, "connect");
var requestedScopes = GetRequestedOperatorScopes();
var role = GetConnectRole();
var requestedScopes = GetRequestedScopes(role);
var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var connectNonce = nonce ?? string.Empty;
var signatureToken = _signatureTokenMode is SignatureTokenMode.V3EmptyToken or SignatureTokenMode.V2EmptyToken
? string.Empty
: _connectAuthToken;
: GetSignatureToken();
var signature = _signatureTokenMode is SignatureTokenMode.V2AuthToken or SignatureTokenMode.V2EmptyToken
? _deviceIdentity.SignConnectPayloadV2(
@ -618,7 +629,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
signedAt,
OperatorClientId,
OperatorClientMode,
OperatorRole,
role,
requestedScopes,
signatureToken)
: _deviceIdentity.SignConnectPayloadV3(
@ -626,7 +637,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
signedAt,
OperatorClientId,
OperatorClientMode,
OperatorRole,
role,
requestedScopes,
signatureToken,
OperatorPlatform,
@ -650,7 +661,7 @@ public class OpenClawGatewayClient : WebSocketClientBase
mode = OperatorClientMode,
displayName = OperatorClientDisplayName
},
role = OperatorRole,
role,
scopes = requestedScopes,
caps = Array.Empty<string>(),
commands = Array.Empty<string>(),
@ -680,10 +691,35 @@ public class OpenClawGatewayClient : WebSocketClientBase
}
}
private string[] GetRequestedOperatorScopes() =>
_useBootstrapHandoffAuth && string.IsNullOrEmpty(_deviceIdentity.DeviceToken)
? s_operatorBootstrapScopes
private string GetConnectRole()
{
return _bootstrapPairAsNode && _tokenIsBootstrapToken && string.IsNullOrEmpty(_deviceIdentity.DeviceToken)
? "node"
: OperatorRole;
}
private string[] GetRequestedScopes(string role)
{
if (role == "node")
return [];
if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
{
// Fresh-device ordering is intentional:
// 1. QR/setup-code bootstrap keeps bounded handoff scopes.
// 2. Standard token auth against the local loopback gateway we installed requests
// full operator scopes, including operator.admin, for the easy-button setup flow.
// 3. Remote/non-loopback fresh standard devices remain bounded and require manual approval.
if (!_tokenIsBootstrapToken && LocalGatewayUrlClassifier.IsLocalGatewayUrl(_currentGatewayUrl))
return s_operatorScopes;
return s_operatorBootstrapScopes;
}
return _deviceIdentity.DeviceTokenScopes is { Count: > 0 } scopes
? scopes.ToArray()
: s_operatorScopes;
}
/// <summary>
/// Builds the auth payload for the connect handshake, matching the gateway's
@ -693,27 +729,34 @@ public class OpenClawGatewayClient : WebSocketClientBase
/// </summary>
private Dictionary<string, string> BuildAuthPayload()
{
var auth = new Dictionary<string, string> { ["token"] = _connectAuthToken };
if (!_useBootstrapHandoffAuth)
{
return auth;
}
var auth = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
{
// Paired device: send explicit device token for cleaner auth path
auth["deviceToken"] = _deviceIdentity.DeviceToken;
}
else if (_tokenIsBootstrapToken)
{
// Fresh QR/setup-code device: do not also send auth.token, which upstream treats
// as an explicit gateway token and therefore suppresses bootstrap pairing.
auth["bootstrapToken"] = _token;
}
else
{
// Fresh device: send bootstrap token for initial pairing
auth["bootstrapToken"] = _token;
auth["token"] = _connectAuthToken;
}
return auth;
}
private string GetSignatureToken()
{
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
return _deviceIdentity.DeviceToken;
return _tokenIsBootstrapToken ? _token : _connectAuthToken;
}
private async Task SendTrackedRequestAsync(string method, object? parameters = null)
{
if (!IsConnected) return;
@ -809,6 +852,13 @@ public class OpenClawGatewayClient : WebSocketClientBase
_pendingChatSendRequests.Clear();
}
foreach (var completion in _pendingWizardResponses.Values)
{
completion.TrySetException(new OperationCanceledException("Gateway connection lost while waiting for wizard response"));
}
_pendingWizardResponses.Clear();
}
private void TrackPendingChatSend(string requestId, TaskCompletionSource<bool> completion)
@ -915,7 +965,8 @@ public class OpenClawGatewayClient : WebSocketClientBase
else if (root.TryGetProperty("payload", out var wizPayload))
{
// Log the payload kind for debugging
_logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizPayload.ToString()?.Substring(0, Math.Min(200, wizPayload.ToString()?.Length ?? 0))}");
var wizardPayloadText = TokenSanitizer.Sanitize(wizPayload.ToString());
_logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizardPayloadText[..Math.Min(200, wizardPayloadText.Length)]}");
wizardCompletion.TrySetResult(wizPayload.Clone());
}
else
@ -943,16 +994,33 @@ public class OpenClawGatewayClient : WebSocketClientBase
if (payload.TryGetProperty("type", out var t) && t.GetString() == "hello-ok")
{
_pairingRequiredAwaitingApproval = false;
_pairingRequiredRequestId = null;
_authFailed = false;
ResetReconnectAttempts();
_operatorDeviceId = TryGetHandshakeDeviceId(payload);
_grantedOperatorScopes = TryGetHandshakeScopes(payload);
_mainSessionKey = TryGetHandshakeMainSessionKey(payload) ?? "main";
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
var newDeviceToken = TryGetHandshakeDeviceToken(payload);
if (_bootstrapPairAsNode)
{
var nodeDeviceToken = TryGetHandshakeDeviceTokenCore(payload, "node", allowDirectDeviceTokenFallback: true);
if (!string.IsNullOrWhiteSpace(nodeDeviceToken))
{
var nodeDeviceTokenScopes = TryGetHandshakeDeviceTokenScopesCore(payload, "node", allowDirectDeviceTokenFallback: true);
_deviceIdentity.StoreDeviceTokenForRole("node", nodeDeviceToken, nodeDeviceTokenScopes);
_logger.Info("Node device token stored for Windows tray node reconnect");
}
}
var newDeviceToken = _bootstrapPairAsNode
? TryGetHandshakeDeviceTokenCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false)
: TryGetHandshakeDeviceTokenCore(payload, preferredRole: null);
if (!string.IsNullOrWhiteSpace(newDeviceToken))
{
_deviceIdentity.StoreDeviceToken(newDeviceToken);
var deviceTokenScopes = _bootstrapPairAsNode
? TryGetHandshakeDeviceTokenScopesCore(payload, OperatorRole, allowDirectDeviceTokenFallback: false)
: TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole: null);
_deviceIdentity.StoreDeviceTokenWithScopes(newDeviceToken, deviceTokenScopes);
_connectAuthToken = newDeviceToken;
_logger.Info("Operator device token stored for reconnect");
}
@ -1136,10 +1204,12 @@ public class OpenClawGatewayClient : WebSocketClientBase
return;
}
var pairingDetails = TryGetPairingConnectErrorDetails(root);
if (method == "connect" &&
message.Contains("pairing required", StringComparison.OrdinalIgnoreCase))
(pairingDetails.IsPairingRequired || message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)))
{
_pairingRequiredAwaitingApproval = true;
_pairingRequiredRequestId = pairingDetails.RequestId;
_logger.Warn("Pairing approval required for this device; auto-reconnect paused until manual reconnect or app restart");
RaiseStatusChanged(ConnectionStatus.Error);
return;
@ -1273,6 +1343,48 @@ public class OpenClawGatewayClient : WebSocketClientBase
return null;
}
private static PairingConnectErrorDetails TryGetPairingConnectErrorDetails(JsonElement root)
{
if (!root.TryGetProperty("error", out var error) || error.ValueKind != JsonValueKind.Object)
return default;
if (!TryGetPairingDetailsElement(error, out var details) || details.ValueKind != JsonValueKind.Object)
return default;
var isPairingRequired = details.TryGetProperty("code", out var code)
&& code.ValueKind == JsonValueKind.String
&& string.Equals(code.GetString(), "PAIRING_REQUIRED", StringComparison.Ordinal);
var requestId = TryGetSafePairingRequestId(details);
return new PairingConnectErrorDetails(isPairingRequired, requestId);
}
private static bool TryGetPairingDetailsElement(JsonElement error, out JsonElement details)
{
if (error.TryGetProperty("details", out details))
return true;
if (error.TryGetProperty("data", out var data)
&& data.ValueKind == JsonValueKind.Object
&& data.TryGetProperty("details", out details))
{
return true;
}
details = default;
return false;
}
private static string? TryGetSafePairingRequestId(JsonElement details)
{
if (!details.TryGetProperty("requestId", out var requestId) || requestId.ValueKind != JsonValueKind.String)
return null;
var value = requestId.GetString()?.Trim();
return value is not null && s_pairingRequestIdRegex.IsMatch(value) ? value : null;
}
private readonly record struct PairingConnectErrorDetails(bool IsPairingRequired, string? RequestId);
private static bool IsUnknownMethodError(string errorMessage)
{
return errorMessage.Contains("unknown method", StringComparison.OrdinalIgnoreCase);
@ -1326,27 +1438,40 @@ public class OpenClawGatewayClient : WebSocketClientBase
private static string[] TryGetHandshakeScopes(JsonElement payload)
{
if (payload.TryGetProperty("auth", out var authPayload) &&
authPayload.ValueKind == JsonValueKind.Object &&
authPayload.TryGetProperty("scopes", out var authScopes) &&
authScopes.ValueKind == JsonValueKind.Array)
{
return ReadStringArray(authScopes);
}
if (payload.TryGetProperty("scopes", out var scopesProp) &&
scopesProp.ValueKind == JsonValueKind.Array)
{
var buffer = new string[scopesProp.GetArrayLength()];
var count = 0;
foreach (var scope in scopesProp.EnumerateArray())
{
if (scope.ValueKind == JsonValueKind.String)
{
var value = scope.GetString();
if (!string.IsNullOrWhiteSpace(value))
buffer[count++] = value;
}
}
return buffer[..count];
return ReadStringArray(scopesProp);
}
return [];
}
private static string[] ReadStringArray(JsonElement array)
{
var buffer = new string[array.GetArrayLength()];
var count = 0;
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
buffer[count++] = value;
}
}
return buffer[..count];
}
private static string? TryGetHandshakeMainSessionKey(JsonElement payload)
{
if (!payload.TryGetProperty("snapshot", out var snapshot) || snapshot.ValueKind != JsonValueKind.Object)
@ -1369,12 +1494,49 @@ public class OpenClawGatewayClient : WebSocketClientBase
}
private static string? TryGetHandshakeDeviceToken(JsonElement payload)
{
return TryGetHandshakeDeviceTokenCore(payload, preferredRole: null);
}
private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole)
{
return TryGetHandshakeDeviceTokenCore(payload, preferredRole, allowDirectDeviceTokenFallback: true);
}
private static string? TryGetHandshakeDeviceTokenCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback)
{
if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!string.IsNullOrWhiteSpace(preferredRole) &&
authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
deviceTokens.ValueKind == JsonValueKind.Array)
{
foreach (var entry in deviceTokens.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
continue;
if (entry.TryGetProperty("role", out var role) &&
role.ValueKind == JsonValueKind.String &&
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase) &&
entry.TryGetProperty("deviceToken", out var roleToken) &&
roleToken.ValueKind == JsonValueKind.String)
{
var roleTokenValue = roleToken.GetString();
if (!string.IsNullOrWhiteSpace(roleTokenValue))
return roleTokenValue;
}
}
if (!allowDirectDeviceTokenFallback)
{
return null;
}
}
if (!authPayload.TryGetProperty("deviceToken", out var deviceToken) || deviceToken.ValueKind != JsonValueKind.String)
{
return null;
@ -1384,6 +1546,54 @@ public class OpenClawGatewayClient : WebSocketClientBase
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole)
{
return TryGetHandshakeDeviceTokenScopesCore(payload, preferredRole, allowDirectDeviceTokenFallback: true);
}
private static string[]? TryGetHandshakeDeviceTokenScopesCore(JsonElement payload, string? preferredRole, bool allowDirectDeviceTokenFallback)
{
if (!payload.TryGetProperty("auth", out var authPayload) || authPayload.ValueKind != JsonValueKind.Object)
{
return null;
}
if (!string.IsNullOrWhiteSpace(preferredRole) &&
authPayload.TryGetProperty("deviceTokens", out var deviceTokens) &&
deviceTokens.ValueKind == JsonValueKind.Array)
{
foreach (var entry in deviceTokens.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object)
continue;
if (entry.TryGetProperty("role", out var role) &&
role.ValueKind == JsonValueKind.String &&
string.Equals(role.GetString(), preferredRole, StringComparison.OrdinalIgnoreCase))
{
return entry.TryGetProperty("scopes", out var roleScopes) && roleScopes.ValueKind == JsonValueKind.Array
? ReadStringArray(roleScopes)
: [];
}
}
if (!allowDirectDeviceTokenFallback)
{
return null;
}
}
if (authPayload.TryGetProperty("deviceToken", out var deviceToken) &&
deviceToken.ValueKind == JsonValueKind.String &&
authPayload.TryGetProperty("scopes", out var scopes) &&
scopes.ValueKind == JsonValueKind.Array)
{
return ReadStringArray(scopes);
}
return null;
}
public string BuildMissingScopeFixCommands(string missingScope)
{
var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim();

View File

@ -16,8 +16,14 @@ public class SettingsData
public string? SshTunnelHost { get; set; }
public int SshTunnelRemotePort { get; set; } = 18789;
public int SshTunnelLocalPort { get; set; } = 18789;
public bool AutoStart { get; set; }
public bool AutoStart { get; set; } = true;
public bool GlobalHotkeyEnabled { get; set; } = true;
/// <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;
@ -32,10 +38,28 @@ public class SettingsData
public bool NodeCanvasEnabled { get; set; } = true;
public bool NodeScreenEnabled { get; set; } = true;
public bool NodeCameraEnabled { get; set; } = true;
public bool ScreenRecordingConsentGiven { get; set; } = false;
public bool CameraRecordingConsentGiven { get; set; } = false;
public bool NodeLocationEnabled { get; set; } = true;
public bool NodeBrowserProxyEnabled { get; set; } = true;
public bool NodeSttEnabled { get; set; } = false;
/// <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; } = "windows";
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.
@ -43,6 +67,8 @@ public class SettingsData
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>
@ -64,6 +90,8 @@ public class SettingsData
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,6 +8,16 @@ public static class SshTunnelCommandLine
private static readonly Regex s_validSshUser = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
private static readonly Regex s_validSshHost = new(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled);
// Fixed SSH options shared by every tunnel invocation.
// Centralised here so the connection policy is visible and easy to review or adjust.
private const string BaseOptions =
"-o BatchMode=yes " +
"-o ExitOnForwardFailure=yes " +
"-o ServerAliveInterval=15 " +
"-o ServerAliveCountMax=3 " +
"-o TCPKeepAlive=yes " +
"-N ";
public static string BuildArguments(string user, string host, int remotePort, int localPort)
=> BuildArguments(user, host, remotePort, localPort, includeBrowserProxyForward: false);
@ -33,13 +43,7 @@ public static class SshTunnelCommandLine
ValidateBrowserProxyPort(localPort, nameof(localPort));
}
var sb = new StringBuilder();
sb.Append("-o BatchMode=yes ");
sb.Append("-o ExitOnForwardFailure=yes ");
sb.Append("-o ServerAliveInterval=15 ");
sb.Append("-o ServerAliveCountMax=3 ");
sb.Append("-o TCPKeepAlive=yes ");
sb.Append("-N ");
var sb = new StringBuilder(BaseOptions);
AppendLocalForward(sb, localPort, remotePort);
if (includeBrowserProxyForward)
AppendLocalForward(sb, localPort + 2, remotePort + 2);

View File

@ -12,6 +12,10 @@ 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);
@ -25,6 +29,7 @@ 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,6 +251,10 @@ 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,6 +30,14 @@ 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;
@ -57,7 +65,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.DeviceToken);
public bool IsPaired => _isPaired || !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
/// <summary>Device ID for display/approval (first 16 chars of full ID)</summary>
public string ShortDeviceId => _deviceIdentity.DeviceId.Length > 16
@ -74,7 +82,7 @@ public class WindowsNodeClient : WebSocketClientBase
protected override string ClientRole => "node";
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath), logger)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken, dataPath, logger), logger)
{
_gatewayToken = NormalizeOptionalCredential(token);
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
@ -98,8 +106,14 @@ public class WindowsNodeClient : WebSocketClientBase
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
}
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath)
private static string ResolveRequiredCredential(string? token, string? bootstrapToken, string dataPath, IOpenClawLogger? logger)
{
var storedNodeToken = TryLoadStoredNodeToken(dataPath, logger);
if (!string.IsNullOrEmpty(storedNodeToken))
{
return storedNodeToken;
}
var gatewayToken = NormalizeOptionalCredential(token);
if (!string.IsNullOrEmpty(gatewayToken))
{
@ -112,14 +126,27 @@ 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
@ -186,7 +213,7 @@ public class WindowsNodeClient : WebSocketClientBase
try
{
// Log raw messages at debug level (visible in dbgview, not in log file noise)
_logger.Debug($"[NODE RX] {json}");
_logger.Debug($"[NODE RX] {TokenSanitizer.Sanitize(json)}");
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
@ -277,11 +304,12 @@ 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}");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
$"Run: openclaw devices approve {ShortDeviceId}..."));
@ -310,9 +338,10 @@ public class WindowsNodeClient : WebSocketClientBase
{
_isPendingApproval = false;
_isPaired = true;
_pairingBlocked = false; // Allow reconnect after approval
_pairingApprovedAwaitingReconnect = true;
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId,
"Pairing approved; reconnecting to refresh node state."));
@ -328,7 +357,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = false;
_pairingApprovedAwaitingReconnect = false;
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Rejected,
_deviceIdentity.DeviceId,
null));
@ -499,7 +528,7 @@ public class WindowsNodeClient : WebSocketClientBase
private async Task SendNodeConnectAsync(string? nonce, long ts)
{
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken);
var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken);
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})");
@ -569,9 +598,9 @@ public class WindowsNodeClient : WebSocketClientBase
private (Dictionary<string, string> Auth, string TokenForSignature) BuildConnectAuth()
{
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
if (!string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
{
return (new Dictionary<string, string> { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken);
return (new Dictionary<string, string> { ["deviceToken"] = _deviceIdentity.NodeDeviceToken }, _deviceIdentity.NodeDeviceToken);
}
if (!string.IsNullOrEmpty(_bootstrapToken))
@ -603,6 +632,7 @@ 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
@ -627,8 +657,8 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info("Received device token - we are now paired!");
_deviceIdentity.StoreDeviceToken(deviceToken);
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
_deviceIdentity.StoreDeviceTokenForRole("node", deviceToken, TryGetAuthScopes(authPayload));
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId,
wasWaiting ? "Pairing approved!" : null));
@ -641,7 +671,7 @@ public class WindowsNodeClient : WebSocketClientBase
// Skip this block if we already fired PairingStatusChanged above via gotNewToken.
if (!gotNewToken)
{
if (string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
if (string.IsNullOrEmpty(_deviceIdentity.NodeDeviceToken))
{
if (reconnectingAfterApproval)
{
@ -654,9 +684,10 @@ 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}");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
$"Run: openclaw devices approve {ShortDeviceId}..."));
@ -668,7 +699,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPaired = true;
_pairingApprovedAwaitingReconnect = false;
_logger.Info("Already paired with stored device token");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Paired,
_deviceIdentity.DeviceId));
}
@ -678,6 +709,22 @@ 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";
@ -717,6 +764,7 @@ public class WindowsNodeClient : WebSocketClientBase
_isPendingApproval = true;
_isPaired = false;
_pairingBlocked = true;
_pairingApprovedAwaitingReconnect = false;
var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
@ -724,14 +772,26 @@ 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}");
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
EmitPairingStatusOnTransition(new PairingStatusEventArgs(
PairingStatus.Pending,
_deviceIdentity.DeviceId,
detail));
return;
}
_logger.Error($"Node registration failed: {error} (code: {errorCode})");
// 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})");
RaiseStatusChanged(ConnectionStatus.Error);
}
@ -779,6 +839,27 @@ 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)
{
@ -930,16 +1011,8 @@ public class WindowsNodeClient : WebSocketClientBase
}
/// <summary>
/// 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.
/// Sends a node.event request with JSON payload.
/// Returns false when not connected or when the transport send fails.
/// </summary>
public async Task<bool> SendNodeEventAsync(string eventName, System.Text.Json.Nodes.JsonObject payload)
{
@ -947,9 +1020,6 @@ 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",
@ -997,6 +1067,20 @@ 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

@ -9,6 +9,7 @@ using OpenClawTray.Helpers;
using OpenClawTray.Services;
using OpenClawTray.Windows;
using OpenClawTray.Onboarding;
using OpenClawTray.Services.LocalGatewaySetup;
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
@ -37,9 +38,17 @@ public partial class App : Application
private TrayIcon? _trayIcon;
private OpenClawGatewayClient? _gatewayClient;
/// <summary>
/// Cached reference to the most recently constructed local-setup engine. Used by
/// <see cref="OnPairingStatusChanged"/> to suppress the "copy pairing command" toast
/// during Phase 14 auto-pair (Bug #2, manual test 2026-05-05). Null when no local
/// setup has run in this app lifetime.
/// </summary>
private LocalGatewaySetupEngine? _localSetupEngine;
/// <summary>The persistent gateway client. Used by the onboarding wizard for RPC calls.</summary>
public OpenClawGatewayClient? GatewayClient => _gatewayClient;
internal SettingsManager Settings => _settings ?? throw new InvalidOperationException("Settings are not initialized.");
/// <summary>
/// Ensures the managed SSH tunnel is started using the current settings.
@ -47,6 +56,28 @@ public partial class App : Application
/// </summary>
public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings);
/// <summary>
/// Creates the WSL local gateway setup engine using the current tray settings.
/// Onboarding pages (Phase 5) call this to drive the local-WSL setup flow;
/// the engine pairs the operator + Windows tray node into the gateway it
/// installs, so we eagerly materialize the NodeService when needed.
/// </summary>
public LocalGatewaySetupEngine CreateLocalGatewaySetupEngine(
bool replaceExistingConfigurationConfirmed = false)
{
var settings = _settings ?? new SettingsManager();
var nodeService = EnsureNodeServiceForLocalGatewaySetup(settings);
var engine = LocalGatewaySetupEngineFactory.CreateLocalOnly(
settings,
new AppLogger(),
nodeService,
replaceExistingConfigurationConfirmed: replaceExistingConfigurationConfirmed);
// Bug #2: cache so OnPairingStatusChanged can read engine.IsAutoPairingWindowsNode
// and suppress the "copy pairing command" toast during the Phase 14 blip.
_localSetupEngine = engine;
return engine;
}
/// <summary>
/// Returns the HWND of the active onboarding window, or IntPtr.Zero if none.
/// Used by onboarding pages that need to host file pickers / dialogs.
@ -86,6 +117,8 @@ public partial class App : Application
private DevicePairingListInfo? _lastDevicePairList;
private ModelsListInfo? _lastModelsList;
private PresenceEntry[]? _lastPresence;
private readonly List<AgentEventInfo> _agentEventsCache = new();
private const int MaxAppAgentEvents = 400;
private UpdateCommandCenterInfo _lastUpdateInfo = BuildInitialUpdateInfo();
private DateTime _lastCheckTime = DateTime.Now;
private DateTime _lastUsageActivityLogUtc = DateTime.MinValue;
@ -118,6 +151,14 @@ public partial class App : Application
private QuickSendDialog? _quickSendDialog;
private ChatWindow? _chatWindow;
private string? _authFailureMessage;
// Bug 3: per-device idempotency for "Node paired" toast. WindowsNodeClient.HandleHelloOk
// re-fires PairingStatusChanged(Paired) on every WS reconnect; we only want one toast
// per device per session. (Source-side suppression also exists in WindowsNodeClient as
// defense-in-depth.)
private readonly HashSet<string> _shownPairedToasts = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTime> _recentToastKeys = new(StringComparer.OrdinalIgnoreCase);
private static readonly TimeSpan ToastDedupeWindow = TimeSpan.FromSeconds(30);
// Node service (optional, enabled in settings)
private NodeService? _nodeService;
@ -135,6 +176,14 @@ public partial class App : Application
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OpenClawTray");
// Operator/node identity store (DeviceIdentity). Lives at %APPDATA%\OpenClawTray
// by convention so it follows the user across machines via roaming profile.
// OPENCLAW_TRAY_APPDATA_DIR isolates a test/E2E identity store the same way
// OPENCLAW_TRAY_DATA_DIR isolates the per-machine data directory.
private static readonly string IdentityDataPath = Path.Combine(
Environment.GetEnvironmentVariable("OPENCLAW_TRAY_APPDATA_DIR")
?? Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenClawTray");
private static readonly string CrashLogPath = Path.Combine(DataPath, "crash.log");
private static readonly string RunMarkerPath = Path.Combine(DataPath, "run.marker");
@ -344,17 +393,31 @@ public partial class App : Application
_sshTunnelService = new SshTunnelService(new AppLogger());
_sshTunnelService.TunnelExited += OnSshTunnelExited;
// First-run check (also supports forced onboarding for testing)
if (RequiresSetup(_settings) ||
Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1")
{
await ShowOnboardingAsync();
}
// Initialize tray icon (window-less pattern from WinUIEx)
// Initialize tray icon FIRST (window-less pattern from WinUIEx).
// The tray is application chrome and must always survive any failure
// in the onboarding wizard. OnLaunched is async void, so a synchronous
// throw inside the OnboardingWindow constructor would otherwise
// propagate through `await ShowOnboardingAsync()` and abort OnLaunched
// before the tray ever initializes.
InitializeTrayIcon();
ShowSurfaceImprovementsTipIfNeeded();
// First-run check (also supports forced onboarding for testing).
// Wrapped in try/catch so a wizard construction failure cannot tear
// down the tray; user can retry via the Setup Guide menu item.
try
{
if (RequiresSetup(_settings) ||
Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1")
{
await ShowOnboardingAsync();
}
}
catch (Exception ex)
{
Logger.Error($"Onboarding failed during launch (tray remains available): {ex}");
}
// Initialize connections — always create operator client for UI data,
// additionally create node service for gateway node mode or local MCP.
InitializeGatewayClient();
@ -364,9 +427,10 @@ public partial class App : Application
}
// Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant)
if (_settings != null && !string.IsNullOrWhiteSpace(_settings.GetEffectiveGatewayUrl()))
if (_settings != null &&
TryResolveChatCredentials(out var prewarmUrl, out var prewarmToken, out _))
{
_chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token);
_chatWindow = new ChatWindow(prewarmUrl, prewarmToken);
// Window is created but hidden — WebView2 initializes in the background
}
@ -378,6 +442,7 @@ public partial class App : Application
{
_globalHotkey = new GlobalHotkeyService();
_globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
_globalHotkey.VoiceHotkeyPressed += OnVoiceHotkeyPressed;
_globalHotkey.Register();
}
@ -433,14 +498,26 @@ public partial class App : Application
ShowChatWindow();
}
private void ShowChatWindow()
internal void ShowChatWindow()
{
if (_settings == null) return;
if (!TryResolveChatCredentials(out var url, out var token, out var credentialSource))
{
Logger.Warn("[ChatWindow] Gateway URL or credential not configured; cannot open quick chat");
return;
}
Logger.Info($"[ChatWindow] Quick-chat credentials resolved from {credentialSource}");
if (_chatWindow == null)
{
_chatWindow = new ChatWindow(_settings.GetEffectiveGatewayUrl(), _settings.Token);
_chatWindow = new ChatWindow(url, token);
}
// Bug 2: cached ChatWindow may have been pre-warmed with empty/stale credentials
// (built before pairing completed). Refresh on every tray click so quick-chat
// follows the same resolver path as the companion-app operator client.
_chatWindow.RefreshCredentials(url, token);
// Toggle: if visible, hide; if hidden, show near tray
if (_chatWindow.Visible)
{
@ -448,8 +525,72 @@ public partial class App : Application
}
else
{
_chatWindow.ShowNearTrayAnimated();
// Bug 1: When called from the wizard's close handler, OnboardingWindow.Close()
// steals focus on the same UI tick, deactivating ChatWindow → its
// OnWindowActivated auto-hides it immediately. Defer the show to a later
// dispatcher tick (Low priority) so the close + focus-loss cascade settles
// before we make the chat window visible.
var window = _chatWindow;
var dispatcher = _dispatcherQueue;
if (dispatcher != null)
{
dispatcher.TryEnqueue(
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
() =>
{
try { window.ShowNearTrayAnimated(); }
catch (Exception ex) { Logger.Warn($"ShowChatWindow deferred show failed: {ex.Message}"); }
});
}
else
{
window.ShowNearTrayAnimated();
}
}
}
private VoiceOverlayWindow? _voiceOverlayWindow;
private VoiceService? _standaloneVoiceService;
private void ShowVoiceOverlay()
{
var voiceService = _nodeService?.VoiceService ?? EnsureStandaloneVoiceService();
if (voiceService == null)
{
// STT not enabled — show settings
ShowHub("voice");
return;
}
if (_voiceOverlayWindow == null || _voiceOverlayWindow.AppWindow == null)
{
_voiceOverlayWindow = new VoiceOverlayWindow(voiceService, new AppLogger());
_voiceOverlayWindow.Closed += (_, _) => _voiceOverlayWindow = null;
// Wire transcription to gateway chat when connected
_voiceOverlayWindow.TextSubmitted += text =>
{
if (_gatewayClient != null && _currentStatus == ConnectionStatus.Connected)
{
_ = _gatewayClient.SendChatMessageAsync(text);
}
};
// Wire Settings button → open the Hub on the Voice & Audio page.
_voiceOverlayWindow.SettingsRequested += () =>
{
_dispatcherQueue?.TryEnqueue(() => ShowHub("voice"));
};
}
_voiceOverlayWindow.Activate();
}
private VoiceService? EnsureStandaloneVoiceService()
{
if (_settings?.NodeSttEnabled != true)
return null;
return _standaloneVoiceService ??= new VoiceService(new AppLogger(), _settings);
}
private void OnTrayContextMenu(TrayIcon sender, TrayIconEventArgs e)
@ -483,7 +624,6 @@ public partial class App : Application
// Rebuild menu content
_trayMenuWindow!.ClearItems();
BuildTrayMenuPopup(_trayMenuWindow);
_trayMenuWindow.SizeToContent();
_trayMenuWindow.ShowAtCursor();
}
catch (Exception ex)
@ -502,6 +642,7 @@ public partial class App : Application
case "dashboard": OpenDashboard(); break;
case "canvas": _nodeService?.ShowCanvasWindow(); break;
case "openchat": ShowChatWindow(); break;
case "voice": ShowVoiceOverlay(); break;
case "webchat": ShowWebChat(); break;
case "hub": ShowHub(); break;
case "companion":
@ -714,6 +855,7 @@ public partial class App : Application
private void AddRecentActivity(
string line,
string category = "general",
string? icon = null,
string? dashboardPath = null,
string? details = null,
string? sessionKey = null,
@ -722,6 +864,7 @@ public partial class App : Application
ActivityStreamService.Add(
category: category,
title: line,
icon: icon,
details: details,
dashboardPath: dashboardPath,
sessionKey: sessionKey,
@ -884,6 +1027,7 @@ public partial class App : Application
_lastNodePairList = null;
_lastDevicePairList = null;
_lastModelsList = null;
_agentEventsCache.Clear();
UpdateTrayIcon();
_hubWindow?.UpdateStatus(_currentStatus);
}
@ -1029,9 +1173,20 @@ public partial class App : Application
menu.AddMenuItem("Dashboard", "🌐", "dashboard");
menu.AddMenuItem("Chat", "💬", "openchat");
menu.AddMenuItem("Canvas", "🎨", "canvas");
menu.AddMenuItem("Voice", "🎙️", "voice");
menu.AddMenuItem("Companion", "🦞", "companion");
menu.AddMenuItem(LocalizationHelper.GetString("Menu_QuickSend"), "📤", "quicksend");
// Setup Guide / Reconfigure entry (PR #274 must-fix #6) — label flips
// based on whether prior config exists. Click dispatches "setup" which
// invokes the existing ShowOnboardingAsync handler (case in OnTrayMenuAction).
var setupMenuLabel = _settings != null
&& new OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard(_settings, IdentityDataPath)
.HasExistingConfiguration()
? LocalizationHelper.GetString("Menu_Reconfigure")
: LocalizationHelper.GetString("Menu_SetupGuide");
menu.AddMenuItem(setupMenuLabel, "🧭", "setup");
// ── Exit ──
menu.AddSeparator();
menu.AddMenuItem(LocalizationHelper.GetString("Menu_Exit"), "❌", "exit");
@ -1492,21 +1647,40 @@ public partial class App : Application
return;
}
if (string.IsNullOrWhiteSpace(_settings.Token))
// Bug #4 (Wizard hung at "Authenticating"): broaden credential resolution
// beyond settings.Token so a paired operator whose only credential is
// BootstrapToken or a stored DeviceIdentity DeviceToken still gets a
// client. Mirrors the prototype's resolver shape (openclaw-windows-node
// App.xaml.cs:1244-1298). Logic lives in GatewayCredentialResolver so
// Tray tests can cover all branches without booting WinUI.
var identityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json");
var credential = OpenClawTray.Services.GatewayCredentialResolver.Resolve(
_settings.Token,
_settings.BootstrapToken,
identityPath,
msg => Logger.Warn(msg));
if (credential == null)
{
Logger.Info("Gateway token not configured — skipping operator client initialization");
return;
}
// Caller's useBootstrapHandoffAuth hint is preserved as an OR so existing
// call sites that put a bootstrap value into settings.Token + pass true
// continue to send auth.bootstrapToken (OpenClawGatewayClient.cs:556-565).
var tokenIsBootstrapToken = credential.IsBootstrapToken || useBootstrapHandoffAuth;
Logger.Info($"Gateway credential resolved from {credential.Source} (bootstrap={tokenIsBootstrapToken})");
// Unsubscribe from old client if exists
UnsubscribeGatewayEvents();
_gatewayClient?.Dispose();
_lastGatewaySelf = null;
_gatewayClient = new OpenClawGatewayClient(
gatewayUrl,
_settings.Token,
credential.Token,
new AppLogger(),
useBootstrapHandoffAuth);
tokenIsBootstrapToken);
_gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null);
_gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories);
_gatewayClient.StatusChanged += OnConnectionStatusChanged;
@ -1581,7 +1755,7 @@ public partial class App : Application
if (!enableNode && !enableMcp) return;
// Gateway connection requires auth (operator token, bootstrap token, or stored device token); MCP doesn't.
var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, DataPath);
var canRunGateway = StartupSetupState.CanStartNodeGateway(_settings, IdentityDataPath);
if (enableNode && !canRunGateway && !enableMcp)
{
@ -1604,13 +1778,15 @@ public partial class App : Application
DataPath,
() => _keepAliveWindow?.Content as FrameworkElement,
_settings,
enableMcpServer: enableMcp);
enableMcpServer: enableMcp,
identityDataPath: IdentityDataPath);
_nodeService.StatusChanged += OnNodeStatusChanged;
_nodeService.NotificationRequested += OnNodeNotificationRequested;
_nodeService.PairingStatusChanged += OnPairingStatusChanged;
_nodeService.ChannelHealthUpdated += OnChannelHealthUpdated;
_nodeService.InvokeCompleted += OnNodeInvokeCompleted;
_nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated;
_nodeService.RecordingStateChanged += OnRecordingStateChanged;
if (canRunGateway)
{
@ -1632,6 +1808,40 @@ public partial class App : Application
}
}
private NodeService? EnsureNodeServiceForLocalGatewaySetup(SettingsManager settings)
{
if (_nodeService != null)
return _nodeService;
if (_dispatcherQueue == null)
return null;
try
{
_nodeService = new NodeService(
new AppLogger(),
_dispatcherQueue,
DataPath,
() => _keepAliveWindow?.Content as FrameworkElement,
settings,
enableMcpServer: settings.EnableMcpServer,
identityDataPath: IdentityDataPath);
_nodeService.StatusChanged += OnNodeStatusChanged;
_nodeService.NotificationRequested += OnNodeNotificationRequested;
_nodeService.PairingStatusChanged += OnPairingStatusChanged;
_nodeService.ChannelHealthUpdated += OnChannelHealthUpdated;
_nodeService.InvokeCompleted += OnNodeInvokeCompleted;
_nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated;
return _nodeService;
}
catch (Exception ex)
{
Logger.Error($"Failed to initialize node service for local gateway setup: {ex}");
_nodeService = null;
return null;
}
}
private void WireAppCapabilityHandlers()
{
var app = _nodeService?.AppCapability;
@ -1769,7 +1979,7 @@ public partial class App : Application
private static bool RequiresSetup(SettingsManager settings)
{
return StartupSetupState.RequiresSetup(settings, DataPath);
return StartupSetupState.RequiresSetup(settings, IdentityDataPath);
}
private bool ShouldInitializeNodeService()
@ -1795,18 +2005,52 @@ public partial class App : Application
SyncHubNodeState();
// Don't show "connected" toast if waiting for pairing - we'll show pairing status instead
if (status == ConnectionStatus.Connected && _nodeService?.IsPaired == true)
var nodeService = _nodeService;
if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true)
{
var deviceId = nodeService.FullDeviceId;
if (HasRecentToast("node-paired", deviceId))
{
Logger.Info($"[ToastDeduper] Suppressed node-connected toast after node-paired deviceId={deviceId}");
return;
}
try
{
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodeModeActive"))
.AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail")));
.AddText(LocalizationHelper.GetString("Toast_NodeModeActiveDetail")),
"node-connected",
deviceId);
}
catch { /* ignore */ }
}
}
private void OnRecordingStateChanged(object? sender, RecordingStateEventArgs args)
{
var source = args.Type == RecordingType.Screen ? "Screen" : "Camera";
if (args.IsActive)
{
var title = args.Type == RecordingType.Screen
? LocalizationHelper.GetString("Activity_ScreenRecordingStarted")
: LocalizationHelper.GetString("Activity_CameraRecordingStarted");
var duration = args.DurationMs > 0 ? $" ({args.DurationMs / 1000.0:0.#}s)" : "";
AddRecentActivity($"{title}{duration}", category: "node",
icon: "🔴",
details: string.Format(LocalizationHelper.GetString("Activity_RecordingRequestedByAgent"), source));
}
else
{
var title = args.Type == RecordingType.Screen
? LocalizationHelper.GetString("Activity_ScreenRecordingComplete")
: LocalizationHelper.GetString("Activity_CameraRecordingComplete");
AddRecentActivity(title, category: "node",
icon: "✅",
details: string.Format(LocalizationHelper.GetString("Activity_RecordingSentToAgent"), source));
}
}
private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args)
{
Logger.Info($"Pairing status: {args.Status}");
@ -1815,21 +2059,48 @@ public partial class App : Application
{
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
{
// Bug #2 (manual test 2026-05-05): suppress the "copy pairing command"
// toast while the local-setup engine is mid-Phase-14 node-role PairAsync.
// The loopback gateway parks the role-upgrade as Pending for ~100ms before
// SettingsWindowsTrayNodeProvisioner's pending-approver auto-approves it;
// the user never needs to copy the command in that window. Manual
// ConnectionPage pairings call ShowPairingPendingNotification directly
// (bypassing this event handler), so the suppression scope is exactly
// the autopair window.
if (LocalGatewaySetupEngine.ShouldSuppressPairingPendingNotification(_localSetupEngine, args.Status))
{
Logger.Info($"Suppressing pairing-pending toast: autopair Phase 14 in progress for {args.DeviceId}");
return;
}
ShowPairingPendingNotification(args.DeviceId);
}
else if (args.Status == OpenClaw.Shared.PairingStatus.Paired)
{
AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodePaired"))
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")));
// Bug 3: idempotency guard — only show "Node paired" toast/activity once
// per device per session. WS reconnects re-fire Paired; suppress duplicates.
var deviceKey = args.DeviceId ?? string.Empty;
if (_shownPairedToasts.Add(deviceKey))
{
AddRecentActivity("Node paired", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_NodePaired"))
.AddText(LocalizationHelper.GetString("Toast_NodePairedDetail")),
"node-paired",
args.DeviceId);
}
else
{
Logger.Info($"Suppressing duplicate Paired toast for device {deviceKey}");
}
}
else if (args.Status == OpenClaw.Shared.PairingStatus.Rejected)
{
AddRecentActivity("Node pairing rejected", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId, details: args.Message ?? LocalizationHelper.GetString("Toast_PairingRejectedDetail"));
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_PairingRejected"))
.AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail")));
.AddText(LocalizationHelper.GetString("Toast_PairingRejectedDetail")),
"node-pairing-rejected",
args.DeviceId);
}
}
catch { /* ignore */ }
@ -1850,6 +2121,7 @@ public partial class App : Application
_hubWindow.NodeIsPendingApproval = _nodeService.IsPendingApproval;
_hubWindow.NodeShortDeviceId = _nodeService.ShortDeviceId;
_hubWindow.NodeFullDeviceId = _nodeService.FullDeviceId;
_hubWindow.VoiceServiceInstance = _nodeService.VoiceService;
}
else
{
@ -1874,7 +2146,9 @@ public partial class App : Application
.AddButton(new ToastButton()
.SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand"))
.AddArgument("action", "copy_pairing_command")
.AddArgument("command", command)));
.AddArgument("command", command)),
"node-pairing-pending",
deviceId);
}
private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args)
@ -1945,6 +2219,19 @@ public partial class App : Application
if (_hubWindow != null && !_hubWindow.IsClosed)
_hubWindow.LastAuthError = null;
}
// Clear stale data when disconnected so tray menu doesn't show old sessions/nodes
if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error)
{
_lastSessions = Array.Empty<SessionInfo>();
_lastChannels = Array.Empty<ChannelHealth>();
_lastNodes = Array.Empty<GatewayNodeInfo>();
_lastNodePairList = null;
_lastDevicePairList = null;
_lastModelsList = null;
_lastGatewaySelf = null;
}
UpdateTrayIcon();
_dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow);
@ -2253,7 +2540,13 @@ public partial class App : Application
private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
{
_dispatcherQueue?.TryEnqueue(() => _hubWindow?.UpdateAgentEvent(evt));
_dispatcherQueue?.TryEnqueue(() =>
{
_agentEventsCache.Insert(0, evt);
if (_agentEventsCache.Count > MaxAppAgentEvents)
_agentEventsCache.RemoveRange(MaxAppAgentEvents, _agentEventsCache.Count - MaxAppAgentEvents);
_hubWindow?.UpdateAgentEvent(evt);
});
}
private void OnNodePairListUpdated(object? sender, PairingListInfo data)
@ -2286,6 +2579,32 @@ public partial class App : Application
$"{notification.Type ?? "info"}: {notification.Title ?? "notification"}",
category: "notification",
details: notification.Message);
// Voice overlay: show agent chat responses, and (independently) speak them
// if the user enabled "Read responses aloud". TTS used to be gated on
// an active voice overlay session — we want the toggle to honor every
// chat reply now that voice and text chat will eventually share one UI.
if (notification.IsChat && !string.IsNullOrEmpty(notification.Message))
{
if (_voiceOverlayWindow != null)
{
_dispatcherQueue?.TryEnqueue(() =>
{
try
{
_voiceOverlayWindow?.AddAgentResponse(notification.Message);
}
catch { }
});
}
// TTS: read response aloud whenever the toggle is on (any chat surface).
if (_settings?.VoiceTtsEnabled == true)
{
_ = SpeakResponseAsync(notification.Message);
}
}
if (_settings?.ShowNotifications != true) return;
if (!ShouldShowNotification(notification)) return;
@ -2487,7 +2806,7 @@ public partial class App : Application
#region Window Management
private void ShowHub(string? navigateTo = null)
internal void ShowHub(string? navigateTo = null, bool activate = true)
{
if (_hubWindow == null || _hubWindow.IsClosed)
{
@ -2498,6 +2817,7 @@ public partial class App : Application
_hubWindow.OpenDashboardAction = OpenDashboard;
_hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync();
_hubWindow.QuickSendAction = () => ShowQuickSend();
_hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync();
_hubWindow.ConnectAction = () =>
{
InitializeGatewayClient();
@ -2519,6 +2839,7 @@ public partial class App : Application
{
ReconnectGateway();
};
_hubWindow.ClearAppAgentEventsCache = () => _agentEventsCache.Clear();
if (_nodeService != null)
{
_hubWindow.NodeIsConnected = _nodeService.IsConnected;
@ -2527,6 +2848,7 @@ public partial class App : Application
_hubWindow.NodeShortDeviceId = _nodeService.ShortDeviceId;
_hubWindow.NodeFullDeviceId = _nodeService.FullDeviceId;
}
_hubWindow.VoiceServiceInstance = _nodeService?.VoiceService ?? _standaloneVoiceService;
_hubWindow.SettingsSaved += OnSettingsSaved;
_hubWindow.Closed += (s, e) =>
{
@ -2544,6 +2866,7 @@ public partial class App : Application
_hubWindow.Settings = _settings;
_hubWindow.GatewayClient = _gatewayClient;
_hubWindow.CurrentStatus = _currentStatus;
_hubWindow.VoiceServiceInstance = _nodeService?.VoiceService ?? _standaloneVoiceService;
if (_nodeService != null)
{
_hubWindow.NodeIsConnected = _nodeService.IsConnected;
@ -2560,7 +2883,29 @@ public partial class App : Application
{
_hubWindow.NavigateTo(navigateTo);
}
_hubWindow.Activate();
if (activate)
{
_hubWindow.Activate();
}
else
{
// Show without stealing focus — used by right-click on the
// tray icon where the popup needs to remain the foreground
// window (popups light-dismiss if focus moves away).
// If the Hub was minimized, restore it first so it actually
// becomes visible behind the popup; otherwise Show(false)
// is a no-op on a minimized window.
try
{
if (_hubWindow.AppWindow.Presenter is Microsoft.UI.Windowing.OverlappedPresenter op
&& op.State == Microsoft.UI.Windowing.OverlappedPresenterState.Minimized)
{
op.Restore(activateWindow: false);
}
_hubWindow.AppWindow.Show(activateWindow: false);
}
catch { /* swallow */ }
}
}
private void SeedHubCachedData()
@ -2575,6 +2920,7 @@ public partial class App : Application
if (_lastPresence != null) _hubWindow.UpdatePresence(_lastPresence);
if (_lastGatewaySelf != null) _hubWindow.UpdateGatewaySelf(_lastGatewaySelf);
if (_lastAgentsList.HasValue) _hubWindow.UpdateAgentsList(_lastAgentsList.Value);
if (_agentEventsCache.Count > 0) _hubWindow.SeedAgentEvents(_agentEventsCache);
}
private void ShowSettings()
@ -2731,7 +3077,9 @@ public partial class App : Application
}
Logger.Info("Showing QuickSend dialog");
var dialog = new QuickSendDialog(_gatewayClient, prefillMessage);
// Bug #3: pass a Func that resolves the live _gatewayClient on
// every Send so post-pair / restart / reinit swaps are observed.
var dialog = new QuickSendDialog(() => _gatewayClient, prefillMessage);
dialog.Closed += (s, e) =>
{
if (ReferenceEquals(_quickSendDialog, dialog))
@ -3267,7 +3615,7 @@ public partial class App : Application
try { _onboardingWindow.Activate(); return; } catch { _onboardingWindow = null; }
}
_onboardingWindow = new OnboardingWindow(_settings);
_onboardingWindow = new OnboardingWindow(_settings, IdentityDataPath);
_onboardingWindow.OnboardingCompleted += (s, e) =>
{
Logger.Info("Onboarding completed");
@ -3333,8 +3681,11 @@ public partial class App : Application
#endregion
private void ShowToast(ToastContentBuilder builder)
private void ShowToast(ToastContentBuilder builder, string? toastTag = null, string? deviceId = null)
{
if (!ShouldShowToast(toastTag, deviceId))
return;
var sound = _settings?.NotificationSound;
if (string.Equals(sound, "None", StringComparison.OrdinalIgnoreCase))
{
@ -3347,6 +3698,78 @@ public partial class App : Application
builder.Show();
}
private bool ShouldShowToast(string? toastTag, string? deviceId)
{
if (string.IsNullOrWhiteSpace(toastTag))
return true;
var normalizedDeviceId = NormalizeToastDeviceId(deviceId);
var dedupeKey = BuildToastKey(toastTag, normalizedDeviceId);
var now = DateTime.UtcNow;
foreach (var staleKey in _recentToastKeys
.Where(pair => now - pair.Value >= ToastDedupeWindow)
.Select(pair => pair.Key)
.ToArray())
{
_recentToastKeys.Remove(staleKey);
}
if (_recentToastKeys.TryGetValue(dedupeKey, out var lastShown) &&
now - lastShown < ToastDedupeWindow)
{
Logger.Info($"[ToastDeduper] Suppressed duplicate toast tag={toastTag} deviceId={normalizedDeviceId}");
return false;
}
_recentToastKeys[dedupeKey] = now;
Logger.Info($"[ToastDeduper] Showing toast tag={toastTag} deviceId={normalizedDeviceId}");
return true;
}
private bool HasRecentToast(string toastTag, string? deviceId)
{
var normalizedDeviceId = NormalizeToastDeviceId(deviceId);
return _recentToastKeys.TryGetValue(BuildToastKey(toastTag, normalizedDeviceId), out var lastShown) &&
DateTime.UtcNow - lastShown < ToastDedupeWindow;
}
private static string NormalizeToastDeviceId(string? deviceId) =>
string.IsNullOrWhiteSpace(deviceId) ? "global" : deviceId.Trim();
private static string BuildToastKey(string toastTag, string normalizedDeviceId) =>
$"{toastTag.Trim()}:{normalizedDeviceId}";
private bool TryResolveChatCredentials(
out string gatewayUrl,
out string token,
out string credentialSource)
{
gatewayUrl = string.Empty;
token = string.Empty;
credentialSource = "none";
if (_settings == null)
return false;
gatewayUrl = _settings.GetEffectiveGatewayUrl();
if (string.IsNullOrWhiteSpace(gatewayUrl))
return false;
var identityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json");
var credential = OpenClawTray.Services.GatewayCredentialResolver.Resolve(
_settings.Token,
_settings.BootstrapToken,
identityPath,
msg => Logger.Warn(msg));
if (credential == null)
return false;
token = credential.Token;
credentialSource = credential.Source;
return true;
}
#region Actions
private void OpenDashboard(string? path = null)
@ -3602,8 +4025,6 @@ public partial class App : Application
private void OnGlobalHotkeyPressed(object? sender, EventArgs e)
{
// Hotkey events are raised from a dedicated Win32 message-loop thread.
// Creating/activating WinUI windows must happen on the app's UI thread.
if (_dispatcherQueue == null)
{
Logger.Warn("Hotkey pressed but DispatcherQueue is null");
@ -3617,6 +4038,12 @@ public partial class App : Application
}
}
private void OnVoiceHotkeyPressed(object? sender, EventArgs e)
{
if (_dispatcherQueue == null) return;
_dispatcherQueue.TryEnqueue(() => ShowVoiceOverlay());
}
#endregion
#region Updates
@ -3836,6 +4263,8 @@ public partial class App : Application
OpenDashboard = OpenDashboard,
OpenQuickSend = ShowQuickSend,
OpenHub = (page) => ShowHub(page),
OpenVoice = () => ShowVoiceOverlay(),
StopVoice = () => _ = StopVoiceAsync(),
SendMessage = async (msg) =>
{
if (_gatewayClient != null)
@ -3846,6 +4275,58 @@ public partial class App : Application
});
}
private async Task StopVoiceAsync()
{
var voiceService = _nodeService?.VoiceService;
if (voiceService != null)
await voiceService.StopAsync();
}
private int _ttsMuteCount;
private async Task SpeakResponseAsync(string text)
{
var voiceService = _nodeService?.VoiceService;
var ttsService = _nodeService?.TextToSpeech;
try
{
if (voiceService == null || _settings == null || ttsService == null) return;
// Increment mute counter — multiple concurrent TTS won't unmute prematurely
Interlocked.Increment(ref _ttsMuteCount);
voiceService.IsMutedForPlayback = true;
var speakText = text.Length > 500 ? text[..500] + "..." : text;
// Don't pass VoiceId here. The shared TextToSpeechService picks
// the right per-provider voice from settings (TtsPiperVoiceId,
// TtsWindowsVoiceId, TtsElevenLabsVoiceId). Cross-provider
// voice IDs would otherwise leak across providers.
var speakArgs = new OpenClaw.Shared.Capabilities.TtsSpeakArgs
{
Text = speakText,
Provider = _settings.TtsProvider ?? TtsCapability.PiperProvider,
Interrupt = true
};
await ttsService.SpeakAsync(speakArgs);
}
catch (Exception ex)
{
Logger.Warn($"TTS response playback failed: {ex.Message}");
}
finally
{
// Only unmute when all concurrent TTS operations have finished
if (voiceService != null)
{
await Task.Delay(300);
if (Interlocked.Decrement(ref _ttsMuteCount) <= 0)
voiceService.IsMutedForPlayback = false;
}
}
}
private static void SendDeepLinkToRunningInstance(string uri)
{
try
@ -3953,6 +4434,12 @@ public partial class App : Application
_nodeService = null;
});
SafeShutdownStep("standalone voice service", () =>
{
_standaloneVoiceService?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_standaloneVoiceService = null;
});
SafeShutdownStep("ssh tunnel service", () =>
{
_sshTunnelService?.Dispose();

View File

@ -17,6 +17,10 @@ public sealed partial class SchemaConfigEditor : UserControl
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));
@ -378,7 +382,7 @@ public sealed partial class SchemaConfigEditor : UserControl
private static string GetLabel(string path, string name)
{
var result = Regex.Replace(name, "([a-z])([A-Z])", "$1 $2");
var result = CamelCaseSplitPattern.Replace(name, "$1 $2");
result = result.Replace("_", " ").Replace(".", " \u203A ");
// Title-case the first character
if (result.Length > 0)

View File

@ -0,0 +1,259 @@
using OpenClaw.Shared;
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace OpenClawTray.Dialogs;
// Bug #3 (manual test 2026-05-05): QuickSendDialog used to capture the App's
// gateway client at constructor time into a readonly field. After autopair (or
// any other path that swapped App._gatewayClient — SSH tunnel restart, manual
// ConnectionPage re-pair, onboarding completion), the dialog kept sending into
// the stale instance which still reported NOT_PAIRED, triggering the
// "copy pair command to clipboard" remediation toast against a perfectly
// paired live client.
//
// This file extracts the per-Send logic into a pure, UI-free coordinator that:
// 1. Resolves the live gateway client from a Func<> provider on every Send.
// 2. Defines explicit behavior for null / disposed / swap-window cases.
// 3. Returns a discriminated outcome the dialog renders.
//
// RubberDucky closure conditions #1 (scope), #2 (lifetime contract) and #3
// (genuine-unpaired regression test) are all satisfied by tests over this
// coordinator (see tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs).
/// <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,7 +9,6 @@ using OpenClawTray.Services;
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using WinUIEx;
namespace OpenClawTray.Dialogs;
@ -19,12 +18,21 @@ namespace OpenClawTray.Dialogs;
/// </summary>
public sealed class QuickSendDialog : WindowEx
{
private readonly OpenClawGatewayClient _client;
// Bug #3 (manual test 2026-05-05): resolve the live App._gatewayClient
// on every Send via this provider instead of capturing a single instance
// at construction time. This survives autopair / SSH-tunnel-restart /
// manual-pair / onboarding-completion swaps under the dialog.
private readonly Func<OpenClawGatewayClient?> _clientProvider;
private readonly QuickSendCoordinator _coordinator;
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);
@ -42,18 +50,26 @@ 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(OpenClawGatewayClient client, string? prefillMessage = null)
public QuickSendDialog(Func<OpenClawGatewayClient?> clientProvider, string? prefillMessage = null)
{
_client = client;
_clientProvider = clientProvider ?? throw new ArgumentNullException(nameof(clientProvider));
_coordinator = new QuickSendCoordinator(() =>
{
var live = _clientProvider();
return live == null ? null : new OpenClawGatewayClientAdapter(live);
});
// Window setup
Title = LocalizationHelper.GetString("WindowTitle_QuickSend");
this.SetWindowSize(420, 260);
ExtendsContentIntoTitleBar = true;
this.SetWindowSize(420, 260 + TitleBarHeight);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
@ -62,9 +78,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. Ensure the window is topmost.
// foreground activation restrictions. Keep the existing topmost promotion.
this.IsAlwaysOnTop = true;
// Build UI programmatically (simple dialog)
var root = new Grid
{
@ -130,20 +146,57 @@ public sealed class QuickSendDialog : WindowEx
Grid.SetRow(buttonPanel, 3);
root.Children.Add(buttonPanel);
Content = new Border
var body = new Border
{
Padding = new Thickness(24),
Child = root
};
// Focus the text box when shown
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.
Activated += (s, e) =>
{
TryBringToFront();
RequestInputFocus();
if (e.WindowActivationState != WindowActivationState.Deactivated)
{
TryBringToFront();
RequestInputFocus();
}
};
Closed += (s, e) => Logger.Info("[QuickSend] Dialog closed");
Closed += (s, e) =>
{
_isClosed = true;
Logger.Info("[QuickSend] Dialog closed");
};
Logger.Info($"[QuickSend] Dialog opened (prefill={!string.IsNullOrEmpty(prefillMessage)})");
}
@ -152,6 +205,9 @@ public sealed class QuickSendDialog : WindowEx
{
try
{
if (_isClosed)
return;
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
if (hwnd == IntPtr.Zero) return;
@ -191,64 +247,77 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.Visibility = Visibility.Collapsed;
_errorDetailsTextBox.Text = string.Empty;
this.SetWindowSize(420, 260);
this.SetWindowSize(420, 260 + TitleBarHeight);
_isSending = true;
_sendButton.IsEnabled = false;
_messageTextBox.IsEnabled = false;
ShowDetails(LocalizationHelper.GetString("QuickSend_Sending"));
QuickSendOutcome outcome;
try
{
if (!await EnsureGatewayConnectedAsync())
{
throw new InvalidOperationException("Gateway connection is not open");
}
await _client.SendChatMessageAsync(message);
Logger.Info($"[QuickSend] Message sent ({message.Length} chars)");
new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("QuickSend_ToastTitle"))
.AddText(LocalizationHelper.GetString("QuickSend_ToastBody"))
.Show();
Close();
outcome = await _coordinator.SendAsync(message);
}
catch (Exception ex)
{
Logger.Error($"Quick send failed: {ex.Message}");
if (IsPairingRequired(ex.Message))
{
var commands = _client.BuildPairingApprovalFixCommands();
CopyTextToClipboard(commands);
// Coordinator catches/classifies all expected failures; this is
// a defensive guard against unexpected programmer errors.
Logger.Error($"Quick send coordinator threw: {ex.Message}");
outcome = new QuickSendOutcome.Failed(ex.Message);
}
ShowErrorDetails($"Pairing approval required\n\n{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}");
new ToastContentBuilder()
.AddText("Quick Send device approval required")
.AddText("Gateway reported pairing required. Approval guidance copied to clipboard.")
.Show();
Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{commands}");
}
else if (TryExtractMissingScope(ex.Message, out var missingScope))
{
var commands = _client.BuildMissingScopeFixCommands(missingScope);
CopyTextToClipboard(commands);
Logger.Warn($"[QuickSend] Pairing required. Commands copied to clipboard.\n{pr.Commands}");
break;
ShowErrorDetails($"Missing scope: {missingScope}\n\n{commands}");
case QuickSendOutcome.MissingScope ms:
CopyTextToClipboard(ms.Commands);
ShowErrorDetails($"Missing scope: {ms.Scope}\n\n{ms.Commands}");
new ToastContentBuilder()
.AddText("Quick Send permission required")
.AddText($"Missing scope '{missingScope}'. Identity + remediation guidance copied to clipboard.")
.AddText($"Missing scope '{ms.Scope}'. Identity + remediation guidance copied to clipboard.")
.Show();
Logger.Warn($"[QuickSend] Missing scope '{missingScope}'. Commands copied to clipboard.\n{commands}");
}
else
{
ShowErrorDetails(ex.Message);
}
Logger.Warn($"[QuickSend] Missing scope '{ms.Scope}'. Commands copied to clipboard.\n{ms.Commands}");
break;
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
case QuickSendOutcome.Failed f:
Logger.Error($"Quick send failed: {f.ErrorMessage}");
ShowErrorDetails(f.ErrorMessage);
break;
}
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
}
private void ShowErrorDetails(string details)
@ -257,7 +326,7 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.MinHeight = 140;
_errorDetailsTextBox.Text = details;
_errorDetailsTextBox.Visibility = Visibility.Visible;
this.SetWindowSize(520, 400);
this.SetWindowSize(520, 400 + TitleBarHeight);
// Move focus to the details box so users can immediately select/copy text.
_errorDetailsTextBox.Focus(FocusState.Programmatic);
@ -269,37 +338,7 @@ public sealed class QuickSendDialog : WindowEx
_errorDetailsTextBox.MinHeight = 80;
_errorDetailsTextBox.Text = details;
_errorDetailsTextBox.Visibility = Visibility.Visible;
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);
this.SetWindowSize(500, 320 + TitleBarHeight);
}
private static void CopyTextToClipboard(string text)
@ -311,54 +350,41 @@ public sealed class QuickSendDialog : WindowEx
private void QueueFocusMessageInput()
{
if (_isClosed)
return;
DispatcherQueue?.TryEnqueue(FocusMessageInput);
}
private void RequestInputFocus()
{
QueueFocusMessageInput();
_ = RetryFocusMessageInputAsync();
if (!_focusRetryRunning)
{
_focusRetryRunning = true;
_ = 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
{
await _client.ConnectAsync();
}
catch
{
// Connect errors are handled by the send flow.
}
var started = Environment.TickCount64;
while (Environment.TickCount64 - started < timeoutMs)
{
if (_client.IsConnectedToGateway)
var delaysMs = new[] { 60, 160, 320 };
foreach (var delay in delaysMs)
{
return true;
await Task.Delay(delay);
if (_isClosed)
return;
TryBringToFront();
QueueFocusMessageInput();
}
await Task.Delay(120);
}
return _client.IsConnectedToGateway;
finally
{
_focusRetryRunning = false;
}
}
public void FocusMessageInput()

View File

@ -0,0 +1,195 @@
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

@ -0,0 +1,133 @@
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,6 +20,7 @@ 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");
@ -123,7 +124,37 @@ public sealed class WelcomeDialog : WindowEx
Grid.SetRow(buttonPanel, 2);
root.Children.Add(buttonPanel);
Content = root;
// 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);
Closed += (s, e) => _tcs.TrySetResult(_result);

View File

@ -11,6 +11,48 @@ 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();
@ -346,19 +388,9 @@ internal static class CommandCenterTextHelper
}
}
redacted = Regex.Replace(
redacted,
@"\b[A-Za-z]:\\Users\\[^\\]+",
"%USERPROFILE%",
RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100));
redacted = PathWindowsUserPattern.Replace(redacted, "%USERPROFILE%");
redacted = Regex.Replace(
redacted,
@"/Users/[^/]+",
"$HOME",
RegexOptions.None,
TimeSpan.FromMilliseconds(100));
redacted = PathUnixUserPattern.Replace(redacted, "$HOME");
return redacted;
}
@ -368,47 +400,19 @@ internal static class CommandCenterTextHelper
if (string.IsNullOrWhiteSpace(value))
return "unknown";
var redacted = Regex.Replace(
var redacted = ValueUrlHostPattern.Replace(
value,
@"\b[a-z][a-z0-9+.-]*://(?:[^@\s/]+@)?([^:/\s]+)",
match => match.Value.Replace(match.Groups[1].Value, "<host>"),
RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100));
match => match.Value.Replace(match.Groups[1].Value, "<host>"));
redacted = Regex.Replace(
redacted,
@"\b(?:\d{1,3}\.){3}\d{1,3}\b",
"<ip>",
RegexOptions.None,
TimeSpan.FromMilliseconds(100));
redacted = ValueIpPattern.Replace(redacted, "<ip>");
redacted = Regex.Replace(
redacted,
@"\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b",
"<email>",
RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100));
redacted = ValueEmailPattern.Replace(redacted, "<email>");
redacted = Regex.Replace(
redacted,
@"\b(?<user>[A-Za-z0-9._-]+)@(?<host>[A-Za-z0-9._-]+)(?=[:\s]|$)",
"<user>@<host>",
RegexOptions.None,
TimeSpan.FromMilliseconds(100));
redacted = ValueUserAtHostPattern.Replace(redacted, "<user>@<host>");
redacted = Regex.Replace(
redacted,
@"(?<=\bto\s)[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
"<host>",
RegexOptions.IgnoreCase,
TimeSpan.FromMilliseconds(100));
redacted = ValueHostAfterToPattern.Replace(redacted, "<host>");
redacted = Regex.Replace(
redacted,
@"^\s*[A-Za-z0-9._-]+(?=:\d{1,5}\b)",
"<host>",
RegexOptions.None,
TimeSpan.FromMilliseconds(100));
redacted = ValueLeadingHostPattern.Replace(redacted, "<host>");
return redacted;
}

View File

@ -4,6 +4,7 @@ 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;
@ -20,14 +21,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 Welcome on normal launches).
// OPENCLAW_ONBOARDING_START_ROUTE; defaults to SetupWarning on normal launches).
var pagesInit = Props.GetPageOrder();
var initialIdx = Math.Max(0, Array.IndexOf(pagesInit, Props.CurrentRoute));
var nav = UseNavigation(pagesInit[initialIdx]);
var (pageIndex, setPageIndex) = UseState(initialIdx);
var pages = Props.GetPageOrder();
// Clamp pageIndex if page order changed (e.g., node mode toggled)
// Clamp pageIndex if page order changed (e.g., node mode toggled, SetupPath changed).
if (pageIndex >= pages.Length)
{
setPageIndex(pages.Length - 1);
@ -35,12 +36,19 @@ public sealed class OnboardingApp : Component<OnboardingState>
void GoNext()
{
if (pageIndex < pages.Length - 1)
// Re-derive pages on each call so SetupPath changes (Local vs Advanced) take effect.
var current = Props.GetPageOrder();
if (pageIndex < current.Length - 1)
{
Logger.Info($"[OnboardingApp] Advancing pageIndex {pageIndex}\u2192{pageIndex + 1}, next route={current[pageIndex + 1]}");
setPageIndex(pageIndex + 1);
nav.Navigate(pages[pageIndex + 1]);
nav.Navigate(current[pageIndex + 1]);
Props.NotifyPageChanged();
Props.NotifyRouteChanged(pages[pageIndex + 1]);
Props.NotifyRouteChanged(current[pageIndex + 1]);
}
else
{
Logger.Info($"[OnboardingApp] AdvanceRequested no-op: at last page (pageIndex={pageIndex}, total={current.Length})");
}
}
@ -55,7 +63,70 @@ 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.
@ -67,7 +138,8 @@ public sealed class OnboardingApp : Component<OnboardingState>
// Page content — fixed height prevents nav bar from jumping between pages
(NavigationHost<OnboardingRoute>(nav, route => route switch
{
OnboardingRoute.Welcome => Component<WelcomePage>(),
OnboardingRoute.SetupWarning => Component<SetupWarningPage, OnboardingState>(Props),
OnboardingRoute.LocalSetupProgress => Component<LocalSetupProgressPage, OnboardingState>(Props),
OnboardingRoute.Connection => Component<ConnectionPage, OnboardingState>(Props),
OnboardingRoute.Ready => Component<ReadyPage, OnboardingState>(Props),
OnboardingRoute.Wizard => Component<WizardPage, OnboardingState>(Props),
@ -94,9 +166,11 @@ 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,6 +32,7 @@ public sealed class OnboardingWindow : WindowEx
private readonly FunctionalHostControl _host;
private readonly string? _visualTestDir;
private readonly DispatcherQueue _dispatcherQueue;
private readonly string? _identityDataPath;
private int _captureIndex;
// WebView2 overlay for Chat page
@ -44,17 +45,32 @@ public sealed class OnboardingWindow : WindowEx
private bool _chatWebViewInitialized;
private readonly OnboardingState _state;
private bool _stateDisposed;
// Single-fire guard so the X button (Closed) and the Finish button (state.Complete →
// OnOnboardingFinished → Close → Closed) don't both dispatch completion. Both paths
// route through OnWizardComplete which no-ops after the first call.
private bool _completionDispatched;
public OnboardingWindow(SettingsManager settings)
public OnboardingWindow(SettingsManager settings, string? identityDataPath = null)
{
_settings = settings;
_identityDataPath = identityDataPath;
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_visualTestDir = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") == "1"
? ValidateTestDir(Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR")
?? Path.Combine(Path.GetTempPath(), "openclaw-visual-test"))
: null;
// Optional override for visual tests: render the onboarding UI in a specific locale
// (e.g. "fr-FR", "zh-CN") regardless of system language. Must be set BEFORE the first
// LocalizationHelper.GetString call so the resource context picks it up.
var testLocale = Environment.GetEnvironmentVariable("OPENCLAW_TEST_LOCALE");
if (!string.IsNullOrWhiteSpace(testLocale))
{
LocalizationHelper.SetLanguageOverride(testLocale);
}
Title = LocalizationHelper.GetString("Onboarding_Title");
ExtendsContentIntoTitleBar = true;
this.SetWindowSize(720, 900);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
@ -70,14 +86,36 @@ 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) &&
@ -99,19 +137,50 @@ public sealed class OnboardingWindow : WindowEx
_chatOverlay.Visibility = Visibility.Collapsed;
_chatOverlay.VerticalAlignment = VerticalAlignment.Top;
// Root grid: functional UI host fills everything, overlay sits on top (except nav bar)
// Root grid: titlebar row + content area
_rootGrid = new Grid
{
Background = GetThemeBrush("SolidBackgroundFillColorBaseBrush")
};
_rootGrid.Children.Add(_host);
_rootGrid.Children.Add(_chatOverlay);
_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);
Content = _rootGrid;
Closed += OnClosed;
// Size the overlay after layout — leave space for the nav bar
// Nav bar is ~60px + VStack bottom padding 20px = 80px minimum
_rootGrid.SizeChanged += (_, args) =>
// 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) =>
{
_chatOverlay.Height = Math.Max(0, args.NewSize.Height - 84);
};
@ -454,108 +523,19 @@ public sealed class OnboardingWindow : WindowEx
/// <summary>
/// Auto-sends the bootstrap kickoff message after the web chat loads.
/// Waits for the WebSocket to connect, then injects the message via JS.
/// Matches macOS's maybeKickoffOnboardingChat behavior.
/// Delegates to <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"/>.
/// </summary>
private async Task SendBootstrapMessageAsync()
{
if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return;
_bootstrapSent = true;
const string bootstrapMessage =
"Hi! I just installed OpenClaw and you're my brand-new agent. " +
"Please start the first-run ritual from BOOTSTRAP.md, ask one question at a time, " +
"and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " +
"ask what matters to me and how you should be. Then guide me through choosing " +
"how we should talk (web-only, WhatsApp, or Telegram).";
try
{
// Wait for the web UI to initialize its WebSocket connection
await Task.Delay(3000);
// Inject JS that finds the chat input and sends the bootstrap message.
// The Lit-based UI uses shadow DOM, so we traverse through custom elements.
// SECURITY: Use JsonSerializer to safely encode the message as a JS string literal,
// preventing XSS via template expression injection (${...}), quotes, or backslashes.
var safeMsg = System.Text.Json.JsonSerializer.Serialize(bootstrapMessage);
var js = $$"""
(function() {
const msg = {{safeMsg}};
// Strategy 1: Find textarea/input in the page (may be in shadow DOM)
function findInput(root) {
const inputs = root.querySelectorAll('textarea, input[type="text"]');
for (const input of inputs) {
if (input.offsetParent !== null || input.offsetHeight > 0) return input;
}
// Search shadow DOMs
const elements = root.querySelectorAll('*');
for (const el of elements) {
if (el.shadowRoot) {
const found = findInput(el.shadowRoot);
if (found) return found;
}
}
return null;
}
function findButton(root) {
// Look for send buttons
const buttons = root.querySelectorAll('button');
for (const btn of buttons) {
const text = (btn.textContent || '').toLowerCase();
const label = (btn.getAttribute('aria-label') || '').toLowerCase();
if (text.includes('send') || label.includes('send') ||
btn.querySelector('svg') && btn.closest('form')) {
return btn;
}
}
const elements = root.querySelectorAll('*');
for (const el of elements) {
if (el.shadowRoot) {
const found = findButton(el.shadowRoot);
if (found) return found;
}
}
return null;
}
const input = findInput(document);
if (input) {
// Set value and dispatch events to trigger Lit's data binding
input.value = msg;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
// Try to find and click the send button
setTimeout(() => {
const btn = findButton(document);
if (btn) {
btn.click();
console.log('[OpenClaw] Bootstrap message sent via button click');
} else {
// Try Enter key as fallback
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
}));
console.log('[OpenClaw] Bootstrap message sent via Enter key');
}
}, 200);
} else {
console.warn('[OpenClaw] Could not find chat input for bootstrap');
}
})();
""";
await _chatWebView.CoreWebView2.ExecuteScriptAsync(js);
Logger.Info("[OnboardingChat] Bootstrap message injection executed");
}
catch (Exception ex)
{
Logger.Warn($"[OnboardingChat] Bootstrap injection failed: {ex.Message}");
// Not fatal — user can type manually
}
await BootstrapMessageInjector.InjectAsync(
script => _chatWebView.CoreWebView2.ExecuteScriptAsync(script).AsTask(),
_settings);
}
/// <summary>
@ -604,15 +584,17 @@ public sealed class OnboardingWindow : WindowEx
private void OnOnboardingFinished(object? sender, EventArgs e)
{
_settings.Save();
Completed = true;
_state.GatewayClient = null;
OnboardingCompleted?.Invoke(this, EventArgs.Empty);
OnWizardComplete();
Close();
}
private void OnClosed(object sender, WindowEventArgs args)
{
// X button path: also runs OnWizardComplete (idempotent via _completionDispatched)
// so a user who clicks the title-bar X on the Ready page still gets the chat-window
// launch when a model has been configured, matching the Finish-button behavior.
OnWizardComplete();
if (_stateDisposed) return;
_stateDisposed = true;
_state.Finished -= OnOnboardingFinished;
@ -624,6 +606,82 @@ 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

@ -136,14 +136,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
// Not a valid setup code — user might be still typing.
// Don't call setSetupCode here to avoid re-render that steals focus.
if (code.Length > 2048)
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
else
@ -151,6 +151,8 @@ 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);
@ -159,7 +161,8 @@ public sealed class ConnectionPage : Component<OnboardingState>
if (result.Token != null)
{
setToken(result.Token);
Props.Settings.Token = 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.BootstrapToken = result.Token;
}
setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
@ -205,7 +208,13 @@ public sealed class ConnectionPage : Component<OnboardingState>
async void TestConnection()
{
Props.Settings.GatewayUrl = url;
Props.Settings.Token = token;
// 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;
}
// When SSH mode, start the managed tunnel before health-checking the local URL.
if (mode == ConnectionMode.Ssh)
@ -473,40 +482,14 @@ public sealed class ConnectionPage : Component<OnboardingState>
catch { /* clipboard unavailable — ignore */ }
}
// Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
// Setup code row: TextField + Paste + QR buttons
cardChildren.Add(
Grid(["1*", "Auto", "Auto"], ["Auto"],
TextField(setupCode, OnSetupCodeChanged,
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
.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),
.Grid(row: 0, column: 0)
.Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
.VAlign(VerticalAlignment.Bottom)
.Margin(6, 0, 0, 0)

View File

@ -0,0 +1,417 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Helpers;
using OpenClawTray.Onboarding.Services;
using OpenClawTray.Services;
using OpenClawTray.Services.LocalGatewaySetup;
using static OpenClawTray.FunctionalUI.Factories;
namespace OpenClawTray.Onboarding.Pages;
/// <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,6 +2,7 @@ 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;
@ -16,7 +17,16 @@ public sealed class ReadyPage : Component<OnboardingState>
{
public override Element Render()
{
var (launchAtLogin, setLaunchAtLogin) = UseState(false);
// Safety-default the rendered switch to ON, then sync from persisted settings
// on mount (SettingsManager defaults AutoStart=true for fresh users). The mount
// sync also materializes the Run-key even if the user never touches the switch.
var (launchAtLogin, setLaunchAtLogin) = UseState(true);
UseEffect(() =>
{
var persisted = Props.Settings.AutoStart;
setLaunchAtLogin(persisted);
ApplyLaunchAtLogin(persisted);
}, Props.Settings.AutoStart);
return ScrollView(
VStack(12,
@ -49,7 +59,11 @@ public sealed class ReadyPage : Component<OnboardingState>
// Launch at Login toggle
HStack(8,
ToggleSwitch(launchAtLogin, v => setLaunchAtLogin(v)),
ToggleSwitch(launchAtLogin, v =>
{
setLaunchAtLogin(v);
ApplyLaunchAtLogin(v);
}),
TextBlock(LocalizationHelper.GetString("Onboarding_Ready_LaunchAtLogin"))
.FontSize(13)
.VAlign(VerticalAlignment.Center)
@ -61,6 +75,24 @@ public 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)

View File

@ -0,0 +1,190 @@
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Helpers;
using OpenClawTray.Onboarding.Services;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace OpenClawTray.Onboarding.Pages;
/// <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

@ -1,78 +0,0 @@
using OpenClawTray.FunctionalUI;
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Helpers;
using static OpenClawTray.FunctionalUI.Factories;
using Microsoft.UI.Xaml;
namespace OpenClawTray.Onboarding.Pages;
/// <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,6 +18,13 @@ 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
@ -282,7 +289,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) ||
@ -523,7 +530,7 @@ public sealed class WizardPage : Component<OnboardingState>
if (!string.IsNullOrEmpty(displayMessage))
{
// URL detection — find https:// URLs in the message
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
var urlMatch = UrlInMessagePattern.Match(displayMessage);
if (urlMatch.Success)
{
var detectedUrl = urlMatch.Value;
@ -545,9 +552,7 @@ 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 = 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");
var codeMatch = DeviceCodePattern.Match(displayMessage);
if (codeMatch.Success)
{
var code = codeMatch.Groups[1].Value;
@ -581,7 +586,7 @@ public sealed class WizardPage : Component<OnboardingState>
{
if (!string.IsNullOrEmpty(displayMessage))
{
var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)");
var urlMatch = UrlInMessagePattern.Match(displayMessage);
if (urlMatch.Success)
{
try
@ -639,4 +644,3 @@ public sealed class WizardPage : Component<OnboardingState>
.Padding(0, 8, 0, 0);
}
}

View File

@ -1,4 +1,4 @@
using System;
using OpenClaw.Shared;
namespace OpenClawTray.Onboarding.Services;
@ -10,18 +10,5 @@ public static class LocalGatewayApprover
/// <summary>
/// Checks if the gateway URL points to localhost.
/// </summary>
public static bool IsLocalGateway(string gatewayUrl)
{
if (string.IsNullOrWhiteSpace(gatewayUrl)) return false;
try
{
var uri = new Uri(gatewayUrl);
var host = uri.Host.ToLowerInvariant();
return host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
}
catch
{
return false;
}
}
public static bool IsLocalGateway(string gatewayUrl) => LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl);
}

View File

@ -0,0 +1,50 @@
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

@ -0,0 +1,131 @@
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

@ -0,0 +1,123 @@
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.Welcome;
public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.SetupWarning;
/// <summary>
/// Raised when the current route changes to or from the Chat page.
@ -31,6 +31,55 @@ 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>
@ -62,38 +111,69 @@ 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 based on the selected mode and chat preference,
/// matching the macOS onboarding flow.
/// Returns the page order for the forked Phase-5 onboarding flow.
/// SetupWarning is page 0 in every flow; the user's choice on that page
/// (<see cref="SetupPath"/>) determines whether page 1 is the local-setup
/// progress page or the legacy advanced Connection page.
/// </summary>
public OnboardingRoute[] GetPageOrder()
{
// Node mode: skip Wizard and Chat — node clients can't use operator RPCs
if (Settings.EnableNodeMode)
// Treat null SetupPath as Local for page-count purposes; the nav-bar
// Next button is disabled on SetupWarning until the user picks a path.
var path = SetupPath ?? Onboarding.Services.SetupPath.Local;
// Node mode: skip Wizard and Chat — remote-node clients can't use operator RPCs.
// Exception (Bug #1, manual test 2026-05-05): Local easy-setup pairs the tray
// as BOTH operator (Phase 12) AND node (Phase 14) on the loopback gateway it
// just stood up. Even though PairAsync flips EnableNodeMode=true mid-onboarding
// (LocalGatewaySetup.cs:2147), the tray still has operator credentials and the
// Wizard hop's wizard.start RPC works. Only skip Wizard for explicit Advanced
// remote-node deployments.
if (Settings.EnableNodeMode && path != Onboarding.Services.SetupPath.Local)
{
return Mode switch
{
ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Remote or ConnectionMode.Ssh =>
[OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
_ => // Later or unknown
[OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
};
return [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready];
}
return (Mode, ShowChat) switch
if (path == Onboarding.Services.SetupPath.Local)
{
// Local-style flows (Local, WSL, SSH tunnel) all run wizard locally
(ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready],
(ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready],
(ConnectionMode.Remote, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready],
(ConnectionMode.Remote, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
(ConnectionMode.Later, _) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
_ => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready],
// Local setup always runs the wizard locally after the gateway is up.
// The WebView2 chat-preview step was removed per UX update (PR #274 follow-up):
// post-Permissions we go straight to Ready, then optionally launch the Hub
// chat tab from OnboardingWindow.OnWizardComplete based on whether the
// wizard reached its "complete" lifecycle state (i.e. user picked a model).
return [OnboardingRoute.SetupWarning, OnboardingRoute.LocalSetupProgress, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready];
}
// Advanced path: keep the legacy ConnectionMode-aware ordering.
// ShowChat (the in-wizard WebView2 chat preview) is intentionally not consulted
// anymore — the preview step has been removed from every flow.
return Mode switch
{
ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready],
ConnectionMode.Remote => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready],
ConnectionMode.Later => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready],
_ => [OnboardingRoute.SetupWarning, OnboardingRoute.Connection, OnboardingRoute.Ready],
};
}
@ -130,10 +210,41 @@ public enum ConnectionMode
public enum OnboardingRoute
{
Welcome,
SetupWarning,
LocalSetupProgress,
Connection,
Wizard,
Permissions,
Chat,
Ready,
}
/// <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

@ -0,0 +1,215 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Shared;
using OpenClawTray.Services;
namespace OpenClawTray.Onboarding.Services;
/// <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

@ -0,0 +1,49 @@
namespace OpenClawTray.Onboarding.Services;
public static class WizardStepSelection
{
public static bool RequiresSelection(string stepType) => stepType is "select" or "multiselect";
public static int SelectedIndex(string stepInput, IReadOnlyList<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

@ -49,17 +49,20 @@
<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.26100.4654" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<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

@ -58,7 +58,12 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Title}" FontWeight="SemiBold"/>
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Icon}" FontSize="11" Margin="5,0,0,0"
VerticalAlignment="Center"
Visibility="{Binding IconVisibility}"/>
</StackPanel>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding TimeAgo}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>

View File

@ -96,6 +96,8 @@ public sealed partial class ActivityPage : Page
return new ActivityViewModel
{
Title = item.Title,
Icon = item.Icon,
IconVisibility = string.IsNullOrWhiteSpace(item.Icon) ? Visibility.Collapsed : Visibility.Visible,
Category = item.Category,
TimeAgo = GetTimeAgo(item.Timestamp),
DetailText = detailText,
@ -135,6 +137,8 @@ public sealed partial class ActivityPage : Page
private class ActivityViewModel
{
public string Title { get; set; } = "";
public string Icon { get; set; } = "";
public Visibility IconVisibility { get; set; }
public string Category { get; set; } = "";
public string TimeAgo { get; set; } = "";
public string DetailText { get; set; } = "";

View File

@ -48,10 +48,19 @@
<ListView x:Name="EventsList" Grid.Row="2"
SelectionMode="None"
Visibility="Collapsed"
IsItemClickEnabled="True"
ItemClick="EventsList_ItemClick"
ContainerContentChanging="EventsList_ContainerContentChanging">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0,1"/>
<Setter Property="Margin" Value="0"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate>
<Grid Padding="12,8" Margin="0,1" CornerRadius="4"
<Grid Padding="16,10,16,12" Margin="0,2" CornerRadius="6"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
@ -59,31 +68,47 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Row 0: stream badge + timestamp + session -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Border CornerRadius="4" Padding="6,2">
<TextBlock Text="{Binding StreamUpper}" FontSize="11" FontWeight="SemiBold"
Foreground="White"/>
<!-- Row 0: badge + timestamp + chevron -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" CornerRadius="4" Padding="8,2,8,4" VerticalAlignment="Center">
<TextBlock Text="{Binding StreamUpper}" FontSize="10" FontWeight="SemiBold"
Foreground="White" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding FormattedTime}" VerticalAlignment="Center"
FontSize="11" FontFamily="Consolas"
<TextBlock Grid.Column="1" Text="{Binding FormattedTime}" VerticalAlignment="Center"
FontSize="11" FontFamily="Consolas" Margin="8,0,0,0"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
<TextBlock Text="{Binding RunId}" VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"
MaxWidth="120" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<FontIcon Grid.Column="3" Glyph="&#xE70D;" FontSize="10" VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
</Grid>
<!-- Row 1: Summary line -->
<TextBlock Grid.Row="1" Text="{Binding SummaryLine}"
FontWeight="SemiBold" FontSize="12"
Margin="0,4,0,0" TextTrimming="CharacterEllipsis"/>
FontSize="13" TextWrapping="Wrap" MaxLines="3"
Margin="0,6,0,0"/>
<!-- Row 2: Data JSON (collapsed by default, expandable) -->
<TextBlock Grid.Row="2" Text="{Binding DataJson}" TextWrapping="Wrap"
MaxLines="6" FontFamily="Consolas" FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Margin="0,4,0,0" IsTextSelectionEnabled="True"/>
<!-- Row 2: Expandable detail (hidden by default) -->
<Grid Grid.Row="2" Visibility="Collapsed" Margin="0,8,0,0">
<!-- Assistant: full text, same formatting as summary -->
<TextBlock TextWrapping="Wrap" FontSize="13"
IsTextSelectionEnabled="True" Visibility="Collapsed"/>
<!-- Non-assistant: RunId + JSON -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding RunId}"
FontSize="11" FontFamily="Consolas"
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
<TextBlock Text="{Binding DataJson}" TextWrapping="Wrap"
FontFamily="Consolas" FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True"/>
</StackPanel>
</Grid>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>

View File

@ -60,6 +60,19 @@ public sealed partial class AgentEventsPage : Page
public void AddEvent(AgentEventInfo evt)
{
// Deduplicate by RunId + Seq
if (_allEvents.Any(e => e.RunId == evt.RunId && e.Seq == evt.Seq))
return;
// For assistant events, replace earlier streaming chunks with the latest one
// (each chunk contains the full accumulated text, so only the latest matters)
if (evt.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase))
{
_allEvents.RemoveAll(e =>
e.Stream.Equals("assistant", StringComparison.OrdinalIgnoreCase) &&
e.RunId == evt.RunId && e.Seq < evt.Seq);
}
_allEvents.Insert(0, evt);
if (_allEvents.Count > MaxEvents)
_allEvents.RemoveRange(MaxEvents, _allEvents.Count - MaxEvents);
@ -100,9 +113,9 @@ public sealed partial class AgentEventsPage : Page
filtered = filtered.Where(e => e.SessionKey != null &&
e.SessionKey.StartsWith($"agent:{_agentIdFilter}:", StringComparison.OrdinalIgnoreCase));
// Filter by stream type
// Filter by stream type (use ResolvedStream so "item" events with kind:"tool" match the Tool filter)
if (_activeFilter != "all")
filtered = filtered.Where(e => e.Stream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase));
filtered = filtered.Where(e => e.ResolvedStream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase));
var list = filtered.ToList();
EventsList.ItemsSource = list;
@ -121,30 +134,96 @@ public sealed partial class AgentEventsPage : Page
private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerContentChangingEventArgs args)
{
if (args.Item is AgentEventInfo evt && args.ItemContainer?.ContentTemplateRoot is Grid grid)
if (args.Item is not AgentEventInfo evt || args.ItemContainer?.ContentTemplateRoot is not Grid grid)
return;
// Row 0: header Grid with badge (col 0), timestamp (col 1), chevron (col 3)
if (grid.Children[0] is Grid headerGrid && headerGrid.Children[0] is Border badge)
{
// Find the first Border in the first StackPanel (the badge)
if (grid.Children[0] is StackPanel headerPanel && headerPanel.Children[0] is Border badge)
var hex = evt.BadgeColorHex;
try
{
var hex = evt.BadgeColorHex;
try
{
var a = Convert.ToByte(hex[1..3], 16);
var r = Convert.ToByte(hex[3..5], 16);
var g = Convert.ToByte(hex[5..7], 16);
var b = Convert.ToByte(hex[7..9], 16);
badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
Microsoft.UI.ColorHelper.FromArgb(a, r, g, b));
}
catch
{
badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray);
}
var r = Convert.ToByte(hex[3..5], 16);
var g = Convert.ToByte(hex[5..7], 16);
var b = Convert.ToByte(hex[7..9], 16);
var color = Microsoft.UI.ColorHelper.FromArgb(255, r, g, b);
badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
Microsoft.UI.ColorHelper.FromArgb(40, r, g, b));
if (badge.Child is TextBlock badgeText)
badgeText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(color);
}
// Hide summary row if empty
if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
catch
{
summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed;
badge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
Microsoft.UI.ColorHelper.FromArgb(40, 100, 100, 100));
}
// Update chevron glyph based on model state
if (headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron)
chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D";
}
// Row 1: summary
if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
{
summaryBlock.Visibility = evt.HasSummary ? Visibility.Visible : Visibility.Collapsed;
if (evt.IsAssistantStream)
{
// Swap between truncated summary and full text
summaryBlock.Text = evt.IsExpanded ? (evt.FullAssistantText ?? evt.SummaryLine) : evt.SummaryLine;
summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
}
else
{
summaryBlock.Text = evt.SummaryLine;
summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
}
}
// Row 2: detail panel — only for streams that still need raw JSON
if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid)
{
if (!evt.ShowDataJson)
{
// Assistant/error/lifecycle events show enough context in the summary row.
detailGrid.Visibility = Visibility.Collapsed;
}
else
{
detailGrid.Visibility = evt.IsExpanded ? Visibility.Visible : Visibility.Collapsed;
}
}
}
private void EventsList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not AgentEventInfo evt) return;
evt.IsExpanded = !evt.IsExpanded;
// Update the visual container
if (sender is ListView listView)
{
var container = listView.ContainerFromItem(e.ClickedItem) as ListViewItem;
if (container?.ContentTemplateRoot is Grid grid)
{
// Update chevron
if (grid.Children[0] is Grid headerGrid
&& headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron)
chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D";
// Update summary text and MaxLines
if (grid.Children.Count > 1 && grid.Children[1] is TextBlock summaryBlock)
{
summaryBlock.Text = evt.IsAssistantStream && evt.IsExpanded
? (evt.FullAssistantText ?? evt.SummaryLine)
: evt.SummaryLine;
summaryBlock.MaxLines = evt.IsExpanded ? 0 : 3;
}
// Toggle detail panel only for streams where raw JSON is still useful.
if (grid.Children.Count > 2 && grid.Children[2] is Grid detailGrid)
detailGrid.Visibility = (evt.IsExpanded && evt.ShowDataJson)
? Visibility.Visible : Visibility.Collapsed;
}
}
}

View File

@ -25,6 +25,70 @@
</ItemsRepeater.Layout>
</ItemsRepeater>
<!-- Speech-to-Text settings (visible only when STT is enabled) -->
<Border x:Name="SttCard" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16" Margin="0,8,0,0"
Visibility="Collapsed">
<StackPanel Spacing="8">
<TextBlock x:Uid="CapabilitiesPage_SttCardHeader" Text="🎤 Speech-to-Text" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<TextBlock x:Uid="CapabilitiesPage_SttCardDescription"
Text="Recognizes microphone audio locally on this PC using Whisper. The model is downloaded once on first use."
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"/>
<TextBlock x:Name="SttEngineHint"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"/>
<HyperlinkButton x:Name="SttMoreSettingsLink"
x:Uid="CapabilitiesPage_SttMoreSettingsLink"
Content="More voice settings…"
Click="OnSttMoreSettingsClick"
Padding="0"/>
</StackPanel>
</Border>
<!-- Text-to-Speech settings (visible only when TTS is enabled) -->
<Border x:Name="TtsCard" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1" CornerRadius="8" Padding="16" Margin="0,8,0,0"
Visibility="Collapsed">
<StackPanel Spacing="8">
<TextBlock x:Uid="CapabilitiesPage_TtsCardHeader" Text="🔊 Text-to-Speech" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<TextBlock x:Uid="CapabilitiesPage_TtsCardDescription"
Text="Default provider for tts.speak. Per-call provider/voiceId/model overrides this default."
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"/>
<ComboBox x:Name="TtsProviderComboBox" x:Uid="CapabilitiesPage_TtsProviderComboBox" Header="Provider"
SelectionChanged="OnTtsProviderSelectionChanged">
<ComboBoxItem x:Uid="CapabilitiesPage_TtsProviderPiper" Content="Piper (local ML, recommended)" Tag="piper"/>
<ComboBoxItem x:Uid="CapabilitiesPage_TtsProviderWindows" Content="Windows built-in speech" Tag="windows"/>
<ComboBoxItem x:Uid="CapabilitiesPage_TtsProviderElevenLabs" Content="ElevenLabs" Tag="elevenlabs"/>
</ComboBox>
<StackPanel x:Name="TtsElevenLabsPanel" Spacing="6" Visibility="Collapsed">
<PasswordBox x:Name="TtsElevenLabsApiKeyBox"
x:Uid="CapabilitiesPage_TtsElevenLabsApiKey"
Header="ElevenLabs API key"
LostFocus="OnTtsElevenLabsCommitted"/>
<TextBox x:Name="TtsElevenLabsVoiceIdBox"
x:Uid="CapabilitiesPage_TtsElevenLabsVoiceId"
Header="ElevenLabs voice ID"
LostFocus="OnTtsElevenLabsCommitted"/>
<TextBox x:Name="TtsElevenLabsModelBox"
x:Uid="CapabilitiesPage_TtsElevenLabsModel"
Header="ElevenLabs model"
PlaceholderText="eleven_multilingual_v2"
LostFocus="OnTtsElevenLabsCommitted"/>
<TextBlock x:Uid="CapabilitiesPage_TtsElevenLabsHelp"
Text="API key is encrypted at rest with Windows DPAPI. Leave blank to keep the previously saved value when you change other fields."
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock x:Name="TtsStatusText"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</Border>
<!-- MCP Server -->
<Border x:Name="McpCard" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"

View File

@ -1,11 +1,14 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using OpenClaw.Shared;
using OpenClaw.Shared.Capabilities;
using OpenClawTray.Services;
using OpenClawTray.Windows;
using System;
using System.Collections.Generic;
using Windows.ApplicationModel.DataTransfer;
using Windows.System;
namespace OpenClawTray.Pages;
@ -13,6 +16,12 @@ public sealed partial class CapabilitiesPage : Page
{
private HubWindow? _hub;
private bool _suppressMcpToggle;
private bool _suppressTtsProviderChange;
// Sentinel rendered into the API key PasswordBox so the user can see
// that a key is already saved without us ever surfacing the plaintext.
// Saving the form treats this exact value as "keep current key".
private const string SavedApiKeySentinel = "••••••••";
public CapabilitiesPage()
{
@ -26,6 +35,8 @@ public sealed partial class CapabilitiesPage : Page
BuildCapabilityToggles(hub);
UpdateMcpStatus(hub);
UpdateSttCard(hub);
UpdateTtsCard(hub);
UpdateNodeStatus(hub);
}
@ -43,6 +54,7 @@ public sealed partial class CapabilitiesPage : Page
("🖥️", "Screen Capture", settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v),
("📍", "Location", settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v),
("🔊", "Text-to-Speech", settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v),
("🎤", "Speech-to-Text", settings.NodeSttEnabled, v => settings.NodeSttEnabled = v),
};
var items = new List<UIElement>();
@ -59,6 +71,8 @@ public sealed partial class CapabilitiesPage : Page
setter(toggle.IsOn);
settings.Save();
hub.RaiseSettingsSaved();
UpdateSttCard(hub);
UpdateTtsCard(hub);
UpdateNodeStatus(hub);
};
items.Add(toggle);
@ -67,6 +81,162 @@ public sealed partial class CapabilitiesPage : Page
CapabilityRepeater.ItemsSource = items;
}
// ============================================================
// Speech-to-Text settings card
// ============================================================
private void UpdateSttCard(HubWindow hub)
{
var enabled = hub.Settings?.NodeSttEnabled == true;
SttCard.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;
if (!enabled || hub.Settings == null) return;
UpdateSttEngineHint(hub);
}
private void UpdateSttEngineHint(HubWindow hub)
{
// Whisper is the only engine. Surface model-readiness so the user
// knows what (if anything) needs to happen before stt.* will work.
//
// Check the file directly via WhisperModelManager rather than going
// through hub.VoiceServiceInstance — that instance is only created
// by NodeService.RegisterCapabilities() at Connect time, so a user
// who toggled STT on but hasn't reconnected yet would see a stale
// "not downloaded" message even with the file on disk.
var modelName = hub.Settings?.SttModelName ?? "base";
var modelManager = new OpenClaw.Shared.Audio.WhisperModelManager(
SettingsManager.SettingsDirectoryPath, new AppLogger());
var modelDownloaded = modelManager.IsModelDownloaded(modelName);
var modelDownloading = hub.VoiceServiceInstance?.IsWhisperDownloadingModel ?? false;
if (modelDownloaded)
{
SttEngineHint.Text = "Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.";
}
else if (modelDownloading)
{
SttEngineHint.Text = "Whisper model is downloading. Speech-to-text will be available once it's ready.";
}
else
{
SttEngineHint.Text = "Whisper model is not downloaded. Open More voice settings… to download it before using speech-to-text.";
}
}
private void OnSttMoreSettingsClick(object sender, RoutedEventArgs e)
{
// Navigate the Hub to the dedicated voice settings page.
_hub?.NavigateTo("voice");
}
// ============================================================
// Text-to-Speech settings card
// ============================================================
private void UpdateTtsCard(HubWindow hub)
{
var enabled = hub.Settings?.NodeTtsEnabled == true;
TtsCard.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;
if (!enabled || hub.Settings == null) return;
var settings = hub.Settings;
_suppressTtsProviderChange = true;
// ComboBox order: 0=Piper, 1=Windows, 2=ElevenLabs.
TtsProviderComboBox.SelectedIndex = settings.TtsProvider switch
{
var p when string.Equals(p, TtsCapability.ElevenLabsProvider, StringComparison.OrdinalIgnoreCase) => 2,
var p when string.Equals(p, TtsCapability.WindowsProvider, StringComparison.OrdinalIgnoreCase) => 1,
_ => 0 // default to Piper for unknown / null / whitespace
};
_suppressTtsProviderChange = false;
// PasswordBox shows a masked sentinel when we already have a saved
// key, so the user can tell something is set without us ever
// putting plaintext on screen.
TtsElevenLabsApiKeyBox.Password =
string.IsNullOrEmpty(settings.TtsElevenLabsApiKey) ? "" : SavedApiKeySentinel;
TtsElevenLabsVoiceIdBox.Text = settings.TtsElevenLabsVoiceId;
TtsElevenLabsModelBox.Text = settings.TtsElevenLabsModel;
UpdateTtsElevenLabsPanelVisibility();
TtsStatusText.Text = "";
}
private void UpdateTtsElevenLabsPanelVisibility()
{
var isEleven = (TtsProviderComboBox.SelectedItem is ComboBoxItem item)
&& string.Equals(item.Tag as string, TtsCapability.ElevenLabsProvider, StringComparison.OrdinalIgnoreCase);
TtsElevenLabsPanel.Visibility = isEleven ? Visibility.Visible : Visibility.Collapsed;
}
private void OnTtsProviderSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressTtsProviderChange) return;
if (_hub?.Settings == null) return;
var newProvider = (TtsProviderComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag)
? tag
: TtsCapability.WindowsProvider;
if (!string.Equals(_hub.Settings.TtsProvider, newProvider, StringComparison.OrdinalIgnoreCase))
{
_hub.Settings.TtsProvider = newProvider;
_hub.Settings.Save();
_hub.RaiseSettingsSaved();
TtsStatusText.Text = $"Default provider: {newProvider}";
}
UpdateTtsElevenLabsPanelVisibility();
}
private void OnTtsElevenLabsCommitted(object sender, RoutedEventArgs e)
{
if (_hub?.Settings == null) return;
var settings = _hub.Settings;
var changed = false;
// Treat the sentinel as "keep existing"; only overwrite when the
// user has typed a real key.
var typedKey = TtsElevenLabsApiKeyBox.Password ?? "";
if (!string.Equals(typedKey, SavedApiKeySentinel, StringComparison.Ordinal))
{
var trimmedKey = typedKey.Trim();
if (!string.Equals(settings.TtsElevenLabsApiKey, trimmedKey, StringComparison.Ordinal))
{
settings.TtsElevenLabsApiKey = trimmedKey;
changed = true;
}
}
var voiceId = TtsElevenLabsVoiceIdBox.Text?.Trim() ?? "";
if (!string.Equals(settings.TtsElevenLabsVoiceId, voiceId, StringComparison.Ordinal))
{
settings.TtsElevenLabsVoiceId = voiceId;
changed = true;
}
var model = TtsElevenLabsModelBox.Text?.Trim() ?? "";
if (!string.Equals(settings.TtsElevenLabsModel, model, StringComparison.Ordinal))
{
settings.TtsElevenLabsModel = model;
changed = true;
}
if (changed)
{
settings.Save();
_hub.RaiseSettingsSaved();
// Re-render the API key field so the sentinel tracks the newly
// saved state instead of leaving the typed key visible.
TtsElevenLabsApiKeyBox.Password =
string.IsNullOrEmpty(settings.TtsElevenLabsApiKey) ? "" : SavedApiKeySentinel;
TtsStatusText.Text = "ElevenLabs settings saved.";
}
}
private void UpdateNodeStatus(HubWindow hub)
{
var nodeEnabled = hub.Settings?.EnableNodeMode ?? false;
@ -90,6 +260,7 @@ public sealed partial class CapabilitiesPage : Page
if (hub.Settings?.NodeScreenEnabled == true) caps.Add("screen");
if (hub.Settings?.NodeLocationEnabled == true) caps.Add("location");
if (hub.Settings?.NodeTtsEnabled == true) caps.Add("tts");
if (hub.Settings?.NodeSttEnabled == true) caps.Add("stt");
NodeDetailsText.Text = caps.Count > 0
? $"Providing {caps.Count} capabilities: {string.Join(", ", caps)}"
: "No capabilities enabled.";

View File

@ -17,14 +17,6 @@ public sealed partial class ChannelsPage : Page
public ChannelsPage()
{
InitializeComponent();
// Sample data for design-time preview
var samples = new List<ChannelViewModel>
{
new() { Name = "Telegram", Status = "connected", StatusColor = "Green", IsRunning = true, ProbeInfo = "Bot: @myclaw_bot · 45ms" },
new() { Name = "WhatsApp", Status = "disconnected", StatusColor = "Red", IsRunning = false, ProbeInfo = null },
};
RenderChannels(samples);
}
public void Initialize(HubWindow hub)

View File

@ -97,6 +97,8 @@ public sealed partial class ChatPage : Page
document.head.appendChild(style);
})();
");
BootstrapMessageInjector.ScriptExecutor exec = script => WebView.CoreWebView2.ExecuteScriptAsync(script).AsTask();
_ = BootstrapMessageInjector.InjectAsync(exec, ((App)Application.Current).Settings, initialDelayMs: 500);
}
else if (e.WebErrorStatus == CoreWebView2WebErrorStatus.ConnectionAborted ||
e.WebErrorStatus == CoreWebView2WebErrorStatus.CannotConnect ||

View File

@ -100,7 +100,7 @@
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<PasswordBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
<TextBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
PlaceholderText="Gateway token" Header="Token"/>
<Button x:Uid="ConnectionPage_Button_105" Grid.Column="1" Content="Cancel"
VerticalAlignment="Bottom" Click="OnCancelTokenPrompt"/>

View File

@ -473,14 +473,14 @@ public sealed partial class ConnectionPage : Page
_pendingGatewayUrl = gw.ConnectionUrl;
_pendingGatewayId = gw.Id;
TokenPromptText.Text = $"Connect to gateway at {gw.Host}:{gw.Port}";
TokenPromptBox.Password = _hub.Settings.Token ?? "";
TokenPromptBox.Text = _hub.Settings.Token ?? "";
TokenPromptPanel.Visibility = Visibility.Visible;
TokenPromptBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
}
private void OnConnectWithToken(object sender, RoutedEventArgs e)
{
var token = TokenPromptBox.Password?.Trim();
var token = TokenPromptBox.Text?.Trim();
if (string.IsNullOrEmpty(token) || _hub?.Settings == null || string.IsNullOrEmpty(_pendingGatewayUrl))
return;
@ -535,10 +535,9 @@ public sealed partial class ConnectionPage : Page
settings.GatewayUrl = result.Url;
if (!string.IsNullOrEmpty(result.Token))
{
// Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
// Don't save it as Settings.Token, which would cause reconnect storms on restart.
settings.BootstrapToken = result.Token;
// Also set as the operator token so InitializeGatewayClient can connect
if (string.IsNullOrWhiteSpace(settings.Token))
settings.Token = result.Token;
}
settings.Save();

View File

@ -9,13 +9,6 @@
<TextBlock x:Uid="CronPage_TextBlock_10" Text="⏱️ Cron Jobs" Style="{StaticResource TitleTextBlockStyle}"/>
<!-- Not-wired InfoBar -->
<InfoBar x:Uid="NotWiredInfoBar" x:Name="NotWiredInfoBar"
IsOpen="True" IsClosable="False"
Severity="Warning"
Title="Cron API not yet wired"
Message="Showing sample data. Cron management methods are not yet available in OpenClawGatewayClient."/>
<!-- Scheduler Status Card -->
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
@ -29,11 +22,11 @@
<TextBlock x:Uid="CronPage_TextBlock_29" Text="Scheduler Status" Style="{StaticResource BodyStrongTextBlockStyle}"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse x:Name="SchedulerStatusIndicator" Width="10" Height="10"
Fill="LimeGreen" VerticalAlignment="Center"/>
<TextBlock x:Uid="SchedulerStatusText" x:Name="SchedulerStatusText" Text="Enabled"
Fill="Gray" VerticalAlignment="Center"/>
<TextBlock x:Uid="SchedulerStatusText" x:Name="SchedulerStatusText" Text=""
VerticalAlignment="Center"/>
</StackPanel>
<TextBlock x:Uid="StorePathText" x:Name="StorePathText" Text="Store: ~/.openclaw/cron.db"
<TextBlock x:Uid="StorePathText" x:Name="StorePathText" Text="Store: "
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Uid="NextWakeText" x:Name="NextWakeText" Text="Next wake: —"
@ -41,7 +34,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<ToggleSwitch x:Name="SchedulerToggle" Grid.Column="1"
IsOn="True" VerticalAlignment="Center"
IsOn="False" VerticalAlignment="Center"
IsEnabled="False"/>
</Grid>
</Border>
@ -116,7 +109,7 @@
</ListView>
<!-- Empty State -->
<StackPanel x:Name="EmptyState" Visibility="Collapsed"
<StackPanel x:Name="EmptyState" Visibility="Visible"
VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="8"
Margin="0,32,0,0">
<TextBlock Text="⏱️" FontSize="48" HorizontalAlignment="Center"/>

View File

@ -12,7 +12,6 @@ namespace OpenClawTray.Pages;
public sealed partial class CronPage : Page
{
private HubWindow? _hub;
private bool _hasLiveData;
public CronPage()
{
@ -24,57 +23,9 @@ public sealed partial class CronPage : Page
_hub = hub;
if (hub.GatewayClient != null)
{
NotWiredInfoBar.IsOpen = false;
_ = hub.GatewayClient.RequestCronListAsync();
_ = hub.GatewayClient.RequestCronStatusAsync();
}
LoadSampleJobs();
}
private void LoadSampleJobs()
{
if (_hasLiveData) return;
var jobs = new List<CronJobViewModel>
{
new()
{
Id = "sample-1",
Name = "Daily email summary",
Schedule = "0 9 * * *",
IsEnabled = true,
LastRunTime = DateTime.Now.AddHours(-3).ToString("yyyy-MM-dd HH:mm"),
LastResult = "success",
ResultBadgeBackground = new SolidColorBrush(Colors.Green),
NextRunTime = DateTime.Now.AddHours(21).ToString("yyyy-MM-dd HH:mm"),
},
new()
{
Id = "sample-2",
Name = "Backup config",
Schedule = "0 0 * * 0",
IsEnabled = true,
LastRunTime = DateTime.Now.AddDays(-3).ToString("yyyy-MM-dd HH:mm"),
LastResult = "success",
ResultBadgeBackground = new SolidColorBrush(Colors.Green),
NextRunTime = DateTime.Now.AddDays(4).ToString("yyyy-MM-dd HH:mm"),
},
new()
{
Id = "sample-3",
Name = "Health check",
Schedule = "*/15 * * * *",
IsEnabled = true,
LastRunTime = DateTime.Now.AddMinutes(-7).ToString("yyyy-MM-dd HH:mm"),
LastResult = "fail",
ResultBadgeBackground = new SolidColorBrush(Colors.Red),
NextRunTime = DateTime.Now.AddMinutes(8).ToString("yyyy-MM-dd HH:mm"),
},
};
JobsList.ItemsSource = jobs;
JobsList.Visibility = Visibility.Visible;
EmptyState.Visibility = Visibility.Collapsed;
}
private void OnRunNowClick(object sender, RoutedEventArgs e)
@ -167,9 +118,6 @@ public sealed partial class CronPage : Page
DispatcherQueue?.TryEnqueue(() =>
{
_hasLiveData = true;
NotWiredInfoBar.IsOpen = false;
if (jobs.Count > 0)
{
JobsList.ItemsSource = jobs;
@ -205,7 +153,6 @@ public sealed partial class CronPage : Page
DispatcherQueue?.TryEnqueue(() =>
{
NotWiredInfoBar.IsOpen = false;
SchedulerToggle.IsOn = enabled;
SchedulerStatusText.Text = enabled ? "Enabled" : "Disabled";
SchedulerStatusIndicator.Fill = new SolidColorBrush(enabled ? Colors.LimeGreen : Colors.Gray);

View File

@ -113,6 +113,7 @@
<Button x:Uid="DebugPage_Button_113" Content="📁 Open Diagnostics Folder" Click="OnOpenDiagnosticsFolder" HorizontalAlignment="Stretch"/>
<Button x:Uid="DebugPage_Button_114" Content="📋 Copy Support Context" Click="OnCopySupportContext"
Style="{ThemeResource AccentButtonStyle}" HorizontalAlignment="Stretch"/>
<Button Content="🔄 Relaunch First-Run Setup" Click="OnRelaunchOnboarding" HorizontalAlignment="Stretch"/>
</StackPanel>
</Expander>

View File

@ -196,4 +196,9 @@ public sealed partial class DebugPage : Page
timer.Start();
}
}
private void OnRelaunchOnboarding(object sender, RoutedEventArgs e)
{
_hub?.OpenSetupAction?.Invoke();
}
}

View File

@ -19,14 +19,14 @@
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Molty mascot with status ring -->
<!-- Companion mascot with status ring -->
<Grid Grid.Column="0" Width="72" Height="72" VerticalAlignment="Center">
<!-- Colored ring (Ellipse behind emoji) -->
<Ellipse x:Name="MoltyRing" Width="68" Height="68"
<Ellipse x:Name="CompanionRing" Width="68" Height="68"
StrokeThickness="3" Fill="Transparent"
Stroke="{ThemeResource TextFillColorDisabledBrush}"/>
<!-- Animated ProgressRing for "working" state -->
<ProgressRing x:Name="MoltyProgressRing" Width="72" Height="72"
<ProgressRing x:Name="CompanionProgressRing" Width="72" Height="72"
IsActive="False" Visibility="Collapsed"/>
<!-- Lobster emoji -->
<TextBlock Text="🦞" FontSize="36"

View File

@ -59,12 +59,12 @@ public sealed partial class HomePage : Page
if (!string.IsNullOrEmpty(gatewayUrl))
GatewayUrlText.Text = gatewayUrl;
UpdateMoltyRing(status);
UpdateCompanionRing(status);
UpdateStatusText(status);
});
}
private void UpdateMoltyRing(ConnectionStatus status)
private void UpdateCompanionRing(ConnectionStatus status)
{
bool hasActiveSessions = _lastSessions?.Any(s =>
string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)) ?? false;
@ -72,17 +72,17 @@ public sealed partial class HomePage : Page
if (status == ConnectionStatus.Connected && hasActiveSessions)
{
// Agent working — animated blue ring
MoltyRing.Visibility = Visibility.Collapsed;
MoltyProgressRing.IsActive = true;
MoltyProgressRing.Visibility = Visibility.Visible;
CompanionRing.Visibility = Visibility.Collapsed;
CompanionProgressRing.IsActive = true;
CompanionProgressRing.Visibility = Visibility.Visible;
}
else
{
MoltyProgressRing.IsActive = false;
MoltyProgressRing.Visibility = Visibility.Collapsed;
MoltyRing.Visibility = Visibility.Visible;
CompanionProgressRing.IsActive = false;
CompanionProgressRing.Visibility = Visibility.Collapsed;
CompanionRing.Visibility = Visibility.Visible;
MoltyRing.Stroke = status switch
CompanionRing.Stroke = status switch
{
ConnectionStatus.Connected => new SolidColorBrush(Colors.LimeGreen),
ConnectionStatus.Error => new SolidColorBrush(Colors.Red),
@ -133,7 +133,7 @@ public sealed partial class HomePage : Page
_lastSessions = sessions;
DispatcherQueue?.TryEnqueue(() =>
{
UpdateMoltyRing(_lastStatus);
UpdateCompanionRing(_lastStatus);
UpdateStatusText(_lastStatus);
});
}

View File

@ -19,26 +19,6 @@ public sealed partial class NodesPage : Page
public NodesPage()
{
InitializeComponent();
// Sample data
var samples = new List<NodeViewModel>
{
new()
{
Name = "Desktop-PC", DeviceId = "a1b2c3d4e5f6g7h8i9j0",
Platform = "windows", IsOnline = true,
Capabilities = new[] { "canvas", "camera", "screen", "system", "clipboard", "browser" },
Commands = new[] { "screenshot", "open-url", "run-command", "read-clipboard", "write-clipboard" },
},
new()
{
Name = "MacBook-Pro", DeviceId = "z9y8x7w6v5u4t3s2r1q0",
Platform = "macos", IsOnline = false,
Capabilities = new[] { "canvas", "screen", "system" },
Commands = new[] { "screenshot", "open-url" },
},
};
RenderNodes(samples);
}
public void Initialize(HubWindow hub)

View File

@ -15,16 +15,6 @@ public sealed partial class SessionsPage : Page
public SessionsPage()
{
InitializeComponent();
// Sample data for design-time preview
var samples = new List<SessionViewModel>
{
new() { Key = "agent:main", Preview = "Help me refactor the authentication module to use JWT tokens...", TimeAgo = "2m ago", ThinkingLevel = "medium", VerboseLevel = null, IsActive = true },
new() { Key = "agent:cron:daily-summary", Preview = "Generated daily summary for 3 channels with 47 messages.", TimeAgo = "1h ago", ThinkingLevel = null, VerboseLevel = "detailed", IsActive = false },
new() { Key = "telegram:user:12345", Preview = "Remind me to check the deployment status at 5pm today.", TimeAgo = "15m ago", ThinkingLevel = null, VerboseLevel = null, IsActive = true },
};
SessionListView.ItemsSource = samples;
EmptyState.Visibility = Visibility.Collapsed;
}
public void Initialize(HubWindow hub)

View File

@ -73,6 +73,25 @@
</StackPanel>
</Expander>
<!-- Privacy -->
<Expander x:Name="PrivacyExpander" IsExpanded="True"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
AutomationProperties.AutomationId="PrivacyExpander">
<Expander.Header>
<TextBlock x:Uid="SettingsPrivacyHeader" Text="Privacy" Style="{StaticResource BodyStrongTextBlockStyle}"/>
</Expander.Header>
<StackPanel Spacing="12" Padding="4">
<TextBlock x:Uid="SettingsPrivacyDescription" Text="Pre-approve capabilities so agents can use them without a permission prompt each time. You'll still see a countdown before recording starts."
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap"/>
<ToggleSwitch x:Uid="ScreenRecordingToggle" x:Name="ScreenRecordingToggle" Header="Allow screen recording"
AutomationProperties.AutomationId="SettingsPageScreenRecording"/>
<ToggleSwitch x:Uid="CameraRecordingToggle" x:Name="CameraRecordingToggle" Header="Allow camera recording"
AutomationProperties.AutomationId="SettingsPageCameraRecording"/>
</StackPanel>
</Expander>
</StackPanel>
</ScrollViewer>
@ -82,9 +101,9 @@
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="0,1,0,0" Padding="24,16">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button x:Uid="CancelButton" x:Name="CancelButton" Content="Cancel" Width="80" Click="OnCancel"
<Button x:Uid="CancelButton" x:Name="CancelButton" Content="Cancel" MinWidth="80" Click="OnCancel"
AutomationProperties.AutomationId="SettingsPageCancel"/>
<Button x:Uid="SaveButton" x:Name="SaveButton" Content="Save" Width="80" Click="OnSave"
<Button x:Uid="SaveButton" x:Name="SaveButton" Content="Save" MinWidth="80" Click="OnSave"
Style="{ThemeResource AccentButtonStyle}"
AutomationProperties.AutomationId="SettingsPageSave"/>
</StackPanel>

View File

@ -14,6 +14,8 @@ public sealed partial class SettingsPage : Page
{
private HubWindow? _hub;
private bool _initialized;
private bool _saving;
private bool _isDirty;
public SettingsPage()
@ -27,8 +29,52 @@ public sealed partial class SettingsPage : Page
if (!_initialized && hub.Settings != null)
{
LoadSettings(hub.Settings);
hub.Settings.Saved += OnExternalSettingsChanged;
RegisterDirtyHandlers();
_initialized = true;
}
else if (_initialized && hub.Settings != null)
{
ScreenRecordingToggle.IsOn = hub.Settings.ScreenRecordingConsentGiven;
CameraRecordingToggle.IsOn = hub.Settings.CameraRecordingConsentGiven;
}
}
private void RegisterDirtyHandlers()
{
void MarkDirty(object s, RoutedEventArgs e) { if (_initialized) _isDirty = true; }
AutoStartToggle.Toggled += MarkDirty;
GlobalHotkeyToggle.Toggled += MarkDirty;
NotificationsToggle.Toggled += MarkDirty;
ScreenRecordingToggle.Toggled += MarkDirty;
CameraRecordingToggle.Toggled += MarkDirty;
NotificationSoundComboBox.SelectionChanged += (s, e) => { if (_initialized) _isDirty = true; };
NotifyHealthCb.Checked += MarkDirty; NotifyHealthCb.Unchecked += MarkDirty;
NotifyUrgentCb.Checked += MarkDirty; NotifyUrgentCb.Unchecked += MarkDirty;
NotifyReminderCb.Checked += MarkDirty; NotifyReminderCb.Unchecked += MarkDirty;
NotifyEmailCb.Checked += MarkDirty; NotifyEmailCb.Unchecked += MarkDirty;
NotifyCalendarCb.Checked += MarkDirty; NotifyCalendarCb.Unchecked += MarkDirty;
NotifyBuildCb.Checked += MarkDirty; NotifyBuildCb.Unchecked += MarkDirty;
NotifyStockCb.Checked += MarkDirty; NotifyStockCb.Unchecked += MarkDirty;
NotifyInfoCb.Checked += MarkDirty; NotifyInfoCb.Unchecked += MarkDirty;
}
private void OnExternalSettingsChanged(object? sender, EventArgs e)
{
if (_hub?.Settings == null || _saving || _isDirty) return;
DispatcherQueue.TryEnqueue(() =>
{
ScreenRecordingToggle.IsOn = _hub.Settings.ScreenRecordingConsentGiven;
CameraRecordingToggle.IsOn = _hub.Settings.CameraRecordingConsentGiven;
// Show that the change is already persisted
SaveButton.Content = "✓ Saved";
var timer = DispatcherQueue.CreateTimer();
timer.Interval = TimeSpan.FromSeconds(2);
timer.Tick += (t, a) => { SaveButton.Content = "Save"; timer.Stop(); };
timer.Start();
});
}
private void LoadSettings(SettingsManager settings)
@ -57,6 +103,9 @@ public sealed partial class SettingsPage : Page
NotifyBuildCb.IsChecked = settings.NotifyBuild;
NotifyStockCb.IsChecked = settings.NotifyStock;
NotifyInfoCb.IsChecked = settings.NotifyInfo;
ScreenRecordingToggle.IsOn = settings.ScreenRecordingConsentGiven;
CameraRecordingToggle.IsOn = settings.CameraRecordingConsentGiven;
}
private void OnSave(object sender, RoutedEventArgs e)
@ -80,7 +129,13 @@ public sealed partial class SettingsPage : Page
s.NotifyStock = NotifyStockCb.IsChecked ?? true;
s.NotifyInfo = NotifyInfoCb.IsChecked ?? true;
s.ScreenRecordingConsentGiven = ScreenRecordingToggle.IsOn;
s.CameraRecordingConsentGiven = CameraRecordingToggle.IsOn;
_saving = true;
s.Save();
_saving = false;
_isDirty = false;
AutoStartManager.SetAutoStart(s.AutoStart);
_hub.RaiseSettingsSaved();
@ -98,6 +153,7 @@ public sealed partial class SettingsPage : Page
_initialized = false;
LoadSettings(_hub.Settings);
_initialized = true;
_isDirty = false;
}
}

View File

@ -15,13 +15,6 @@
<ComboBoxItem x:Uid="SkillsPage_ComboBoxItem_15" Content="All Agents" Tag="" IsSelected="True"/>
</ComboBox>
<!-- Not-wired InfoBar -->
<InfoBar x:Uid="NotWiredInfoBar" x:Name="NotWiredInfoBar"
IsOpen="True" IsClosable="False"
Severity="Warning"
Title="Skills API not yet wired"
Message="Showing sample data. Skills management methods are not yet available in OpenClawGatewayClient."/>
<!-- Skills List -->
<ListView x:Name="SkillsList" SelectionMode="None">
<ListView.ItemTemplate>
@ -74,7 +67,7 @@
</ListView>
<!-- Empty State -->
<StackPanel x:Name="EmptyState" Visibility="Collapsed"
<StackPanel x:Name="EmptyState" Visibility="Visible"
VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="8"
Margin="0,32,0,0">
<TextBlock Text="🧩" FontSize="48" HorizontalAlignment="Center"/>

View File

@ -11,7 +11,6 @@ namespace OpenClawTray.Pages;
public sealed partial class SkillsPage : Page
{
private HubWindow? _hub;
private bool _hasLiveData;
public SkillsPage()
{
@ -24,10 +23,8 @@ public sealed partial class SkillsPage : Page
PopulateAgentFilter(hub);
if (hub.GatewayClient != null)
{
NotWiredInfoBar.IsOpen = false;
_ = hub.GatewayClient.RequestSkillsStatusAsync(GetSelectedAgentId());
}
LoadSampleSkills();
}
private void PopulateAgentFilter(HubWindow hub)
@ -58,49 +55,6 @@ public sealed partial class SkillsPage : Page
_ = client.RequestSkillsStatusAsync(GetSelectedAgentId());
}
private void LoadSampleSkills()
{
if (_hasLiveData) return;
var skills = new List<SkillViewModel>
{
new()
{
Id = "github",
Name = "GitHub Integration",
Version = "v2.1",
Description = "Connect OpenClaw to GitHub for issue tracking, PR reviews, and repository management.",
StatusText = "Active",
StatusBackground = new SolidColorBrush(Colors.Green),
ActionLabel = "Update",
},
new()
{
Id = "email",
Name = "Email Digest",
Version = "v1.3",
Description = "Automatically summarize and send email digests of daily activity and session outcomes.",
StatusText = "Active",
StatusBackground = new SolidColorBrush(Colors.Green),
ActionLabel = "Update",
},
new()
{
Id = "calendar",
Name = "Calendar Sync",
Version = "v0.9",
Description = "Sync scheduled tasks and cron jobs with your calendar provider for visibility.",
StatusText = "Inactive",
StatusBackground = new SolidColorBrush(Colors.Gray),
ActionLabel = "Enable",
},
};
SkillsList.ItemsSource = skills;
SkillsList.Visibility = Visibility.Visible;
EmptyState.Visibility = Visibility.Collapsed;
}
private void OnSkillActionClick(object sender, RoutedEventArgs e)
{
var skillId = (sender as Button)?.Tag as string;
@ -165,9 +119,6 @@ public sealed partial class SkillsPage : Page
DispatcherQueue?.TryEnqueue(() =>
{
_hasLiveData = true;
NotWiredInfoBar.IsOpen = false;
if (skills.Count > 0)
{
SkillsList.ItemsSource = skills;

View File

@ -21,7 +21,7 @@
<TextBlock x:Uid="UsagePage_TextBlock_21" Text="Total Cost" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"/>
<TextBlock x:Name="TotalCostText" Text="$2.41" FontSize="28" FontWeight="SemiBold"
<TextBlock x:Name="TotalCostText" Text="" FontSize="28" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
@ -34,7 +34,7 @@
<StackPanel Spacing="4">
<TextBlock x:Uid="UsagePage_TextBlock_35" Text="Requests" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="RequestCountText" Text="142" FontSize="28" FontWeight="SemiBold"/>
<TextBlock x:Name="RequestCountText" Text="" FontSize="28" FontWeight="SemiBold"/>
</StackPanel>
</Border>
@ -44,7 +44,7 @@
<StackPanel Spacing="4">
<TextBlock x:Uid="UsagePage_TextBlock_45" Text="Tokens" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Uid="TokenCountText" x:Name="TokenCountText" Text="284.5K" FontSize="28" FontWeight="SemiBold"/>
<TextBlock x:Uid="TokenCountText" x:Name="TokenCountText" Text="" FontSize="28" FontWeight="SemiBold"/>
</StackPanel>
</Border>
@ -54,7 +54,7 @@
<StackPanel Spacing="4">
<TextBlock x:Uid="UsagePage_TextBlock_55" Text="Providers" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock x:Name="ProviderCountText" Text="3" FontSize="28" FontWeight="SemiBold"/>
<TextBlock x:Name="ProviderCountText" Text="" FontSize="28" FontWeight="SemiBold"/>
</StackPanel>
</Border>
</StackPanel>

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