From d25951aeb99ccb55466192bd4922d2e6c145c11b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 01:32:45 +0000
Subject: [PATCH 001/115] fix(chat): surface incompatible-gateway state when
handshake lacks session key
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the gateway handshake completes but does not advertise a
mainSessionKey (or the legacy mainKey alias), the composer was showing
"Connected" with a disabled-but-unlabelled send box. The user had no
indication that the gateway needed updating.
Changes:
- OpenClawChatDataProvider: emit "Incompatible gateway" as
ConnectionStatus when HasHandshakeSnapshot=true but MainSessionKey is
null/empty and the socket is Connected, instead of plain "Connected".
- OpenClawChatRoot: map the "Incompatible" prefix to the new
"incompatible-gateway" connState token.
- OpenClawComposer: handle "incompatible-gateway" → disable inputs +
show Chat_Composer_Placeholder_IncompatibleGateway placeholder.
- Resources.resw (all 5 locales): add
Chat_Composer_Placeholder_IncompatibleGateway string.
- OpenClawChatDataProviderTests: add two focused tests for the
incompatible-handshake path (snapshot ConnectionStatus and
ComposeTarget.IsReady=false).
Closes #459
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatDataProvider.cs | 23 ++++++++----
.../Chat/OpenClawChatRoot.cs | 4 +-
.../Chat/OpenClawComposer.cs | 1 +
.../Strings/en-us/Resources.resw | 3 ++
.../Strings/fr-fr/Resources.resw | 3 ++
.../Strings/nl-nl/Resources.resw | 3 ++
.../Strings/zh-cn/Resources.resw | 3 ++
.../Strings/zh-tw/Resources.resw | 3 ++
.../OpenClawChatDataProviderTests.cs | 37 +++++++++++++++++++
9 files changed, 71 insertions(+), 9 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 83dd83d7e..f0ebc5908 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -2037,14 +2037,21 @@ private ChatDataSnapshot BuildSnapshotLocked()
var defaultThreadId = ResolveDefaultThreadIdLocked();
- var connectionLabel = _status switch
- {
- ConnectionStatus.Connected => "Connected",
- ConnectionStatus.Connecting => "Connecting…",
- ConnectionStatus.Disconnected => "Disconnected",
- ConnectionStatus.Error => "Disconnected — error",
- _ => _status.ToString()
- };
+ // When the gateway is connected and the handshake completed but no
+ // session key was advertised, distinguish this from a normal "Connected"
+ // state so the UI can surface a clear compatibility warning.
+ var connectionLabel = (_status == ConnectionStatus.Connected
+ && _bridge.HasHandshakeSnapshot
+ && string.IsNullOrWhiteSpace(composeKey))
+ ? "Incompatible gateway"
+ : _status switch
+ {
+ ConnectionStatus.Connected => "Connected",
+ ConnectionStatus.Connecting => "Connecting…",
+ ConnectionStatus.Disconnected => "Disconnected",
+ ConnectionStatus.Error => "Disconnected — error",
+ _ => _status.ToString()
+ };
var composeTarget = composeReady
? new ChatComposeTarget(composeKey, true)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index ae297b5d6..57c98a5e5 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -273,7 +273,9 @@ Element BuildLoadingElement()
var connectedRaw = snapshot.ConnectionStatus;
var hostConnected = connectedRaw is not null
&& connectedRaw.StartsWith("Connected", StringComparison.OrdinalIgnoreCase);
- var connState = hostConnected ? "connected"
+ var connState = (connectedRaw is not null && connectedRaw.StartsWith("Incompatible", StringComparison.OrdinalIgnoreCase))
+ ? "incompatible-gateway"
+ : hostConnected ? "connected"
: (connectedRaw is not null && connectedRaw.StartsWith("Connecting", StringComparison.OrdinalIgnoreCase))
? "connecting"
: "disconnected";
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index 96c7fed00..3ca7afc69 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -183,6 +183,7 @@ public override Element Render()
{
"connected" => LocalizationHelper.GetString("Chat_Composer_Placeholder_Connected"),
"connecting" => LocalizationHelper.GetString("Chat_Composer_Placeholder_Connecting"),
+ "incompatible-gateway" => LocalizationHelper.GetString("Chat_Composer_Placeholder_IncompatibleGateway"),
_ => LocalizationHelper.GetString("Chat_Composer_Placeholder_NotConnected")
};
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 052ca9801..d82ac1204 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2877,6 +2877,9 @@ On your gateway host (Mac/Linux), run:
Not connected
+
+ Gateway update required — incompatible version
+
Attach
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 69c912520..9fd29c2aa 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2828,6 +2828,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Non connecté
+
+ Gateway update required — incompatible version
+
Joindre
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index fe766d944..87a49064b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2829,6 +2829,9 @@ Voer op uw gateway-host (Mac/Linux) uit:
Niet verbonden
+
+ Gateway update required — incompatible version
+
Bijvoegen
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index c1b9c1ed9..5849c2968 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2828,6 +2828,9 @@
未连接
+
+ Gateway update required — incompatible version
+
附加
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index c9218f538..8871ddbaf 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2828,6 +2828,9 @@
未連線
+
+ Gateway update required — incompatible version
+
附加
diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
index 40f5fc8ef..dc0048eb4 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
@@ -460,6 +460,43 @@ public async Task LoadAsync_HandshakeKnown_ZeroSessions_ExposesReadyComposeTarge
Assert.Equal("agent:main:main", snap.DefaultThreadId);
}
+ [Fact]
+ public async Task LoadAsync_HandshakeComplete_NoSessionKey_SignalsIncompatibleGateway()
+ {
+ // When the gateway completes the handshake but does not advertise
+ // a mainSessionKey (or sessionDefaults.mainKey), the provider must surface
+ // an "Incompatible gateway" connection label and a NotReady compose target
+ // so the UI can show a clear "gateway update required" message rather than
+ // silently blocking send. Relates to issue #459.
+ var (bridge, provider, _, _) = CreateProvider();
+ bridge.HasHandshakeSnapshot = true;
+ bridge.MainSessionKey = null; // incompatible gateway: no session key
+ bridge.RaiseStatus(ConnectionStatus.Connected);
+ var snap = await provider.LoadAsync();
+
+ Assert.Equal("Incompatible gateway", snap.ConnectionStatus);
+ Assert.False(snap.ComposeTarget.IsReady);
+ Assert.Null(snap.ComposeTarget.SessionKey);
+ }
+
+ [Fact]
+ public async Task StatusChanged_IncompatibleGateway_IsReflectedInSnapshotConnectionLabel()
+ {
+ // Raise Connected with handshake present but no session key; the snapshot
+ // must use "Incompatible gateway" rather than the plain "Connected" label.
+ var (bridge, provider, snapshots, _) = CreateProvider();
+ bridge.HasHandshakeSnapshot = true;
+ bridge.MainSessionKey = null;
+ await provider.LoadAsync();
+ snapshots.Clear();
+
+ bridge.RaiseStatus(ConnectionStatus.Connected);
+
+ Assert.NotEmpty(snapshots);
+ Assert.Equal("Incompatible gateway", snapshots[^1].ConnectionStatus);
+ Assert.False(snapshots[^1].ComposeTarget.IsReady);
+ }
+
// ── Parity additions: streaming, lifecycle, reasoning, history, abort ──
[Fact]
From 53300ecda3b4b16c786d3de613e89f835ad6c081 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:01 -0700
Subject: [PATCH 002/115] fix(chat): tighten composer padding symmetry and cap
tool burst card width
Visual cleanup pass on the chat surface (no functional change):
- OpenClawComposer: right-edge padding 8 -> 14 in both ThreeRow and
InlinePill branches so the action icons (attach / mic / settings / send)
no longer jam against the window edge.
- OpenClawComposer: dropdowns row ColumnGap 4 -> 6 for clearer separation
between Channel / Model / Reasoning pickers.
- OpenClawChatTimeline: cap tool-burst cards (CardOf + TaskList listCard)
at MaxWidth=720 with HAlign.Left so a single 'exec' row no longer
stretches across the full viewport with the Done pill floating at the
far right edge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 5 +++--
src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs | 8 ++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 3d94748ca..a7e509e5a 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1187,7 +1187,8 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .CornerRadius(8);
+ .CornerRadius(8)
+ .Set(b => { b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1469,7 +1470,7 @@ string Truncate(string s, int max)
).Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
.CornerRadius(8)
- .Set(b => { b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Wrap with the assistant avatar slot so the burst visually
// anchors to the agent that produced it (and lines up with the
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index fc1a311c3..99846888d 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -248,8 +248,8 @@ public override Element Render()
Element dropdownsRow = composerLayout switch
{
ChatComposerLayout.Minimal => Empty(),
- ChatComposerLayout.InlinePill => (FlexRow(modelCombo) with { ColumnGap = 4 }),
- _ => (FlexRow(channelCombo, modelCombo, reasoningCombo) with { ColumnGap = 4 }),
+ ChatComposerLayout.InlinePill => (FlexRow(modelCombo) with { ColumnGap = 6 }),
+ _ => (FlexRow(channelCombo, modelCombo, reasoningCombo) with { ColumnGap = 6 }),
};
// ── Row 2: multi-line composer textbox ─────────────────────────
@@ -745,7 +745,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
permissionBanner,
Border(
VStack(8, textbox, attachmentChip, bottomRow)
- ).Padding(14, 12, 8, 12)
+ ).Padding(14, 12, 14, 12)
.Set(b =>
{
b.BorderThickness = new Thickness(0, 1, 0, 0);
@@ -770,7 +770,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
permissionBanner2,
Border(
VStack(8, dropdownsRow, textbox, voiceIndicator, attachmentChip, actionsRow)
- ).Padding(14, 12, 8, 12)
+ ).Padding(14, 12, 14, 12)
.Set(b =>
{
// Top divider only — mirrors Kenny's ChatShell ComposerBorder.
From 53d6a3df4ce4e166caacac7fd366bce9532a1c92 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:01 -0700
Subject: [PATCH 003/115] ui(chat): unify margins, radius, drop tool footer,
outline tool cards
- Symmetrize user/assistant/tool burst outer margins (16px both sides)
- Use bubbleRadius for tool CardOf/listCard and conditional header buttons
- Make tool card background Transparent so outline distinguishes it from filled assistant bubble
- Drop Plain tool burst footer; assistant follow-up bubble already carries the time
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 59 +++++++++----------
1 file changed, 29 insertions(+), 30 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index a7e509e5a..c6e5e41ed 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -439,9 +439,10 @@ static Element TimelineInset(Element child, double top = 2, double bottom = 2) =
? themeBrush("TextFillColorTertiaryBrush")
: themeBrush("TextFillColorSecondaryBrush");
var chatTextFg = themeBrush("TextFillColorPrimaryBrush");
- // Tool chips kept in a slightly cooler/dim shade so they read as
- // secondary content next to the assistant bubble.
- var toolCardBgBrush = themeBrush("SubtleFillColorTertiaryBrush");
+ // Tool chips: outline-only so they clearly differ from the filled
+ // assistant bubble (per visual feedback — make tool cards distinct
+ // from chat bubbles instead of looking like a slightly dimmer copy).
+ var toolCardBgBrush = (Brush)new SolidColorBrush(Microsoft.UI.Colors.Transparent);
var toolCardBorderBrush = themeBrush("ControlStrokeColorDefaultBrush");
// Avatar: 36×36 circle (Kenny uses circular avatars). Same constructor
@@ -850,7 +851,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
VStack(2, bubbleRow, footer)
.HAlign(HorizontalAlignment.Stretch)
).Background(new SolidColorBrush(Colors.Transparent))
- .Margin(gutter, topMargin, 8, bottomMargin),
+ .Margin(gutter, topMargin, 16, bottomMargin),
entry.Id);
}
@@ -913,7 +914,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
.HAlign(HorizontalAlignment.Stretch)
.AutomationName(entry.Text ?? "")
).Background(new SolidColorBrush(Colors.Transparent))
- .Margin(8, topMargin, gutter, bottomMargin),
+ .Margin(16, topMargin, gutter, bottomMargin),
entry.Id);
}
@@ -1167,14 +1168,11 @@ Element BuildSection(string sectionLabel, string contentText)
string? StepPrefix(int i) => showStepNumbers ? $"{i + 1}." : null;
- // Footer reflects the *last* entry's timestamp — that's when the
- // burst finished from the user's POV.
+ // Footer (when shown) reflects the *last* entry's timestamp —
+ // that's when the burst finished from the user's POV.
var lastEntry = entries[entries.Count - 1];
var entryMeta = MetaFor(lastEntry.Id);
var timeStr = FormatTime(entryMeta?.Timestamp);
- var plainFooter = string.IsNullOrEmpty(timeStr)
- ? LocalizationHelper.GetString("Chat_Tool_FooterLabel")
- : string.Format(LocalizationHelper.GetString("Chat_Tool_FooterWithTimeFormat"), timeStr);
// "Task · 3 steps · 8:16 PM" — used by FooterReframe + as the
// companion line under the TaskHeader card. Keeps the time so
// users still get the chronology.
@@ -1187,8 +1185,7 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .CornerRadius(8)
- .Set(b => { b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1244,7 +1241,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
b.HorizontalAlignment = HorizontalAlignment.Stretch;
b.HorizontalContentAlignment = HorizontalAlignment.Stretch;
b.Padding = new Thickness(0);
- b.CornerRadius = new CornerRadius(8, 8, summaryExpanded ? 0 : 8, summaryExpanded ? 0 : 8);
+ b.CornerRadius = new CornerRadius(bubbleRadius.TopLeft, bubbleRadius.TopRight, summaryExpanded ? 0 : bubbleRadius.BottomRight, summaryExpanded ? 0 : bubbleRadius.BottomLeft);
}).Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x22, 0x00, 0x00, 0x00)))
@@ -1440,7 +1437,7 @@ string Truncate(string s, int max)
b.HorizontalAlignment = HorizontalAlignment.Stretch;
b.HorizontalContentAlignment = HorizontalAlignment.Stretch;
b.Padding = bubblePadding;
- b.CornerRadius = new CornerRadius(8, 8, effectiveExpanded ? 0 : 8, effectiveExpanded ? 0 : 8);
+ b.CornerRadius = new CornerRadius(bubbleRadius.TopLeft, bubbleRadius.TopRight, effectiveExpanded ? 0 : bubbleRadius.BottomRight, effectiveExpanded ? 0 : bubbleRadius.BottomLeft);
}).Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x22, 0x00, 0x00, 0x00)))
@@ -1469,8 +1466,7 @@ string Truncate(string s, int max)
VStack(0, cardChildren.ToArray())
).Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .CornerRadius(8)
- .Set(b => { b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Wrap with the assistant avatar slot so the burst visually
// anchors to the agent that produced it (and lines up with the
@@ -1490,23 +1486,26 @@ string Truncate(string s, int max)
listCard.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
- // When avatar is present, drop the left margin to 0 so the
- // avatar fills the indent slot. Otherwise keep the original 36.
- // No trailing footer — assistant follow-up bubble below carries
- // the timestamp for the whole turn.
- var leftMargin = showAssistAvatar ? 8.0 : 36.0;
- return burstRow.HAlign(HorizontalAlignment.Stretch).Margin(leftMargin, 6, gutter, 6);
+ // Match assistant bubble's outer inset so user/assistant/tool
+ // share the same left edge. Avatar slot lives inside burstRow.
+ return burstRow.HAlign(HorizontalAlignment.Stretch).Margin(16, 6, gutter, 6);
}
- // FooterReframe: rows unchanged, footer becomes "Task · N steps · time".
- // Plain: original — "Tool · time".
- var footerText = style == ToolBurstStyle.FooterReframe ? TaskFooter() : plainFooter;
+ // FooterReframe keeps the "Task · N steps · time" caption.
+ // Plain drops the footer entirely — the assistant follow-up
+ // bubble below carries the timestamp for the whole turn, and
+ // labelling each tool card with "Tool · time" added visual noise.
+ if (style == ToolBurstStyle.FooterReframe)
+ {
+ return VStack(2,
+ CardOf(rows),
+ FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
+ ).HAlign(HorizontalAlignment.Stretch)
+ .Margin(16, 6, gutter, 6);
+ }
- return VStack(2,
- CardOf(rows),
- FooterCaption(footerText, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch)
- .Margin(36, 6, gutter, 6);
+ return CardOf(rows).HAlign(HorizontalAlignment.Left)
+ .Margin(16, 6, gutter, 6);
}
// Legacy single-entry RenderToolEntry removed — all ToolCall rendering
From 43ed8a395219d38854548effe034b3837f8f5ed3 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:01 -0700
Subject: [PATCH 004/115] ui(chat): align assistant continuation with tool
cards + add top breathing room
- Drop the 36x36 spacer that mid-run assistant bubbles inherited; continuation bubbles now sit at the same left inset as tool burst cards above them, so the agent column reads as a single straight edge.
- Add 20px top padding above the first message in the scroll content so the conversation does not crowd the window edge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 21 +++++++++----------
1 file changed, 10 insertions(+), 11 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index c6e5e41ed..88ae65840 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -865,14 +865,12 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
return Empty();
// Avatar shown only on the FIRST entry of a contiguous agent-side
- // run (Assistant + ToolCall task cards count as one agent block).
- // Mid-run entries get a spacer so the bubble stays aligned with
- // the first bubble that does carry the avatar.
- Element leftSlot = !showAssistAvatar
+ // run. Continuation entries get no spacer — they align flush with
+ // the tool burst cards above (which also sit at the left inset),
+ // so the agent column reads as a single vertical edge.
+ Element leftSlot = !showAssistAvatar || !showAvatar
? Empty()
- : (showAvatar
- ? AssistantAvatar().VAlign(VerticalAlignment.Top)
- : Border(Empty()).Size(36, 36));
+ : AssistantAvatar().VAlign(VerticalAlignment.Top);
// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
@@ -1683,8 +1681,9 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
// Page background matches dash-light --bg so bubbles stand out.
Border(
ScrollView(
- Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto],
+ Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto],
loadMoreButton.Grid(row: 0, column: 0),
+ Border(Empty()).Height(20).Grid(row: 1, column: 0),
VStack(2, renderedEntries).Set(sp =>
{
if (contentRef.Current != sp)
@@ -1696,9 +1695,9 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
QueueScrollToBottom(sv, prevSessionIdRef.Current, disableAnimation: true);
};
}
- }).Grid(row: 1, column: 0),
- thinkingIndicator.Grid(row: 2, column: 0),
- Border(Empty()).Height(24).Grid(row: 3, column: 0)
+ }).Grid(row: 2, column: 0),
+ thinkingIndicator.Grid(row: 3, column: 0),
+ Border(Empty()).Height(24).Grid(row: 4, column: 0)
)
).Set(sv =>
{
From 31c8dbe8a256689f5deee6f57dbca2947bc52c48 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:01 -0700
Subject: [PATCH 005/115] ui(chat): strip phantom avatar gap, unify tool row
padding/height, align composer inset
- When no assistant avatar shown, drop the leftSlot's right margin so assistant bubbles share the same left edge (16px) as tool burst cards.
- Tool burst row header now uses bubblePadding instead of (12,8,12,8) and a 32px MinHeight, so tool rows match chat bubble heights.
- Composer outer padding 14->16 to align dropdowns/input flush with chat bubble left edge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 8 ++++----
src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 88ae65840..9d6855148 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -886,7 +886,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var bubbleRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, bubbleSideMargin, 0),
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
@@ -1031,8 +1031,8 @@ Element BuildRow(ChatTimelineItem entry, bool isFirst, bool isLast, string? step
.VAlign(VerticalAlignment.Center)
.HAlign(HorizontalAlignment.Right)
.Grid(row: 0, column: 4)
- ).HAlign(HorizontalAlignment.Stretch).Padding(12, 8, 12, 8)
- ).Set(b => b.MinHeight = 22);
+ ).HAlign(HorizontalAlignment.Stretch).Padding(bubblePadding.Left, bubblePadding.Top, bubblePadding.Right, bubblePadding.Bottom)
+ ).Set(b => b.MinHeight = 32);
Element body = Empty();
if (isExpanded)
@@ -1480,7 +1480,7 @@ string Truncate(string s, int max)
var burstRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, bubbleSideMargin, 0),
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
listCard.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index 99846888d..bfa798306 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -745,7 +745,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
permissionBanner,
Border(
VStack(8, textbox, attachmentChip, bottomRow)
- ).Padding(14, 12, 14, 12)
+ ).Padding(16, 12, 16, 12)
.Set(b =>
{
b.BorderThickness = new Thickness(0, 1, 0, 0);
@@ -770,7 +770,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
permissionBanner2,
Border(
VStack(8, dropdownsRow, textbox, voiceIndicator, attachmentChip, actionsRow)
- ).Padding(14, 12, 14, 12)
+ ).Padding(16, 12, 16, 12)
.Set(b =>
{
// Top divider only — mirrors Kenny's ChatShell ComposerBorder.
From 65ba516cc138ddfb3d752080f233badaf35428ee Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:02 -0700
Subject: [PATCH 006/115] ui(chat): align continuation footer, soften tool
hover, light tool tint
- Assistant footer leftInset now respects per-entry avatar visibility (not the global flag), so continuation entries' timestamps align with the bubble's left edge instead of being indented 44px.
- Tool burst button hover/press alphas 0x22/0x33 -> 0x10/0x1C for a subtler reveal that doesn't darken the card on every pointer pass.
- Tool card background back to a faint LayerOnAcrylicFillColorDefault tint so the card has gentle presence instead of looking like a pure outline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 22 +++++++++----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 9d6855148..54de16850 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -439,10 +439,10 @@ static Element TimelineInset(Element child, double top = 2, double bottom = 2) =
? themeBrush("TextFillColorTertiaryBrush")
: themeBrush("TextFillColorSecondaryBrush");
var chatTextFg = themeBrush("TextFillColorPrimaryBrush");
- // Tool chips: outline-only so they clearly differ from the filled
- // assistant bubble (per visual feedback — make tool cards distinct
- // from chat bubbles instead of looking like a slightly dimmer copy).
- var toolCardBgBrush = (Brush)new SolidColorBrush(Microsoft.UI.Colors.Transparent);
+ // Tool chips: very subtle background tint + light border so they
+ // read as a secondary surface distinct from the filled assistant
+ // bubble without looking like an empty outlined box.
+ var toolCardBgBrush = themeBrush("LayerOnAcrylicFillColorDefaultBrush");
var toolCardBorderBrush = themeBrush("ControlStrokeColorDefaultBrush");
// Avatar: 36×36 circle (Kenny uses circular avatars). Same constructor
@@ -900,7 +900,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
entryMeta?.InputTokens, entryMeta?.OutputTokens,
entryMeta?.ResponseTokens, entryMeta?.ContextPercent,
chatStampFg, entry.Id, entry.Text ?? "");
- var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0;
+ var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
footer = footer.Margin(leftInset, 2, 0, 0);
}
@@ -1135,8 +1135,8 @@ Element BuildSection(string sectionLabel, string contentText)
})
.Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
- .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x22, 0x00, 0x00, 0x00)))
- .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x33, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x10, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x1C, 0x00, 0x00, 0x00)))
.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)));
@@ -1242,8 +1242,8 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
b.CornerRadius = new CornerRadius(bubbleRadius.TopLeft, bubbleRadius.TopRight, summaryExpanded ? 0 : bubbleRadius.BottomRight, summaryExpanded ? 0 : bubbleRadius.BottomLeft);
}).Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
- .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x22, 0x00, 0x00, 0x00)))
- .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x33, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x10, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x1C, 0x00, 0x00, 0x00)))
.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)));
@@ -1438,8 +1438,8 @@ string Truncate(string s, int max)
b.CornerRadius = new CornerRadius(bubbleRadius.TopLeft, bubbleRadius.TopRight, effectiveExpanded ? 0 : bubbleRadius.BottomRight, effectiveExpanded ? 0 : bubbleRadius.BottomLeft);
}).Resources(r => r
.Set("ButtonBackground", new SolidColorBrush(Colors.Transparent))
- .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x22, 0x00, 0x00, 0x00)))
- .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x33, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPointerOver", new SolidColorBrush(Color.FromArgb(0x10, 0x00, 0x00, 0x00)))
+ .Set("ButtonBackgroundPressed", new SolidColorBrush(Color.FromArgb(0x1C, 0x00, 0x00, 0x00)))
.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)));
From 003afae7ce85d615179e26b5b660d390c8881b1a Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:02 -0700
Subject: [PATCH 007/115] fix(chat): align timestamps with bubble inner text
User/assistant footer insets now include bubblePadding so timestamps
sit flush with the bubble's text content edge instead of the outer
bubble border.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 54de16850..43565b80a 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -840,6 +840,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
var entryMeta = MetaFor(entry.Id);
var timeStr = FormatTime(entryMeta?.Timestamp);
var rightInset = showUserAvatar ? (36 + bubbleSideMargin) : 0;
+ rightInset += (int)bubblePadding.Right;
footer = BuildUserFooter(userSender, timeStr, chatStampFg, entry.Id, entry.Text ?? "")
.Margin(0, 2, rightInset, 0);
}
@@ -901,6 +902,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
entryMeta?.ResponseTokens, entryMeta?.ContextPercent,
chatStampFg, entry.Id, entry.Text ?? "");
var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
+ leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
}
From ab977832f79b2b3dab359ec05652f8734ace097e Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:02 -0700
Subject: [PATCH 008/115] fix(chat): align user timestamp with bubble text (no
avatar case)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the user avatar is hidden, the rightSlot column still added an
8px left margin even though the column body was empty, leaving an
8px gap on the right of the bubble. Gate the margin on showUserAvatar
so the bubble actually reaches the container's right edge when the
avatar is off — this makes rightInset (= bubblePadding.Right) place
the timestamp flush with the bubble's inner text right edge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 43565b80a..fd10efbd5 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -831,7 +831,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
[GridSize.Star(), GridSize.Auto],
[GridSize.Auto],
content.HAlign(HorizontalAlignment.Right).Grid(row: 0, column: 0),
- rightSlot.Grid(row: 0, column: 1).Margin(bubbleSideMargin, 0, 0, 0)
+ rightSlot.Grid(row: 0, column: 1).Margin(showUserAvatar ? bubbleSideMargin : 0, 0, 0, 0)
).HAlign(HorizontalAlignment.Stretch);
Element footer = Empty();
From 85deafa99cdd20e25283ca570108190c0361b6c1 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:02 -0700
Subject: [PATCH 009/115] fix(chat): align Plain/FooterReframe tool bursts with
assistant bubble
The TaskHeader burst style reserved an avatar slot (36+8px) so the
list card lined up with the assistant bubble's text edge, but the
Plain and FooterReframe styles started flush at the gutter. When the
assistant entry above the burst showed an avatar, the tool cards
appeared 44px further left than the bubble.
Extracted the avatar-slot wrap into a helper and applied it to all
three burst styles so user/assistant/tool share the same left edge
regardless of burst style.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 32 +++++++++++++++----
1 file changed, 26 insertions(+), 6 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index fd10efbd5..8883008f2 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1491,20 +1491,40 @@ string Truncate(string s, int max)
return burstRow.HAlign(HorizontalAlignment.Stretch).Margin(16, 6, gutter, 6);
}
+ // Helper: wrap a tool burst card with the same avatar/spacer slot
+ // that TaskHeader uses, so Plain/FooterReframe align with the
+ // assistant bubble's text edge instead of starting at the gutter.
+ Element WrapWithAvatarSlot(Element card)
+ {
+ Element leftSlot = !showAssistAvatar
+ ? Empty()
+ : (showAvatar
+ ? AssistantAvatar().VAlign(VerticalAlignment.Top)
+ : Border(Empty()).Size(36, 36));
+
+ return Grid(
+ [GridSize.Auto, GridSize.Star()],
+ [GridSize.Auto],
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
+ card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
+ ).HAlign(HorizontalAlignment.Stretch);
+ }
+
// FooterReframe keeps the "Task · N steps · time" caption.
// Plain drops the footer entirely — the assistant follow-up
// bubble below carries the timestamp for the whole turn, and
// labelling each tool card with "Tool · time" added visual noise.
if (style == ToolBurstStyle.FooterReframe)
{
- return VStack(2,
- CardOf(rows),
- FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch)
- .Margin(16, 6, gutter, 6);
+ return WrapWithAvatarSlot(
+ VStack(2,
+ CardOf(rows),
+ FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
+ )
+ ).Margin(16, 6, gutter, 6);
}
- return CardOf(rows).HAlign(HorizontalAlignment.Left)
+ return WrapWithAvatarSlot(CardOf(rows))
.Margin(16, 6, gutter, 6);
}
From d70387fe8327ed0edf32cbda1d9a15dc802d66a5 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:02 -0700
Subject: [PATCH 010/115] feat(chat): default ToolBurstStyle to TaskList for
collapsible bursts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Default tool burst rendering switches from Plain (verbose per-row stack) to TaskList:
- While any step is InProgress: auto-expanded, shows 'Working on X...'
- When the burst completes: auto-collapses to a single one-line summary card
('Ran N steps' or last result snippet) with a chevron to expand
- Click chevron to override; per-step rows still individually expandable for
full args/output (3-tier disclosure)
Addresses Scott's feedback: 'I'd like to be able to have tool calls summarized,
or made smaller, or collapsible, so there would be some way to be clear that
work is happening, and if I want to see the verbose logs, I could.'
No data-flow changes — purely the default value of the existing ToolBurstStyle
enum. The dev exploration panel still exposes Plain/TaskHeader/CompactSummary/
FooterReframe/TaskList for tuning.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/Explorations/ChatExplorationState.cs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
index 3abd1fe24..f0c5c8dd8 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
@@ -394,7 +394,13 @@ public static bool ShowContextPercent
// ---- Tool burst (H) ----
- private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain;
+ // Default to TaskList so tool bursts auto-collapse into a one-line
+ // summary when finished (with chevron to expand). Matches the "show
+ // work is happening, hide verbose logs unless asked" UX Scott
+ // requested — TaskList stays expanded while a step is InProgress and
+ // collapses to "Ran N steps" / result snippet when done. Per-step
+ // rows can still be expanded individually for full args/output.
+ private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.TaskList;
private static bool _showStepNumbers;
///
From 7d6a8b1ec1eb0f1d0a1f57dd1337d51827a9ea3f Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:03 -0700
Subject: [PATCH 011/115] Chat UI: tool card indent + token/ctx footer priority
- Tool card aligns under bubble (toolLeftMargin = gutter + avatarSlot + 16) with right edge matched (MaxWidth -= indent). Plain/FooterReframe/CompactSummary/TaskHeader unified.
- Footer priority: hide sender/model by default, surface input/output tokens + context % pills.
- Preset record defaults updated to match (new presets inherit token/ctx ON, sender/model OFF).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../ChatExplorationPresetStore.cs | 8 +--
.../Chat/Explorations/ChatExplorationState.cs | 16 ++----
.../Chat/OpenClawChatTimeline.cs | 55 +++++++++----------
3 files changed, 34 insertions(+), 45 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs
index 16807a9d9..255ef1fb5 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationPresetStore.cs
@@ -43,10 +43,10 @@ public sealed record ChatExplorationPreset
public double BubbleSideMargin { get; init; } = 8;
// Footer
- public bool ShowSenderName { get; init; } = true;
- public bool ShowModelName { get; init; } = true;
- public bool ShowTokens { get; init; }
- public bool ShowContextPercent { get; init; }
+ public bool ShowSenderName { get; init; } = false;
+ public bool ShowModelName { get; init; } = false;
+ public bool ShowTokens { get; init; } = true;
+ public bool ShowContextPercent { get; init; } = true;
// Avatar
public bool ShowAvatars { get; init; } = true;
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
index f0c5c8dd8..3327d1f58 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
@@ -155,10 +155,10 @@ public static class ChatExplorationState
private static double _bubbleMaxWidth = 560d;
private static double _bubbleSideMargin = 8d;
- private static bool _showSenderName = true;
- private static bool _showModelName = true;
- private static bool _showTokens;
- private static bool _showContextPercent;
+ private static bool _showSenderName = false;
+ private static bool _showModelName = false;
+ private static bool _showTokens = true;
+ private static bool _showContextPercent = true;
// Default icon glyphs match production OpenClawComposer.cs.
private static string _sendIconGlyph = "\uE724";
@@ -394,13 +394,7 @@ public static bool ShowContextPercent
// ---- Tool burst (H) ----
- // Default to TaskList so tool bursts auto-collapse into a one-line
- // summary when finished (with chevron to expand). Matches the "show
- // work is happening, hide verbose logs unless asked" UX Scott
- // requested — TaskList stays expanded while a step is InProgress and
- // collapses to "Ran N steps" / result snippet when done. Per-step
- // rows can still be expanded individually for full args/output.
- private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.TaskList;
+ private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain;
private static bool _showStepNumbers;
///
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 8883008f2..ebd6267ce 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1151,6 +1151,18 @@ Element BuildSection(string sectionLabel, string contentText)
var showStepNumbers = ChatExplorationState.ShowStepNumbers && entries.Count > 1;
var stepCount = entries.Count;
+ // Tool burst alignment: align outer left to the assistant bubble's
+ // outer left edge + a small indent so the card visually reads as
+ // "owned by" the assistant bubble above. Width math:
+ // bubble outer left = gutter(16) + avatar(36) + sideMargin
+ // tool card outer left = bubble outer left + indent
+ // When avatars are globally hidden, drop the avatar slot but still
+ // keep the indent so the tool card sits inside the bubble's reading
+ // column rather than aligning to the gutter.
+ const int toolIndent = 16;
+ var toolAvatarSlot = showAssistAvatar ? (36 + (int)bubbleSideMargin) : 0;
+ var toolLeftMargin = 16 + toolAvatarSlot + toolIndent;
+
// Aggregate burst status: Error if any errored, Running if any
// not-yet-finished, Interrupted if any interrupted (but not running),
// otherwise Done. Drives the task header pill.
@@ -1185,7 +1197,7 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720; b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1260,7 +1272,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(pieces.ToArray()),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(36, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskHeader: prepend a non-clickable header row to the card.
@@ -1292,7 +1304,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(combined),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(36, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskList: per-step rows with a status icon (✓ / spinner / ✕)
@@ -1491,41 +1503,24 @@ string Truncate(string s, int max)
return burstRow.HAlign(HorizontalAlignment.Stretch).Margin(16, 6, gutter, 6);
}
- // Helper: wrap a tool burst card with the same avatar/spacer slot
- // that TaskHeader uses, so Plain/FooterReframe align with the
- // assistant bubble's text edge instead of starting at the gutter.
- Element WrapWithAvatarSlot(Element card)
- {
- Element leftSlot = !showAssistAvatar
- ? Empty()
- : (showAvatar
- ? AssistantAvatar().VAlign(VerticalAlignment.Top)
- : Border(Empty()).Size(36, 36));
-
- return Grid(
- [GridSize.Auto, GridSize.Star()],
- [GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
- card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
- ).HAlign(HorizontalAlignment.Stretch);
- }
-
// FooterReframe keeps the "Task · N steps · time" caption.
// Plain drops the footer entirely — the assistant follow-up
// bubble below carries the timestamp for the whole turn, and
// labelling each tool card with "Tool · time" added visual noise.
+ // Both styles align the card to the bubble's text edge + indent
+ // (see toolLeftMargin) so the burst visually belongs to the
+ // assistant bubble it follows.
if (style == ToolBurstStyle.FooterReframe)
{
- return WrapWithAvatarSlot(
- VStack(2,
- CardOf(rows),
- FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- )
- ).Margin(16, 6, gutter, 6);
+ return VStack(2,
+ CardOf(rows),
+ FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
- return WrapWithAvatarSlot(CardOf(rows))
- .Margin(16, 6, gutter, 6);
+ return CardOf(rows)
+ .HAlign(HorizontalAlignment.Left)
+ .Margin(toolLeftMargin, 6, gutter, 6);
}
// Legacy single-entry RenderToolEntry removed — all ToolCall rendering
From 1287011c9f5630851a0ca63a7282b78f55c35bde Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:03 -0700
Subject: [PATCH 012/115] Chat UI: tool cards below assistant/thinking,
right-align with bubble
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Reorder timeline display within each turn so ToolCall bursts render AFTER the assistant reply (or the thinking indicator if none yet). Gateway still emits tool_start before assistant_delta; only the visual order changes.
- Inline 'agent is thinking…' indicator right after the most recent User entry instead of pinning to bottom of timeline, so tool cards visually hang below it.
- Tool burst card HAlign Left→Stretch (Plain/FooterReframe/CompactSummary/TaskHeader) so the right edge fills to the bubble's max right boundary instead of shrinking to content.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 102 ++++++++++++++----
1 file changed, 80 insertions(+), 22 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index ebd6267ce..5f74830f0 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1197,7 +1197,7 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Stretch; });
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1272,7 +1272,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(pieces.ToArray()),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskHeader: prepend a non-clickable header row to the card.
@@ -1304,7 +1304,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(combined),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskList: per-step rows with a status icon (✓ / spinner / ✕)
@@ -1515,11 +1515,11 @@ string Truncate(string s, int max)
return VStack(2,
CardOf(rows),
FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
return CardOf(rows)
- .HAlign(HorizontalAlignment.Left)
+ .HAlign(HorizontalAlignment.Stretch)
.Margin(toolLeftMargin, 6, gutter, 6);
}
@@ -1628,11 +1628,42 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
k == ChatTimelineItemKind.Assistant || k == ChatTimelineItemKind.ToolCall;
var renderedEntries = new Element[Props.Entries.Count];
- for (int i = 0; i < Props.Entries.Count; i++)
+ // Reorder display so that within each turn (segment between User entries)
+ // ToolCall bursts render AFTER the assistant reply (or the thinking
+ // indicator if no reply has streamed yet). Gateway emits tool_start
+ // before assistant_delta, but the desired visual flow is
+ // [User] → [Assistant reply / thinking] → [Tool burst]
+ // so the assistant message reads first and the tool work hangs below it.
+ var orderedIdx = new int[Props.Entries.Count];
{
+ int outPos = 0;
+ int turnStart = 0;
+ void Flush(int endExclusive)
+ {
+ for (int j = turnStart; j < endExclusive; j++)
+ if (Props.Entries[j].Kind != ChatTimelineItemKind.ToolCall)
+ orderedIdx[outPos++] = j;
+ for (int j = turnStart; j < endExclusive; j++)
+ if (Props.Entries[j].Kind == ChatTimelineItemKind.ToolCall)
+ orderedIdx[outPos++] = j;
+ }
+ for (int i = 0; i < Props.Entries.Count; i++)
+ {
+ if (Props.Entries[i].Kind == ChatTimelineItemKind.User && i > turnStart)
+ {
+ Flush(i);
+ turnStart = i;
+ }
+ }
+ Flush(Props.Entries.Count);
+ }
+
+ for (int k = 0; k < orderedIdx.Length; k++)
+ {
+ int i = orderedIdx[k];
var entry = Props.Entries[i];
- var prevKind = i > 0 ? Props.Entries[i - 1].Kind : (ChatTimelineItemKind?)null;
- var nextKind = i < Props.Entries.Count - 1 ? Props.Entries[i + 1].Kind : (ChatTimelineItemKind?)null;
+ var prevKind = k > 0 ? Props.Entries[orderedIdx[k - 1]].Kind : (ChatTimelineItemKind?)null;
+ var nextKind = k < orderedIdx.Length - 1 ? Props.Entries[orderedIdx[k + 1]].Kind : (ChatTimelineItemKind?)null;
var startsBurst = prevKind is null || !SameBurstKind(prevKind.Value, entry.Kind);
var endsBurst = nextKind is null || !SameBurstKind(entry.Kind, nextKind.Value);
var showAvatar = !(prevKind is { } pk && IsAgentSide(pk) && IsAgentSide(entry.Kind));
@@ -1644,28 +1675,26 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
{
if (!showToolCalls)
{
- renderedEntries[i] = Empty().WithKey(entry.Id);
+ renderedEntries[k] = Empty().WithKey(entry.Id);
continue;
}
if (!startsBurst)
{
- // Non-start tool entries collapsed into the burst rendered
- // at startsBurst; render Empty here to avoid duplication.
- renderedEntries[i] = Empty().WithKey(entry.Id);
+ renderedEntries[k] = Empty().WithKey(entry.Id);
continue;
}
var burst = new System.Collections.Generic.List { entry };
- int j = i + 1;
- while (j < Props.Entries.Count && Props.Entries[j].Kind == ChatTimelineItemKind.ToolCall)
+ int kj = k + 1;
+ while (kj < orderedIdx.Length && Props.Entries[orderedIdx[kj]].Kind == ChatTimelineItemKind.ToolCall)
{
- burst.Add(Props.Entries[j]);
- j++;
+ burst.Add(Props.Entries[orderedIdx[kj]]);
+ kj++;
}
- renderedEntries[i] = RenderToolBurst(burst, showAvatar).WithKey(entry.Id);
+ renderedEntries[k] = RenderToolBurst(burst, showAvatar).WithKey(entry.Id);
continue;
}
- renderedEntries[i] = RenderEntry(entry, startsBurst, endsBurst, showAvatar).WithKey(entry.Id);
+ renderedEntries[k] = RenderEntry(entry, startsBurst, endsBurst, showAvatar).WithKey(entry.Id);
}
// Inline "thinking" indicator rendered just below the last entry
@@ -1694,14 +1723,44 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
.LiveRegion(Microsoft.UI.Xaml.Automation.Peers.AutomationLiveSetting.Polite);
}
+ // Build the final element list, splicing the thinking indicator
+ // inline RIGHT AFTER the most recent User entry so tool bursts that
+ // follow it visually hang below "Agent is thinking…" (and below the
+ // assistant reply once one streams in).
+ Element[] timelineRows;
+ if (Props.ShowThinkingIndicator && renderedEntries.Length > 0)
+ {
+ int insertAfter = -1;
+ for (int k = orderedIdx.Length - 1; k >= 0; k--)
+ {
+ if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
+ {
+ insertAfter = k;
+ break;
+ }
+ }
+ var spliced = new System.Collections.Generic.List(renderedEntries.Length + 1);
+ for (int k = 0; k < renderedEntries.Length; k++)
+ {
+ spliced.Add(renderedEntries[k]);
+ if (k == insertAfter) spliced.Add(thinkingIndicator);
+ }
+ if (insertAfter < 0) spliced.Insert(0, thinkingIndicator);
+ timelineRows = spliced.ToArray();
+ }
+ else
+ {
+ timelineRows = renderedEntries;
+ }
+
return Grid([GridSize.Star()], [GridSize.Star()],
// Page background matches dash-light --bg so bubbles stand out.
Border(
ScrollView(
- Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto],
+ Grid([GridSize.Star()], [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Auto],
loadMoreButton.Grid(row: 0, column: 0),
Border(Empty()).Height(20).Grid(row: 1, column: 0),
- VStack(2, renderedEntries).Set(sp =>
+ VStack(2, timelineRows).Set(sp =>
{
if (contentRef.Current != sp)
{
@@ -1713,8 +1772,7 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
};
}
}).Grid(row: 2, column: 0),
- thinkingIndicator.Grid(row: 3, column: 0),
- Border(Empty()).Height(24).Grid(row: 4, column: 0)
+ Border(Empty()).Height(24).Grid(row: 3, column: 0)
)
).Set(sv =>
{
From 6301d78b6beab4d0bd90799fed73b62697d9d22e Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:03 -0700
Subject: [PATCH 013/115] Chat UI: align assistant bubble + tool card right
edges
Assistant bubble previously used HAlign=Left with no MaxWidth, so its right edge tracked content width. Tool burst card used HAlign=Stretch with MaxWidth=704, filling further right than the bubble.
Give the assistant card MaxWidth=720 and HAlign=Stretch so it always pins to the same max right boundary as the tool card (60 + 720 = 76 + 704 = 780). Tool card now sits indented 16px inside the bubble's left edge with an identical right stroke, reading as a true child of the bubble above.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 5f74830f0..c0fe4f181 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -875,6 +875,9 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
+ // MaxWidth+Stretch so the bubble's right stroke lines up with the
+ // tool burst card's right stroke (tool card sits indented inside
+ // the same column with HAlign=Stretch and MaxWidth = 720 - indent).
var card = Border(
SafeMarkdownText(entry.Text)
).Background(assistantBubbleBg)
@@ -882,13 +885,14 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
{
b.CornerRadius = bubbleRadius;
b.Padding = bubblePadding;
+ b.MaxWidth = 720;
});
var bubbleRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
- card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
+ card.HAlign(HorizontalAlignment.Stretch).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
Element footer = Empty();
From ccec251d247f30c017db8f30cc804ba0fd26c706 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:03 -0700
Subject: [PATCH 014/115] Chat UI: revert assistant bubble to HAlign=Left (keep
avatar/timestamp anchored)
WinUI's HAlign=Stretch + finite MaxWidth centers the element inside its slot (rather than pinning it left), which detached the bubble from the avatar + timestamp column. Revert to HAlign=Left so the bubble grows from the avatar's edge; MaxWidth=720 still caps the right edge so long messages line up with the tool burst card's right stroke. Short messages keep the previous content-width behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index c0fe4f181..4ff929c88 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -875,9 +875,9 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
- // MaxWidth+Stretch so the bubble's right stroke lines up with the
- // tool burst card's right stroke (tool card sits indented inside
- // the same column with HAlign=Stretch and MaxWidth = 720 - indent).
+ // HAlign=Left keeps the bubble anchored next to the avatar/timestamp
+ // column. MaxWidth=720 caps the growth so long messages stop where
+ // the tool burst card's max right edge lands.
var card = Border(
SafeMarkdownText(entry.Text)
).Background(assistantBubbleBg)
@@ -892,7 +892,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
- card.HAlign(HorizontalAlignment.Stretch).Grid(row: 0, column: 1)
+ card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
Element footer = Empty();
From 00b4c598540245e2c697c7b72dff2997dfc066d1 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:04 -0700
Subject: [PATCH 015/115] Chat UI: revert tool card to HAlign=Left (avoid WinUI
stretch-centers-on-wide-screen)
Same WinUI quirk as the assistant bubble: HAlign=Stretch with finite MaxWidth centers the element inside an oversized slot rather than pinning it left. On wide screens the tool burst card drifted away from the bubble's left edge.
Revert all four tool burst styles (Plain, FooterReframe, CompactSummary, TaskHeader) to HAlign=Left. Both bubble and tool card now anchor on the left next to the avatar/timestamp column; right edges line up when both fill their MaxWidth (720 / 720-indent).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 4ff929c88..9eb26266f 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1201,7 +1201,7 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Stretch; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Left; });
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1276,7 +1276,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(pieces.ToArray()),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskHeader: prepend a non-clickable header row to the card.
@@ -1308,7 +1308,7 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
return VStack(2,
CardOf(combined),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskList: per-step rows with a status icon (✓ / spinner / ✕)
@@ -1519,11 +1519,11 @@ string Truncate(string s, int max)
return VStack(2,
CardOf(rows),
FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
}
return CardOf(rows)
- .HAlign(HorizontalAlignment.Stretch)
+ .HAlign(HorizontalAlignment.Left)
.Margin(toolLeftMargin, 6, gutter, 6);
}
From 8add87d10c544aa0595774ec8d5417f6d55306c6 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:05 -0700
Subject: [PATCH 016/115] Chat UI: anchor tool card left while stretching to
MaxWidth via Auto/Star Grid
Tool card needs to fill its 704-wide slot so the right stroke aligns with the assistant bubble's right edge, but HAlign=Stretch alone centers the card on wide screens (WinUI's Stretch + finite MaxWidth quirk).
Wrap the card in an Auto/Star Grid: the Auto column sizes to the card's MaxWidth (704), keeping the card pinned to toolLeftMargin and filling the slot. The Star column absorbs the rest. Applied to Plain, FooterReframe, CompactSummary, TaskHeader.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 30 +++++++++++++------
1 file changed, 21 insertions(+), 9 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 9eb26266f..4c42db8b5 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1201,7 +1201,20 @@ string TaskFooter()
Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
.Background(toolCardBgBrush)
.WithBorder(toolCardBorderBrush, 1)
- .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Left; });
+ .Set(b => { b.CornerRadius = bubbleRadius; b.MaxWidth = 720 - toolIndent; b.HorizontalAlignment = HorizontalAlignment.Stretch; });
+
+ // Wrap the card in a left-anchored Auto/Star Grid so its 704-wide
+ // slot stays pinned to toolLeftMargin instead of being centered
+ // inside the wider timeline column (WinUI's quirk: HAlign=Stretch
+ // with finite MaxWidth centers when the slot is bigger than the
+ // MaxWidth). The Auto column sizes to the card's MaxWidth, the
+ // Star column eats the remaining width on the right.
+ Element AnchorLeft(Element card) => Grid(
+ [GridSize.Auto, GridSize.Star()],
+ [GridSize.Auto],
+ card.Grid(row: 0, column: 0),
+ Empty().Grid(row: 0, column: 1)
+ ).HAlign(HorizontalAlignment.Stretch);
// Build the per-step rows once — used by Plain, TaskHeader, and
// CompactSummary (when expanded).
@@ -1274,9 +1287,9 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
pieces.AddRange(rows);
}
return VStack(2,
- CardOf(pieces.ToArray()),
+ AnchorLeft(CardOf(pieces.ToArray())),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskHeader: prepend a non-clickable header row to the card.
@@ -1306,9 +1319,9 @@ Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls))
Array.Copy(rows, 0, combined, 1, rows.Length);
return VStack(2,
- CardOf(combined),
+ AnchorLeft(CardOf(combined)),
FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskList: per-step rows with a status icon (✓ / spinner / ✕)
@@ -1517,13 +1530,12 @@ string Truncate(string s, int max)
if (style == ToolBurstStyle.FooterReframe)
{
return VStack(2,
- CardOf(rows),
+ AnchorLeft(CardOf(rows)),
FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Left).Margin(toolLeftMargin, 6, gutter, 6);
+ ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
}
- return CardOf(rows)
- .HAlign(HorizontalAlignment.Left)
+ return AnchorLeft(CardOf(rows))
.Margin(toolLeftMargin, 6, gutter, 6);
}
From fa6f7ad6daf575a7f634a18195229679a1e5f27c Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:05 -0700
Subject: [PATCH 017/115] Chat UI: bind tool card width to assistant bubble's
ActualWidth
Assistant bubble is content-sized (HAlign=Left, MaxWidth=720), so we can't predict its rendered width at layout time. Result: the tool card's right edge rarely matched the bubble's right edge, especially with short replies.
Solution: thread a per-turn Border[1] slot from RenderAssistantEntry into RenderToolBurst. The assistant bubble's Border drops itself into slot[0] on materialize; the tool card subscribes to bubble.SizeChanged and sets its own Width = bubble.ActualWidth - toolIndent. The two cards' left indent and right edges now stay exactly parallel as the bubble grows, regardless of content length.
Reset slot at each User entry boundary so tool cards never bind to a prior turn's bubble. Falls back to MaxWidth/AnchorLeft when no bubble exists (tool-only turn).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 54 +++++++++++++++++--
1 file changed, 50 insertions(+), 4 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 4c42db8b5..e631a5a76 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -856,7 +856,13 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
entry.Id);
}
- Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar)
+ // Per-turn shared reference between the assistant bubble and any
+ // tool cards rendered below it. The tool card binds its Width to
+ // bubble.ActualWidth - toolIndent so the two cards' right edges
+ // (and left indent) stay exactly parallel as the bubble grows
+ // with content. Single-element Border[] used as a mutable slot
+ // since these are local functions (no nested class allowed).
+ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null)
{
if (string.IsNullOrEmpty(entry.Text))
return Empty();
@@ -886,6 +892,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
b.CornerRadius = bubbleRadius;
b.Padding = bubblePadding;
b.MaxWidth = 720;
+ if (bubbleSlot != null) bubbleSlot[0] = b;
});
var bubbleRow = Grid(
@@ -928,7 +935,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
// into `▸ ⚡ · [Done]`; click expands the row
// to reveal the original args + raw output (the previous chip body).
// A single trailing `Tool ·
TaskList,
+ /// Smart default — picks per burst state:
+ /// running bursts render Plain (per-step status visible);
+ /// terminal multi-step bursts collapse to CompactSummary (1-line summary,
+ /// click chevron to expand); single-step bursts stay Plain.
+ /// Matches Scott's feedback: keep live progress visible, fold completed
+ /// work into a tidy one-liner once the turn finishes.
+ Auto,
}
///
@@ -394,7 +401,7 @@ public static bool ShowContextPercent
// ---- Tool burst (H) ----
- private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain;
+ private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Auto;
private static bool _showStepNumbers;
///
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
index d9a555b8c..99792087f 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
@@ -212,6 +212,7 @@ public override Element Render()
// ── H. Tool burst (multi-step task framing) ──────────────────
// Variants explored here mirror competitor patterns:
+ // Auto — smart default: Plain while running, CompactSummary when done
// Plain — current Cursor-lite (no task framing)
// TaskHeader — Cursor's "Tool calls (N steps)" + per-row list
// CompactSummary — single collapsed "Task · 3 steps" row, expands
@@ -219,6 +220,7 @@ public override Element Render()
var toolBurstSection = Section("H. Tool burst style",
EnumCombo("Burst style", ChatExplorationState.ToolBurstStyle,
v => ChatExplorationState.ToolBurstStyle = v,
+ ToolBurstStyle.Auto,
ToolBurstStyle.Plain,
ToolBurstStyle.TaskHeader,
ToolBurstStyle.CompactSummary,
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index e631a5a76..d4b99c5a5 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1157,8 +1157,25 @@ Element BuildSection(string sectionLabel, string contentText)
// ── Style-aware composition ──────────────────────────────
// Read the live exploration state for the burst variant.
- // Defaults to Plain when not explicitly toggled.
+ // Defaults to Auto, which picks the best variant per burst:
+ // - single-step → Plain (one inline row, nothing to fold)
+ // - all terminal → CompactSummary (1-line collapsed summary,
+ // click chevron to expand the steps)
+ // - any running → Plain (per-step status visible in real time)
+ // Matches Scott's feedback: keep live progress legible while
+ // executing, then tidy up to a one-liner once the turn finishes.
var style = ChatExplorationState.ToolBurstStyle;
+ if (style == ToolBurstStyle.Auto)
+ {
+ bool allTerminal = true;
+ foreach (var e in entries)
+ {
+ if (e.ToolResult == ChatToolCallStatus.InProgress) { allTerminal = false; break; }
+ }
+ style = (entries.Count >= 2 && allTerminal)
+ ? ToolBurstStyle.CompactSummary
+ : ToolBurstStyle.Plain;
+ }
var showStepNumbers = ChatExplorationState.ShowStepNumbers && entries.Count > 1;
var stepCount = entries.Count;
From 725c3907d73d9cf491f36809cfae849b12ecd9b8 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:06 -0700
Subject: [PATCH 019/115] Chat UI: align collapsed tool header with step rows,
drop tool timestamp
Two follow-ups from Scott's screenshot:
1. The collapsed CompactSummary header used FlexRow(ColumnGap=6)+padding(12,8,12,8)+MinHeight=22 while BuildRow used Grid[Auto,Auto,Auto,Star,Auto]+margin(6,0,0,0)+bubblePadding+MinHeight=32. Rebuilt the summary header with the exact same Grid template, margins, padding, and MinHeight as the step rows so the chevron / lightning / Task label / Done pill axes line up vertically when the burst is expanded.
2. Removed the trailing FooterCaption(timeStr/TaskFooter()) from CompactSummary, TaskHeader, and FooterReframe returns. The assistant bubble above the tool burst already shows its own timestamp/model/tokens footer, so the time under the tool card was redundant noise.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 68 ++++++++++++-------
1 file changed, 45 insertions(+), 23 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index d4b99c5a5..f199c66b6 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1292,25 +1292,50 @@ Element AnchorLeft(Element card) => Grid(
};
var summaryHeader = Border(
- (FlexRow(
+ Grid(
+ [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto],
+ [GridSize.Auto],
Caption(summaryChevron).Foreground(TertiaryText)
- .Set(t => { t.FontSize = 12; }).VAlign(VerticalAlignment.Center),
+ .Set(t => { t.FontSize = 12; })
+ .VAlign(VerticalAlignment.Center)
+ .Grid(row: 0, column: 0),
Caption("⚡").Foreground(taskStatusBg)
- .Set(t => { t.FontSize = 12; }).VAlign(VerticalAlignment.Center),
+ .Set(t => { t.FontSize = 12; })
+ .VAlign(VerticalAlignment.Center)
+ .Margin(6, 0, 0, 0)
+ .Grid(row: 0, column: 1),
Caption($"Task · {stepCount} steps").Foreground(SecondaryText)
- .Set(t => { t.FontSize = 12; t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold; })
- .VAlign(VerticalAlignment.Center),
+ .Set(t =>
+ {
+ t.FontSize = 12;
+ t.FontWeight = Microsoft.UI.Text.FontWeights.SemiBold;
+ })
+ .VAlign(VerticalAlignment.Center)
+ .Margin(6, 0, 0, 0)
+ .Grid(row: 0, column: 2),
Caption("· " + toolList).Foreground(TertiaryText)
- .Set(t => { t.FontSize = 12; t.TextTrimming = TextTrimming.CharacterEllipsis; t.MaxLines = 1; })
- .VAlign(VerticalAlignment.Center).Flex(grow: 1),
+ .Set(t =>
+ {
+ t.FontSize = 12;
+ t.TextTrimming = TextTrimming.CharacterEllipsis;
+ t.MaxLines = 1;
+ })
+ .VAlign(VerticalAlignment.Center)
+ .Margin(6, 0, 12, 0)
+ .Grid(row: 0, column: 3),
Border(
Caption(taskStatusText).Foreground(new SolidColorBrush(Colors.White))
.Set(t => { t.FontSize = 10; t.LineHeight = 16; })
.VAlign(VerticalAlignment.Center)
- ).Background(taskStatusBg).CornerRadius(10).Padding(8, 0, 8, 0)
- .Set(b => b.MinHeight = 18).VAlign(VerticalAlignment.Center)
- ) with { ColumnGap = 6 }).Padding(12, 8, 12, 8)
- ).Set(b => b.MinHeight = 22);
+ ).Background(taskStatusBg)
+ .CornerRadius(10)
+ .Padding(8, 0, 8, 0)
+ .Set(b => b.MinHeight = 18)
+ .VAlign(VerticalAlignment.Center)
+ .HAlign(HorizontalAlignment.Right)
+ .Grid(row: 0, column: 4)
+ ).HAlign(HorizontalAlignment.Stretch).Padding(bubblePadding.Left, bubblePadding.Top, bubblePadding.Right, bubblePadding.Bottom)
+ ).Set(b => b.MinHeight = 32);
var summaryButton = Button(summaryHeader, toggleSummary).Set(b =>
{
@@ -1333,10 +1358,9 @@ Element AnchorLeft(Element card) => Grid(
// the summary header inside the same card.
pieces.AddRange(rows);
}
- return VStack(2,
- AnchorLeft(CardOf(pieces.ToArray())),
- FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ return AnchorLeft(CardOf(pieces.ToArray()))
+ .HAlign(HorizontalAlignment.Stretch)
+ .Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskHeader: prepend a non-clickable header row to the card.
@@ -1365,10 +1389,9 @@ Element AnchorLeft(Element card) => Grid(
combined[0] = taskHeader;
Array.Copy(rows, 0, combined, 1, rows.Length);
- return VStack(2,
- AnchorLeft(CardOf(combined)),
- FooterCaption(timeStr ?? string.Empty, HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ return AnchorLeft(CardOf(combined))
+ .HAlign(HorizontalAlignment.Stretch)
+ .Margin(toolLeftMargin, 6, gutter, 6);
}
// TaskList: per-step rows with a status icon (✓ / spinner / ✕)
@@ -1576,10 +1599,9 @@ string Truncate(string s, int max)
// assistant bubble it follows.
if (style == ToolBurstStyle.FooterReframe)
{
- return VStack(2,
- AnchorLeft(CardOf(rows)),
- FooterCaption(TaskFooter(), HorizontalAlignment.Left).Margin(0, 2, 0, 0)
- ).HAlign(HorizontalAlignment.Stretch).Margin(toolLeftMargin, 6, gutter, 6);
+ return AnchorLeft(CardOf(rows))
+ .HAlign(HorizontalAlignment.Stretch)
+ .Margin(toolLeftMargin, 6, gutter, 6);
}
return AnchorLeft(CardOf(rows))
From 5058dccac3552a45ee3d3471f557dc65265c992d Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:06 -0700
Subject: [PATCH 020/115] chore(chat-ui): move assistant timestamp below tool
burst
When an assistant message is followed by a tool burst in the same turn,
defer the timestamp/model/tokens footer so it renders BELOW the tool
card(s) instead of between the bubble and the tools. Order becomes:
bubble -> tool card(s) -> timestamp/model/tokens
Implementation mirrors the existing bubbleSlot pattern: RenderAssistantEntry
accepts an Element[1] footerSlot; when supplied, the built footer is
handed back to the caller and the inline footer slot in the VStack
collapses to Empty(). The outer loop precomputes turn boundaries, hands
out a footerSlot whenever a tool entry follows in the same turn, and
splices the captured footer Element into timelineRows just after the
last entry of the turn (alongside the thinking indicator splice).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 84 ++++++++++++++++---
1 file changed, 73 insertions(+), 11 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index f199c66b6..f7358fb28 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -862,7 +862,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
// (and left indent) stay exactly parallel as the bubble grows
// with content. Single-element Border[] used as a mutable slot
// since these are local functions (no nested class allowed).
- Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null)
+ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null, Element[]? footerSlot = null)
{
if (string.IsNullOrEmpty(entry.Text))
return Empty();
@@ -915,6 +915,15 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
+ // When a footerSlot is supplied, the caller is deferring the
+ // timestamp/model/tokens line to render BELOW the tool burst
+ // (so the timestamp sits at the very bottom of the turn).
+ // Hand the footer over and emit Empty() in its inline slot.
+ if (footerSlot != null)
+ {
+ footerSlot[0] = footer;
+ footer = Empty();
+ }
}
var topMargin = startsBurst ? 4.0 : 1.0;
@@ -1747,6 +1756,28 @@ void Flush(int endExclusive)
// rendered below it in the same turn. Reset at each User entry.
Microsoft.UI.Xaml.Controls.Border[]? currentBubbleSlot = null;
+ // Pre-compute the last orderedIdx position of the turn containing each
+ // entry. Used to (a) decide whether to defer an assistant footer past
+ // a following tool burst and (b) know where to splice the deferred
+ // footer back into the timeline so the timestamp sits at the very
+ // bottom of the turn.
+ var turnEndAt = new int[orderedIdx.Length];
+ {
+ int ts = 0;
+ for (int k = 1; k < orderedIdx.Length; k++)
+ {
+ if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
+ {
+ for (int j = ts; j < k; j++) turnEndAt[j] = k - 1;
+ ts = k;
+ }
+ }
+ for (int j = ts; j < orderedIdx.Length; j++) turnEndAt[j] = orderedIdx.Length - 1;
+ }
+
+ // Map: insert these deferred-footer elements right AFTER orderedIdx[k].
+ var deferredFooterAfter = new System.Collections.Generic.Dictionary();
+
for (int k = 0; k < orderedIdx.Length; k++)
{
int i = orderedIdx[k];
@@ -1791,7 +1822,31 @@ void Flush(int endExclusive)
if (entry.Kind == ChatTimelineItemKind.Assistant)
{
currentBubbleSlot ??= new Microsoft.UI.Xaml.Controls.Border[1];
- renderedEntries[k] = RenderAssistantEntry(entry, startsBurst, endsBurst, showAvatar, currentBubbleSlot).WithKey(entry.Id);
+
+ // Does a ToolCall follow this assistant entry in the same turn?
+ // If yes, defer the assistant footer (timestamp/model/tokens)
+ // so it renders BELOW the tool card(s), keeping the timestamp
+ // at the very bottom of the turn.
+ bool hasToolAfter = false;
+ int turnEnd = turnEndAt[k];
+ for (int kj = k + 1; kj <= turnEnd; kj++)
+ {
+ if (Props.Entries[orderedIdx[kj]].Kind == ChatTimelineItemKind.ToolCall)
+ {
+ hasToolAfter = true;
+ break;
+ }
+ }
+
+ Element[]? footerSlot = (hasToolAfter && endsBurst && showTimestamps) ? new Element[1] : null;
+ renderedEntries[k] = RenderAssistantEntry(entry, startsBurst, endsBurst, showAvatar, currentBubbleSlot, footerSlot).WithKey(entry.Id);
+
+ if (footerSlot != null && footerSlot[0] != null)
+ {
+ // Splice the deferred footer in right after the last
+ // entry of this turn (which will be the tail tool burst).
+ deferredFooterAfter[turnEnd] = footerSlot[0]!;
+ }
continue;
}
@@ -1827,26 +1882,33 @@ void Flush(int endExclusive)
// Build the final element list, splicing the thinking indicator
// inline RIGHT AFTER the most recent User entry so tool bursts that
// follow it visually hang below "Agent is thinking…" (and below the
- // assistant reply once one streams in).
+ // assistant reply once one streams in). Also splice any deferred
+ // assistant footers (timestamp/model/tokens) AFTER the last entry of
+ // their turn so the timestamp sits at the very bottom — under the
+ // tool card(s).
Element[] timelineRows;
- if (Props.ShowThinkingIndicator && renderedEntries.Length > 0)
+ if ((Props.ShowThinkingIndicator && renderedEntries.Length > 0) || deferredFooterAfter.Count > 0)
{
int insertAfter = -1;
- for (int k = orderedIdx.Length - 1; k >= 0; k--)
+ if (Props.ShowThinkingIndicator)
{
- if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
+ for (int k = orderedIdx.Length - 1; k >= 0; k--)
{
- insertAfter = k;
- break;
+ if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
+ {
+ insertAfter = k;
+ break;
+ }
}
}
- var spliced = new System.Collections.Generic.List(renderedEntries.Length + 1);
+ var spliced = new System.Collections.Generic.List(renderedEntries.Length + 1 + deferredFooterAfter.Count);
for (int k = 0; k < renderedEntries.Length; k++)
{
spliced.Add(renderedEntries[k]);
- if (k == insertAfter) spliced.Add(thinkingIndicator);
+ if (Props.ShowThinkingIndicator && k == insertAfter) spliced.Add(thinkingIndicator);
+ if (deferredFooterAfter.TryGetValue(k, out var deferred)) spliced.Add(deferred);
}
- if (insertAfter < 0) spliced.Insert(0, thinkingIndicator);
+ if (Props.ShowThinkingIndicator && insertAfter < 0) spliced.Insert(0, thinkingIndicator);
timelineRows = spliced.ToArray();
}
else
From e8a4c2e2312ee7cee9dd4560c5f0a871069ffb06 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:06 -0700
Subject: [PATCH 021/115] Revert "chore(chat-ui): move assistant timestamp
below tool burst"
This reverts commit 9f84ba9196e2d2fb414eda57c1b27cd5a6bf1fde.
---
.../Chat/OpenClawChatTimeline.cs | 84 +++----------------
1 file changed, 11 insertions(+), 73 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index f7358fb28..f199c66b6 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -862,7 +862,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
// (and left indent) stay exactly parallel as the bubble grows
// with content. Single-element Border[] used as a mutable slot
// since these are local functions (no nested class allowed).
- Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null, Element[]? footerSlot = null)
+ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null)
{
if (string.IsNullOrEmpty(entry.Text))
return Empty();
@@ -915,15 +915,6 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
- // When a footerSlot is supplied, the caller is deferring the
- // timestamp/model/tokens line to render BELOW the tool burst
- // (so the timestamp sits at the very bottom of the turn).
- // Hand the footer over and emit Empty() in its inline slot.
- if (footerSlot != null)
- {
- footerSlot[0] = footer;
- footer = Empty();
- }
}
var topMargin = startsBurst ? 4.0 : 1.0;
@@ -1756,28 +1747,6 @@ void Flush(int endExclusive)
// rendered below it in the same turn. Reset at each User entry.
Microsoft.UI.Xaml.Controls.Border[]? currentBubbleSlot = null;
- // Pre-compute the last orderedIdx position of the turn containing each
- // entry. Used to (a) decide whether to defer an assistant footer past
- // a following tool burst and (b) know where to splice the deferred
- // footer back into the timeline so the timestamp sits at the very
- // bottom of the turn.
- var turnEndAt = new int[orderedIdx.Length];
- {
- int ts = 0;
- for (int k = 1; k < orderedIdx.Length; k++)
- {
- if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
- {
- for (int j = ts; j < k; j++) turnEndAt[j] = k - 1;
- ts = k;
- }
- }
- for (int j = ts; j < orderedIdx.Length; j++) turnEndAt[j] = orderedIdx.Length - 1;
- }
-
- // Map: insert these deferred-footer elements right AFTER orderedIdx[k].
- var deferredFooterAfter = new System.Collections.Generic.Dictionary();
-
for (int k = 0; k < orderedIdx.Length; k++)
{
int i = orderedIdx[k];
@@ -1822,31 +1791,7 @@ void Flush(int endExclusive)
if (entry.Kind == ChatTimelineItemKind.Assistant)
{
currentBubbleSlot ??= new Microsoft.UI.Xaml.Controls.Border[1];
-
- // Does a ToolCall follow this assistant entry in the same turn?
- // If yes, defer the assistant footer (timestamp/model/tokens)
- // so it renders BELOW the tool card(s), keeping the timestamp
- // at the very bottom of the turn.
- bool hasToolAfter = false;
- int turnEnd = turnEndAt[k];
- for (int kj = k + 1; kj <= turnEnd; kj++)
- {
- if (Props.Entries[orderedIdx[kj]].Kind == ChatTimelineItemKind.ToolCall)
- {
- hasToolAfter = true;
- break;
- }
- }
-
- Element[]? footerSlot = (hasToolAfter && endsBurst && showTimestamps) ? new Element[1] : null;
- renderedEntries[k] = RenderAssistantEntry(entry, startsBurst, endsBurst, showAvatar, currentBubbleSlot, footerSlot).WithKey(entry.Id);
-
- if (footerSlot != null && footerSlot[0] != null)
- {
- // Splice the deferred footer in right after the last
- // entry of this turn (which will be the tail tool burst).
- deferredFooterAfter[turnEnd] = footerSlot[0]!;
- }
+ renderedEntries[k] = RenderAssistantEntry(entry, startsBurst, endsBurst, showAvatar, currentBubbleSlot).WithKey(entry.Id);
continue;
}
@@ -1882,33 +1827,26 @@ void Flush(int endExclusive)
// Build the final element list, splicing the thinking indicator
// inline RIGHT AFTER the most recent User entry so tool bursts that
// follow it visually hang below "Agent is thinking…" (and below the
- // assistant reply once one streams in). Also splice any deferred
- // assistant footers (timestamp/model/tokens) AFTER the last entry of
- // their turn so the timestamp sits at the very bottom — under the
- // tool card(s).
+ // assistant reply once one streams in).
Element[] timelineRows;
- if ((Props.ShowThinkingIndicator && renderedEntries.Length > 0) || deferredFooterAfter.Count > 0)
+ if (Props.ShowThinkingIndicator && renderedEntries.Length > 0)
{
int insertAfter = -1;
- if (Props.ShowThinkingIndicator)
+ for (int k = orderedIdx.Length - 1; k >= 0; k--)
{
- for (int k = orderedIdx.Length - 1; k >= 0; k--)
+ if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
{
- if (Props.Entries[orderedIdx[k]].Kind == ChatTimelineItemKind.User)
- {
- insertAfter = k;
- break;
- }
+ insertAfter = k;
+ break;
}
}
- var spliced = new System.Collections.Generic.List(renderedEntries.Length + 1 + deferredFooterAfter.Count);
+ var spliced = new System.Collections.Generic.List(renderedEntries.Length + 1);
for (int k = 0; k < renderedEntries.Length; k++)
{
spliced.Add(renderedEntries[k]);
- if (Props.ShowThinkingIndicator && k == insertAfter) spliced.Add(thinkingIndicator);
- if (deferredFooterAfter.TryGetValue(k, out var deferred)) spliced.Add(deferred);
+ if (k == insertAfter) spliced.Add(thinkingIndicator);
}
- if (Props.ShowThinkingIndicator && insertAfter < 0) spliced.Insert(0, thinkingIndicator);
+ if (insertAfter < 0) spliced.Insert(0, thinkingIndicator);
timelineRows = spliced.ToArray();
}
else
From 4c522a3252e01d0a5d847c24d50eb84ba8a3fd46 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Wed, 20 May 2026 20:39:06 -0700
Subject: [PATCH 022/115] feat(chat-ui): nest single/collapsed tool burst
inside assistant bubble
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the assistant has produced a reply AND the tool burst would render
as a single visible row (one chip OR a collapsed multi-step summary),
embed the tool card inside the assistant bubble's content area instead
of rendering it as a sibling card below.
bubble {
text...
[tool card] <- nested
}
In-flight multi-step bursts (Plain expanded) and bursts arriving before
the assistant reply still render externally so live progress stays
visible. Plain / TaskHeader / TaskList / FooterReframe styles never
nest — only Auto and CompactSummary opt in.
Implementation:
- RenderAssistantEntry gains an Element? nestedTool param. When set,
the bubble wraps its markdown text in a VStack(8, text, nestedTool)
so the tool card sits flush below the message with an 8px top gap,
inside the bubble's existing padding/border/radius.
- RenderToolBurst gains a bool nested flag. In nested mode CardOf
drops MaxWidth/HAlign.Left and the bubbleSlot Width binding (the
parent bubble already constrains us); a new Wrap helper bypasses
AnchorLeft and the toolLeftMargin/gutter outer margin so the card
stretches inside the bubble.
- Outer loop precomputes turn boundaries, looks ahead from the last
assistant entry of the turn for a contiguous tool burst, and asks
BurstIsNestable to gate the decision (count==1 OR all-terminal under
Auto/CompactSummary). Consumed orderedIdx positions are tracked in a
HashSet so the external render branch emits Empty() for them.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatTimeline.cs | 133 ++++++++++++++++--
1 file changed, 118 insertions(+), 15 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index f199c66b6..3e2a2ca8b 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -862,7 +862,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
// (and left indent) stay exactly parallel as the bubble grows
// with content. Single-element Border[] used as a mutable slot
// since these are local functions (no nested class allowed).
- Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null)
+ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst, bool showAvatar, Microsoft.UI.Xaml.Controls.Border[]? bubbleSlot = null, Element? nestedTool = null)
{
if (string.IsNullOrEmpty(entry.Text))
return Empty();
@@ -884,8 +884,17 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
// HAlign=Left keeps the bubble anchored next to the avatar/timestamp
// column. MaxWidth=720 caps the growth so long messages stop where
// the tool burst card's max right edge lands.
+ // When `nestedTool` is supplied, the tool burst (single chip OR
+ // collapsed multi-step summary) is rendered INSIDE the bubble's
+ // content area — directly below the assistant text with a small
+ // top gap — so it visually reads as a child of the bubble.
+ Element bubbleContent = SafeMarkdownText(entry.Text);
+ if (nestedTool != null)
+ {
+ bubbleContent = VStack(8, bubbleContent, nestedTool);
+ }
var card = Border(
- SafeMarkdownText(entry.Text)
+ bubbleContent
).Background(assistantBubbleBg)
.Set(b =>
{
@@ -935,7 +944,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
// into `▸ ⚡ · [Done]`; click expands the row
// to reveal the original args + raw output (the previous chip body).
// A single trailing `Tool ·
public static string McpTokenPath =>
System.IO.Path.Combine(SettingsManager.SettingsDirectoryPath, "mcp-token.txt");
- private readonly bool _enableMcpServer;
+ private volatile bool _enableMcpServer;
private McpHttpServer? _mcpServer;
private string? _mcpStartupError;
public bool IsMcpRunning => _mcpServer != null;
- public AppCapability? AppCapability => _appCapability;
public VoiceService? VoiceService => _voiceService;
public TextToSpeechService? TextToSpeech => _textToSpeechService;
public string McpEndpoint => McpServerUrl;
@@ -272,10 +275,6 @@ private void RegisterCapabilities()
{
_capabilities.Clear();
- // App operations capability (always registered, not gated by a toggle)
- _appCapability = new AppCapability(_logger);
- Register(_appCapability);
-
// System capability (notifications + command execution)
_systemCapability = new SystemCapability(_logger);
_systemCapability.NotifyRequested += OnSystemNotify;
@@ -393,6 +392,18 @@ private void Register(INodeCapability capability)
_nodeClient?.RegisterCapability(capability);
}
+ ///
+ /// Register a capability that is only visible to local MCP clients, not
+ /// the gateway. Used for app-level testing/control tools.
+ ///
+ public void RegisterMcpOnlyCapability(INodeCapability capability)
+ {
+ lock (_capabilitiesLock)
+ {
+ _mcpOnlyCapabilities.Add(capability);
+ }
+ }
+
///
/// Adopt a created by an outside party
/// (typically )
@@ -591,11 +602,23 @@ private void StartMcpServer()
{
// Bridge reads the live _capabilities list every tools/list, so any
// future Register(...) call is exposed via MCP automatically.
+ // MCP-only capabilities (e.g. AppCapability) are merged in so
+ // they appear in tools/list but never touch the gateway client.
// The snapshot takes the same lock RegisterCapabilities holds,
// so a tools/list arriving mid-rebuild observes either the old
// or the new set — never a partially-cleared list.
var bridge = new McpToolBridge(
- () => { lock (_capabilitiesLock) return _capabilities.ToArray(); },
+ () => {
+ lock (_capabilitiesLock)
+ {
+ if (_mcpOnlyCapabilities.Count == 0)
+ return _capabilities.ToArray();
+ var merged = new List(_capabilities.Count + _mcpOnlyCapabilities.Count);
+ merged.AddRange(_capabilities);
+ merged.AddRange(_mcpOnlyCapabilities);
+ return merged.ToArray();
+ }
+ },
_logger,
serverName: "openclaw-tray-mcp",
serverVersion: typeof(NodeService).Assembly.GetName().Version?.ToString() ?? "0.0.0");
@@ -666,6 +689,42 @@ public string ResetMcpToken()
return token;
}
+ ///
+ /// Update the MCP server state at runtime (e.g. when the user toggles
+ /// EnableMcpServer in the Settings UI). Starts or stops the HTTP server
+ /// and ensures capabilities are registered for MCP-only mode.
+ ///
+ public void SetMcpEnabled(bool enabled)
+ {
+ _enableMcpServer = enabled;
+
+ if (enabled)
+ {
+ if (_mcpServer != null) return; // already running
+
+ _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server");
+
+ bool needsCapabilities;
+ lock (_capabilitiesLock) { needsCapabilities = _capabilities.Count == 0; }
+ if (needsCapabilities)
+ {
+ RegisterCapabilities();
+ }
+ else
+ {
+ StartMcpServer();
+ }
+ }
+ else
+ {
+ _logger.Info("[MCP] SetMcpEnabled(false) — stopping MCP server");
+ // Always call StopMcpServer to clear stale startup errors even
+ // if the server isn't running. StopMcpServer is lock-protected
+ // and handles _mcpServer == null safely.
+ StopMcpServer();
+ }
+ }
+
public GatewayNodeInfo? GetLocalNodeInfo()
{
if (_nodeClient == null)
diff --git a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
index 4f009b0bc..61ee2e7fe 100644
--- a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
+++ b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
@@ -104,4 +104,31 @@ public void UiOnlyChange_ReturnsUiOnly()
Assert.Equal(SettingsChangeImpact.UiOnly,
SettingsChangeClassifier.Classify(prev, next));
}
+
+ [Fact]
+ public void McpServerToggled_ReturnsUiOnly()
+ {
+ var prev = MakeSnapshot(gatewayUrl: "wss://test", enableMcpServer: false);
+ var next = MakeSnapshot(gatewayUrl: "wss://test", enableMcpServer: true);
+ Assert.Equal(SettingsChangeImpact.UiOnly,
+ SettingsChangeClassifier.Classify(prev, next));
+ }
+
+ [Fact]
+ public void McpServerToggledOff_ReturnsUiOnly()
+ {
+ var prev = MakeSnapshot(gatewayUrl: "wss://test", enableMcpServer: true);
+ var next = MakeSnapshot(gatewayUrl: "wss://test", enableMcpServer: false);
+ Assert.Equal(SettingsChangeImpact.UiOnly,
+ SettingsChangeClassifier.Classify(prev, next));
+ }
+
+ [Fact]
+ public void McpAndNodeModeToggled_ReturnsNodeReconnect()
+ {
+ var prev = MakeSnapshot(gatewayUrl: "wss://test", enableNodeMode: false, enableMcpServer: false);
+ var next = MakeSnapshot(gatewayUrl: "wss://test", enableNodeMode: true, enableMcpServer: true);
+ Assert.Equal(SettingsChangeImpact.NodeReconnectRequired,
+ SettingsChangeClassifier.Classify(prev, next));
+ }
}
From 6711591b5c140f5de418ea3db804e2a24988c98b Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Wed, 20 May 2026 22:53:52 -0700
Subject: [PATCH 037/115] Add Run system tools permission toggle
Gate system.run and system.run.prepare behind the Permissions page toggle while keeping the rest of the system capability registered. Rebuild node capabilities from current settings on reconnect so permission changes take effect without restarting the tray.
Resolved against #483/#488 after local validation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../ConnectionSettingsSnapshot.cs | 1 +
.../SettingsChangeImpact.cs | 3 +-
.../Capabilities/SystemCapability.cs | 39 +++++++++-
src/OpenClaw.Shared/SettingsData.cs | 10 +++
.../Pages/PermissionsPage.xaml.cs | 7 +-
.../Services/NodeCapabilityGating.cs | 7 ++
.../Services/NodeService.cs | 48 +++++-------
.../Services/SettingsDataExtensions.cs | 1 +
.../Services/SettingsManager.cs | 9 +++
.../Strings/en-us/Resources.resw | 6 ++
.../Strings/fr-fr/Resources.resw | 6 ++
.../Strings/nl-nl/Resources.resw | 6 ++
.../Strings/zh-cn/Resources.resw | 6 ++
.../Strings/zh-tw/Resources.resw | 6 ++
.../SettingsChangeImpactTests.cs | 14 ++++
.../OpenClaw.Shared.Tests/CapabilityTests.cs | 77 +++++++++++++++++++
.../McpToolBridgeTests.cs | 53 +++++++++++++
.../LocalizationValidationTests.cs | 2 +
.../NodeCapabilityGatingTests.cs | 15 ++++
19 files changed, 282 insertions(+), 34 deletions(-)
diff --git a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs
index a152d966a..147fc29a5 100644
--- a/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs
+++ b/src/OpenClaw.Connection/ConnectionSettingsSnapshot.cs
@@ -20,4 +20,5 @@ public sealed record ConnectionSettingsSnapshot(
bool NodeBrowserProxyEnabled,
bool NodeSttEnabled,
bool NodeTtsEnabled,
+ bool NodeSystemRunEnabled,
string? FullSettingsJson);
diff --git a/src/OpenClaw.Connection/SettingsChangeImpact.cs b/src/OpenClaw.Connection/SettingsChangeImpact.cs
index 49b3977c5..0f00d6a1e 100644
--- a/src/OpenClaw.Connection/SettingsChangeImpact.cs
+++ b/src/OpenClaw.Connection/SettingsChangeImpact.cs
@@ -60,7 +60,8 @@ public static SettingsChangeImpact Classify(ConnectionSettingsSnapshot? prev, Co
prev.NodeLocationEnabled != next.NodeLocationEnabled ||
prev.NodeBrowserProxyEnabled != next.NodeBrowserProxyEnabled ||
prev.NodeSttEnabled != next.NodeSttEnabled ||
- prev.NodeTtsEnabled != next.NodeTtsEnabled)
+ prev.NodeTtsEnabled != next.NodeTtsEnabled ||
+ prev.NodeSystemRunEnabled != next.NodeSystemRunEnabled)
return SettingsChangeImpact.CapabilityReload;
// Check if anything else changed (UI-only changes)
diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs
index 21914912f..c72d7f32d 100644
--- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs
+++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs
@@ -17,7 +17,7 @@ public class SystemCapability : NodeCapabilityBase
private const int DefaultRunTimeoutMs = 30_000;
private const int MaxRunTimeoutMs = 600_000; // 10 minutes
- private static readonly string[] _commands = new[]
+ private static readonly string[] _commandsWithRun = new[]
{
"system.notify",
"system.run",
@@ -27,6 +27,14 @@ public class SystemCapability : NodeCapabilityBase
"system.execApprovals.set"
};
+ private static readonly string[] _commandsWithoutRun = new[]
+ {
+ "system.notify",
+ "system.which",
+ "system.execApprovals.get",
+ "system.execApprovals.set"
+ };
+
private static readonly string[] DangerousAllowPatternFragments =
[
"remove-item",
@@ -47,8 +55,11 @@ public class SystemCapability : NodeCapabilityBase
"net "
];
- public override IReadOnlyList Commands => _commands;
-
+ private readonly bool _includeRunCommands;
+
+ public override IReadOnlyList Commands =>
+ _includeRunCommands ? _commandsWithRun : _commandsWithoutRun;
+
// Event to let UI handle the actual notification display
public event EventHandler? NotifyRequested;
@@ -62,8 +73,18 @@ public class SystemCapability : NodeCapabilityBase
// V2 exec approval handler (null = legacy path; inert until explicitly set)
private IExecApprovalV2Handler? _v2Handler;
- public SystemCapability(IOpenClawLogger logger) : base(logger)
+ /// Capability logger.
+ ///
+ /// When false, system.run and system.run.prepare are dropped
+ /// from and rejects them
+ /// with a clear error before any V2/legacy dispatch. The rest of the
+ /// system category (notify/which/execApprovals.get/set) is
+ /// unaffected. Wired from the tray "Run system tools" permission toggle
+ /// via NodeCapabilityGating.ShouldRegisterSystemRun.
+ ///
+ public SystemCapability(IOpenClawLogger logger, bool includeRunCommands = true) : base(logger)
{
+ _includeRunCommands = includeRunCommands;
}
///
@@ -98,6 +119,16 @@ public void SetV2Handler(IExecApprovalV2Handler handler)
public override async Task ExecuteAsync(NodeInvokeRequest request)
{
+ // "Run system tools" kill switch — applied before V2/legacy dispatch
+ // so stale gateway allowlists and cached MCP clients still see the
+ // capability as disabled when the user turned it off.
+ if (!_includeRunCommands &&
+ (request.Command == "system.run" || request.Command == "system.run.prepare"))
+ {
+ Logger.Info($"[system.run] rejected: 'Run system tools' is disabled (command={request.Command})");
+ return Error("system.run is disabled by user setting (Permissions → Run system tools).");
+ }
+
return request.Command switch
{
"system.notify" => await HandleNotifyAsync(request),
diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index a0e850652..d288bfad7 100644
--- a/src/OpenClaw.Shared/SettingsData.cs
+++ b/src/OpenClaw.Shared/SettingsData.cs
@@ -40,6 +40,16 @@ public class SettingsData
public bool CameraRecordingConsentGiven { get; set; } = false;
public bool NodeLocationEnabled { get; set; } = true;
public bool NodeBrowserProxyEnabled { get; set; } = true;
+ ///
+ /// Master switch for the system.run + system.run.prepare
+ /// commands. When false, those commands are dropped from the
+ /// capability's declared command list (so they don't appear in the
+ /// connect handshake or in MCP tools/list) and invocations are
+ /// rejected defensively. Per-command exec approvals still apply when
+ /// enabled. Default true for backward compatibility — exec has
+ /// been part of the Windows node since day one.
+ ///
+ public bool NodeSystemRunEnabled { get; set; } = true;
public bool NodeSttEnabled { get; set; } = false;
/// STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US".
public string SttLanguage { get; set; } = "auto";
diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
index 842ddcfd0..af1182069 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs
@@ -114,9 +114,10 @@ private void ReloadFeatureToggleStates()
{
if (CurrentApp.Settings == null || _featureToggles.Count == 0) return;
var s = CurrentApp.Settings;
- // Order matches BuildCapabilityToggles: browser, camera, canvas, screen, location, tts, stt.
+ // Order matches BuildCapabilityToggles: system-run, browser, camera, canvas, screen, location, tts, stt.
bool[] expected =
{
+ s.NodeSystemRunEnabled,
s.NodeBrowserProxyEnabled, s.NodeCameraEnabled, s.NodeCanvasEnabled,
s.NodeScreenEnabled, s.NodeLocationEnabled, s.NodeTtsEnabled, s.NodeSttEnabled,
};
@@ -151,6 +152,10 @@ private void BuildCapabilityToggles()
// OnToggleSideEffect runs after the new value is persisted.
var capabilities = new (string Icon, string Label, string Description, bool Value, Action Setter, Action? OnToggleSideEffect)[]
{
+ ("⚡",
+ LocalizationHelper.GetString("PermissionsPage_Cap_SystemRun_Label"),
+ LocalizationHelper.GetString("PermissionsPage_Cap_SystemRun_Description"),
+ settings.NodeSystemRunEnabled, v => settings.NodeSystemRunEnabled = v, null),
("🌐",
LocalizationHelper.GetString("PermissionsPage_Cap_Browser_Label"),
LocalizationHelper.GetString("PermissionsPage_Cap_Browser_Description"),
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
index d6cb2597d..db3d8afc0 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs
@@ -13,6 +13,11 @@ namespace OpenClawTray.Services;
/// Defaults: capabilities default ON (a missing or null settings object
/// counts as enabled) except tts.speak and stt.transcribe,
/// which are privacy-sensitive and require an explicit opt-in.
+///
+/// gates the system.run /
+/// system.run.prepare commands inside SystemCapability, not
+/// the whole system category — system.notify, system.which,
+/// and the exec-approval read/write commands stay registered regardless.
///
internal static class NodeCapabilityGating
{
@@ -44,4 +49,6 @@ internal static class NodeCapabilityGating
return localNode?.Capabilities?.ToArray();
}
+
+ public static bool ShouldRegisterSystemRun(SettingsManager? s) => s?.NodeSystemRunEnabled != false;
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 985914574..5ee2ebd42 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -275,8 +275,13 @@ private void RegisterCapabilities()
{
_capabilities.Clear();
- // System capability (notifications + command execution)
- _systemCapability = new SystemCapability(_logger);
+ // System capability (notifications + command execution). The
+ // "Run system tools" toggle gates the run/run.prepare commands
+ // inside the capability — the rest (notify/which/execApprovals)
+ // stay registered regardless.
+ _systemCapability = new SystemCapability(
+ _logger,
+ includeRunCommands: NodeCapabilityGating.ShouldRegisterSystemRun(_settings));
_systemCapability.NotifyRequested += OnSystemNotify;
_systemCapability.SetCommandRunner(BuildSystemRunRunner());
_systemCapability.SetApprovalPolicy(new ExecApprovalPolicy(_dataPath, _logger));
@@ -468,32 +473,17 @@ public void AttachClient(WindowsNodeClient client, string? bearerToken = null)
_logger.Info($"[NodeService] AttachClient: capabilitiesBuilt={capabilitiesBuilt}, _capabilities.Count={_capabilities.Count}");
- // First connect after app startup may not have built capability objects yet.
- // RegisterCapabilities() populates _capabilities and registers them on _nodeClient.
- if (!capabilitiesBuilt)
- {
- _logger.Info("[NodeService] AttachClient: capabilities not yet built, calling RegisterCapabilities()");
- RegisterCapabilities();
- }
- else
- {
- // Reconnect path: capabilities already exist, just re-bind to the new client.
- lock (_capabilitiesLock)
- {
- foreach (var capability in _capabilities)
- {
- try
- {
- client.RegisterCapability(capability);
- }
- catch (Exception ex)
- {
- _logger.Warn($"[NodeService] AttachClient: failed to register {capability.Category}: {ex.Message}");
- }
- }
- }
- _logger.Info($"[NodeService] AttachClient: re-registered {_capabilities.Count} capabilities on new client");
- }
+ // Always rebuild from current settings. The previous reconnect path
+ // re-registered the cached _capabilities instances, but _capabilities
+ // is only cleared in DisconnectAsync — which is never invoked on the
+ // reconnect path used by App.OnSettingsSaved (CapabilityReload calls
+ // ReconnectAsync, not DisconnectAsync). That left toggles like
+ // NodeCanvasEnabled / NodeSystemRunEnabled silently requiring a full
+ // app restart to take effect. RegisterCapabilities() clears the list,
+ // rebuilds with current settings, and registers on the new _nodeClient
+ // — correct for first-attach AND reconnect.
+ _logger.Info("[NodeService] AttachClient: rebuilding capabilities from current settings");
+ RegisterCapabilities();
// Log final registration state for diagnostics
_logger.Info($"[NodeService] AttachClient DONE: client.Registration.Capabilities={client.RegisteredCapabilityCount}, client.Registration.Commands={client.RegisteredCommandCount}");
@@ -764,6 +754,8 @@ private List BuildDisabledCommands()
disabled.AddRange(CommandCenterCommandGroups.SafeCompanionCommands.Where(command => command.StartsWith("location.", StringComparison.OrdinalIgnoreCase)));
if (_settings?.NodeBrowserProxyEnabled == false)
disabled.Add("browser.proxy");
+ if (_settings?.NodeSystemRunEnabled == false)
+ disabled.AddRange(new[] { "system.run", "system.run.prepare" });
if (_settings?.NodeSttEnabled != true)
disabled.Add(SttCapability.TranscribeCommand);
if (_settings?.NodeTtsEnabled != true)
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs
index 5094e4de6..48df000b1 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsDataExtensions.cs
@@ -21,5 +21,6 @@ public static class SettingsDataExtensions
settings.NodeBrowserProxyEnabled,
settings.NodeSttEnabled,
settings.NodeTtsEnabled,
+ settings.NodeSystemRunEnabled,
settings.ToJson());
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index ca0748947..765cea7c5 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -86,6 +86,13 @@ public class SettingsManager
public bool CameraRecordingConsentGiven { get; set; } = false;
public bool NodeLocationEnabled { get; set; } = true;
public bool NodeBrowserProxyEnabled { get; set; } = true;
+ ///
+ /// Master switch for the system.run / system.run.prepare
+ /// commands. Per-command exec approvals still apply when this is on;
+ /// flipping it off removes those commands from the declared capability
+ /// entirely. Default true (backward compatible).
+ ///
+ public bool NodeSystemRunEnabled { get; set; } = true;
public bool NodeSttEnabled { get; set; } = false;
/// STT language: "auto" for Whisper auto-detect, or a BCP-47 tag like "en-US".
public string SttLanguage { get; set; } = "auto";
@@ -199,6 +206,7 @@ public void Load()
CameraRecordingConsentGiven = loaded.CameraRecordingConsentGiven;
NodeLocationEnabled = loaded.NodeLocationEnabled;
NodeBrowserProxyEnabled = loaded.NodeBrowserProxyEnabled;
+ NodeSystemRunEnabled = loaded.NodeSystemRunEnabled;
NodeSttEnabled = loaded.NodeSttEnabled;
SttLanguage = string.IsNullOrWhiteSpace(loaded.SttLanguage) ? SttLanguage : loaded.SttLanguage;
SttModelName = string.IsNullOrWhiteSpace(loaded.SttModelName) ? SttModelName : loaded.SttModelName;
@@ -320,6 +328,7 @@ private void LoadLegacyGatewayCredentials(string json)
CameraRecordingConsentGiven = CameraRecordingConsentGiven,
NodeLocationEnabled = NodeLocationEnabled,
NodeBrowserProxyEnabled = NodeBrowserProxyEnabled,
+ NodeSystemRunEnabled = NodeSystemRunEnabled,
NodeSttEnabled = NodeSttEnabled,
SttLanguage = SttLanguage,
SttModelName = SttModelName,
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 052ca9801..bd443f1a3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2991,6 +2991,12 @@ On your gateway host (Mac/Linux), run:
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
+
+ Run system tools
+
+
+ Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 69c912520..36b169055 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2942,6 +2942,12 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
+
+ Run system tools
+
+
+ Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index fe766d944..583b9de27 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2943,6 +2943,12 @@ Voer op uw gateway-host (Mac/Linux) uit:
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
+
+ Run system tools
+
+
+ Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index c1b9c1ed9..0767d1607 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2942,6 +2942,12 @@
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
+
+ Run system tools
+
+
+ Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index c9218f538..e7b7030c9 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2942,6 +2942,12 @@
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
+
+ Run system tools
+
+
+ Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
index 61ee2e7fe..6352c9f8a 100644
--- a/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
+++ b/tests/OpenClaw.Connection.Tests/SettingsChangeImpactTests.cs
@@ -20,6 +20,7 @@ private static ConnectionSettingsSnapshot MakeSnapshot(
bool nodeBrowserProxyEnabled = false,
bool nodeSttEnabled = false,
bool nodeTtsEnabled = false,
+ bool nodeSystemRunEnabled = true,
string? fullSettingsJson = null) => new(
gatewayUrl,
useSshTunnel,
@@ -36,6 +37,7 @@ private static ConnectionSettingsSnapshot MakeSnapshot(
nodeBrowserProxyEnabled,
nodeSttEnabled,
nodeTtsEnabled,
+ nodeSystemRunEnabled,
fullSettingsJson);
[Fact]
@@ -96,6 +98,18 @@ public void CapabilityChanged_ReturnsCapabilityReload()
SettingsChangeClassifier.Classify(prev, next));
}
+ [Fact]
+ public void SystemRunCapabilityChanged_ReturnsCapabilityReload()
+ {
+ // Flipping the "Run system tools" toggle must trigger a capability
+ // reload — without it the App.OnSettingsSaved branch falls through to
+ // UiOnly and the connect-handshake commands list stays stale.
+ var prev = MakeSnapshot(gatewayUrl: "wss://test", nodeSystemRunEnabled: true);
+ var next = MakeSnapshot(gatewayUrl: "wss://test", nodeSystemRunEnabled: false);
+ Assert.Equal(SettingsChangeImpact.CapabilityReload,
+ SettingsChangeClassifier.Classify(prev, next));
+ }
+
[Fact]
public void UiOnlyChange_ReturnsUiOnly()
{
diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs
index 1add23f39..9317060cb 100644
--- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs
+++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs
@@ -756,6 +756,83 @@ public Task RunAsync(CommandRequest request, CancellationToken ct
});
}
}
+
+ [Fact]
+ public void Commands_IncludeRunByDefault()
+ {
+ // Backward-compatibility: the no-arg path used by every existing test
+ // and call-site must keep advertising system.run + system.run.prepare.
+ var cap = new SystemCapability(NullLogger.Instance);
+ Assert.Contains("system.run", cap.Commands);
+ Assert.Contains("system.run.prepare", cap.Commands);
+ Assert.Contains("system.notify", cap.Commands);
+ Assert.Contains("system.which", cap.Commands);
+ Assert.Contains("system.execApprovals.get", cap.Commands);
+ Assert.Contains("system.execApprovals.set", cap.Commands);
+ }
+
+ [Fact]
+ public void Commands_OmitRunWhenIncludeRunCommandsFalse()
+ {
+ // "Run system tools" toggle off: the run commands disappear from the
+ // declared command list (the handshake's connect message + MCP
+ // tools/list) while the rest of the system category stays.
+ var cap = new SystemCapability(NullLogger.Instance, includeRunCommands: false);
+ Assert.DoesNotContain("system.run", cap.Commands);
+ Assert.DoesNotContain("system.run.prepare", cap.Commands);
+ Assert.Contains("system.notify", cap.Commands);
+ Assert.Contains("system.which", cap.Commands);
+ Assert.Contains("system.execApprovals.get", cap.Commands);
+ Assert.Contains("system.execApprovals.set", cap.Commands);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SystemRun_ReturnsDisabledErrorWhenIncludeRunCommandsFalse()
+ {
+ // Even if a stale gateway allowlist still routes system.run to us
+ // (commands are snapshotted at pairing-approval time), the capability
+ // must refuse before any V2/legacy dispatch runs.
+ var cap = new SystemCapability(NullLogger.Instance, includeRunCommands: false);
+ var resp = await cap.ExecuteAsync(new NodeInvokeRequest
+ {
+ Id = "rn1",
+ Command = "system.run",
+ Args = Parse("""{"cmd":"echo hello"}""")
+ });
+ Assert.False(resp.Ok);
+ Assert.Contains("Run system tools", resp.Error ?? "", StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SystemRunPrepare_ReturnsDisabledErrorWhenIncludeRunCommandsFalse()
+ {
+ var cap = new SystemCapability(NullLogger.Instance, includeRunCommands: false);
+ var resp = await cap.ExecuteAsync(new NodeInvokeRequest
+ {
+ Id = "rp1",
+ Command = "system.run.prepare",
+ Args = Parse("""{"cmd":"echo hello"}""")
+ });
+ Assert.False(resp.Ok);
+ Assert.Contains("Run system tools", resp.Error ?? "", StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_OtherSystemCommands_StillWorkWhenIncludeRunCommandsFalse()
+ {
+ // system.notify must keep working — the toggle only gates the run
+ // commands. Notifications and the exec-approval reader/writer stay
+ // available so the user can still inspect their policy from the
+ // operator console.
+ var cap = new SystemCapability(NullLogger.Instance, includeRunCommands: false);
+ var resp = await cap.ExecuteAsync(new NodeInvokeRequest
+ {
+ Id = "n1",
+ Command = "system.notify",
+ Args = Parse("""{"title":"Hello","body":"World","sound":false}""")
+ });
+ Assert.True(resp.Ok);
+ }
}
public class BrowserProxyCapabilityTests
diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
index c25614c46..f60bd5778 100644
--- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
+++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs
@@ -4,6 +4,7 @@
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Shared;
+using OpenClaw.Shared.Capabilities;
using OpenClaw.Shared.Mcp;
using Xunit;
@@ -464,6 +465,58 @@ public async Task ToolsList_AllStt_Absent_WhenSttCapabilityNotRegistered()
Assert.DoesNotContain("stt.status", toolNames);
}
+ [Fact]
+ public async Task ToolsList_SystemRun_Present_WhenSystemCapabilityIncludesRunCommands()
+ {
+ // Default SystemCapability (includeRunCommands: true) — the
+ // "Run system tools" toggle is on. tools/list advertises both
+ // system.run and system.run.prepare.
+ var caps = new List
+ {
+ new SystemCapability(OpenClaw.Shared.NullLogger.Instance),
+ };
+ var bridge = CreateBridge(caps);
+ var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}");
+
+ using var doc = JsonDocument.Parse(resp!);
+ var toolNames = new HashSet();
+ foreach (var t in doc.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray())
+ toolNames.Add(t.GetProperty("name").GetString()!);
+
+ Assert.Contains("system.run", toolNames);
+ Assert.Contains("system.run.prepare", toolNames);
+ // The rest of the system category is always present.
+ Assert.Contains("system.notify", toolNames);
+ Assert.Contains("system.which", toolNames);
+ }
+
+ [Fact]
+ public async Task ToolsList_SystemRun_Absent_WhenSystemCapabilityExcludesRunCommands()
+ {
+ // "Run system tools" toggle off: NodeService constructs
+ // SystemCapability(includeRunCommands: false). The MCP tools/list
+ // must drop the two run commands but keep the rest of the system
+ // category (notify/which/execApprovals).
+ var caps = new List
+ {
+ new SystemCapability(OpenClaw.Shared.NullLogger.Instance, includeRunCommands: false),
+ };
+ var bridge = CreateBridge(caps);
+ var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}");
+
+ using var doc = JsonDocument.Parse(resp!);
+ var toolNames = new HashSet();
+ foreach (var t in doc.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray())
+ toolNames.Add(t.GetProperty("name").GetString()!);
+
+ Assert.DoesNotContain("system.run", toolNames);
+ Assert.DoesNotContain("system.run.prepare", toolNames);
+ Assert.Contains("system.notify", toolNames);
+ Assert.Contains("system.which", toolNames);
+ Assert.Contains("system.execApprovals.get", toolNames);
+ Assert.Contains("system.execApprovals.set", toolNames);
+ }
+
[Fact]
public async Task Initialize_ReturnsCustomServerNameAndVersion()
{
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index 655a27bba..d10190b4a 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -77,6 +77,8 @@ public class LocalizationValidationTests
"PermissionsPage_Cap_Tts_Description",
"PermissionsPage_Cap_Stt_Label",
"PermissionsPage_Cap_Stt_Description",
+ "PermissionsPage_Cap_SystemRun_Label",
+ "PermissionsPage_Cap_SystemRun_Description",
"PermissionsPage_SttHint_Ready",
"PermissionsPage_SttHint_Downloading",
"PermissionsPage_SttHint_FailedFormat",
diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
index 696710575..b9d747614 100644
--- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
+++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
@@ -43,6 +43,7 @@ public void NullSettings_DefaultOnCapabilities_AreEnabled()
Assert.True(NodeCapabilityGating.ShouldRegisterCamera(null));
Assert.True(NodeCapabilityGating.ShouldRegisterLocation(null));
Assert.True(NodeCapabilityGating.ShouldRegisterBrowserProxy(null));
+ Assert.True(NodeCapabilityGating.ShouldRegisterSystemRun(null));
}
[Fact]
@@ -72,6 +73,18 @@ public void DefaultSettings_OtherCapabilities_AreEnabled()
Assert.True(NodeCapabilityGating.ShouldRegisterCamera(s));
Assert.True(NodeCapabilityGating.ShouldRegisterLocation(s));
Assert.True(NodeCapabilityGating.ShouldRegisterBrowserProxy(s));
+ Assert.True(NodeCapabilityGating.ShouldRegisterSystemRun(s));
+ }
+
+ [Fact]
+ public void SystemRun_OnlyDisabledWhenExplicitlySetToFalse()
+ {
+ var s = NewSettings();
+ Assert.True(NodeCapabilityGating.ShouldRegisterSystemRun(s));
+ s.NodeSystemRunEnabled = false;
+ Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s));
+ s.NodeSystemRunEnabled = true;
+ Assert.True(NodeCapabilityGating.ShouldRegisterSystemRun(s));
}
[Fact]
@@ -122,11 +135,13 @@ public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse()
s.NodeCameraEnabled = false;
s.NodeLocationEnabled = false;
s.NodeBrowserProxyEnabled = false;
+ s.NodeSystemRunEnabled = false;
Assert.False(NodeCapabilityGating.ShouldRegisterCanvas(s));
Assert.False(NodeCapabilityGating.ShouldRegisterScreen(s));
Assert.False(NodeCapabilityGating.ShouldRegisterCamera(s));
Assert.False(NodeCapabilityGating.ShouldRegisterLocation(s));
Assert.False(NodeCapabilityGating.ShouldRegisterBrowserProxy(s));
+ Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s));
}
}
From ffb59394e88bbc3c7248460dbbbc9782b59e70cc Mon Sep 17 00:00:00 2001
From: Mike Harsh
Date: Wed, 20 May 2026 23:15:16 -0700
Subject: [PATCH 038/115] Fix Windows node gateway metadata (#475)
* Fix Windows node gateway metadata
Send canonical Windows device family metadata during node connect so upstream gateway command policy can recognize the first-party Windows desktop node without configuring a global command allowlist.
Also normalize v3 device auth metadata to match gateway signature verification.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Run device auth metadata test by default
Move the v3 platform metadata normalization contract from IntegrationFact to Fact so default Shared test runs cover the gateway signature contract.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Mike Harsh
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/gateway-node-integration.md | 163 +++++-------------
src/OpenClaw.Shared/DeviceIdentity.cs | 19 +-
src/OpenClaw.Shared/NodeCapabilities.cs | 1 +
src/OpenClaw.Shared/WindowsNodeClient.cs | 10 +-
.../Services/NodeService.cs | 1 +
.../DeviceIdentityTests.cs | 25 +++
.../WindowsNodeClientTests.cs | 24 +++
7 files changed, 122 insertions(+), 121 deletions(-)
diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md
index 18b67b024..d2f254aaf 100644
--- a/docs/gateway-node-integration.md
+++ b/docs/gateway-node-integration.md
@@ -28,11 +28,14 @@ The gateway has hardcoded defaults per platform (from `PLATFORM_DEFAULTS`):
| **macOS** | canvas.*, camera.list, location.get, device.info/status, contacts.search, calendar.events, reminders.list, photos.latest, motion.*, system.run/which/notify, screen.snapshot, browser.proxy |
| **iOS** | canvas.*, camera.list, location.get, device.info/status, contacts.*, calendar.*, reminders.*, photos.latest, motion.*, system.notify |
| **Android** | canvas.*, camera.list, location.get, notifications.*, device.*, contacts.*, calendar.*, callLog.search, reminders.*, photos.latest, motion.*, system.notify |
-| **Windows** | **system.run, system.run.prepare, system.which, system.notify, browser.proxy** |
+| **Windows** | camera.list, location.get, device.info, device.status, system.*, browser.proxy, screen.snapshot |
| **Linux** | system.run, system.run.prepare, system.which, system.notify, browser.proxy |
| **Unknown** | canvas.*, camera.list, location.get, system.notify |
-**Windows and Linux get almost nothing by default** — only system commands. No canvas, no camera, no screen, no location. This is because Windows/Linux were originally designed as headless "node host" platforms (exec-only), not full companion apps like macOS/iOS.
+Desktop host commands such as `system.run`, `browser.proxy`, and
+`screen.snapshot` are filtered unless the node reports canonical desktop
+metadata and the command is approved through pairing/live session state or
+explicit config.
### 1.2 "Dangerous" Commands (Always Need Explicit Opt-In)
@@ -49,46 +52,21 @@ SMS_DANGEROUS_COMMANDS = ["sms.send", "sms.search"]
Even macOS doesn't get `camera.snap` or `camera.clip` by default! They must be added via `gateway.nodes.allowCommands`.
-### 1.3 How to Enable Commands for Windows
+### 1.3 How to Enable Privacy-Sensitive Commands for Windows
-Add ALL needed commands to `gateway.nodes.allowCommands` in `~/.openclaw/openclaw.json`:
+Normal first-party Windows companion commands should work after pairing when the
+node reports canonical `platform: "windows"` and `deviceFamily: "Windows"`.
+Only add privacy-sensitive commands when you explicitly want the gateway to
+allow camera capture or screen recording:
```json5
{
gateway: {
nodes: {
allowCommands: [
- // Canvas
- "canvas.present",
- "canvas.hide",
- "canvas.navigate",
- "canvas.eval",
- "canvas.snapshot",
- "canvas.a2ui.push",
- "canvas.a2ui.pushJSONL",
- "canvas.a2ui.reset",
- // Camera (all are dangerous or not in Windows defaults)
- "camera.list",
"camera.snap",
"camera.clip",
- // Screen
- "screen.snapshot",
"screen.record",
- // Location
- "location.get",
- // Device metadata/status
- "device.info",
- "device.status",
- // Text-to-speech playback (enable only when agent-driven audio is desired)
- "tts.speak",
- // System (already in Windows defaults, but listed for completeness)
- // "system.run",
- // "system.run.prepare",
- // "system.which",
- // "system.notify",
- // Exec approvals
- "system.execApprovals.get",
- "system.execApprovals.set",
]
}
}
@@ -199,29 +177,19 @@ client: {
}
```
-Detection logic (from `node-command-policy.ts`):
-1. Normalize `platform` → lowercase
-2. Match against prefix rules: `"win"` → windows, `"mac"/"darwin"` → macos, etc.
-3. If no match, try `deviceFamily` field
-4. If still no match → `"unknown"` (gets conservative defaults)
+Detection logic (from `node-command-policy.ts`) now treats desktop command
+defaults as a stricter, canonical-platform path:
-Our node sends `platform: "windows"` which correctly matches the `windows` prefix rule.
+1. Normalize `platform` and `deviceFamily`.
+2. Match only canonical platform IDs such as `windows`, `macos`, and `linux`.
+3. Require desktop platforms to have a matching desktop family, for example
+ `platform: "windows"` with `deviceFamily: "Windows"`.
+4. If metadata is missing or noncanonical, fall back to `"unknown"` and a
+ conservative allowlist.
-**The problem isn't detection — it's that the `windows` platform intentionally gets a minimal allowlist.** The gateway team designed Windows as a headless exec host, not a full companion app with camera/canvas/screen.
-
-### 3.1 What "Unknown" Gets (and Why It's Actually Better)
-
-Ironically, the `unknown` platform gets MORE than Windows:
-```typescript
-unknown: [
- ...CANVAS_COMMANDS,
- ...CAMERA_COMMANDS, // camera.list
- ...LOCATION_COMMANDS, // location.get
- NODE_SYSTEM_NOTIFY_COMMAND,
-]
-```
-
-If we sent `platform: "windows-desktop"` (which wouldn't match any prefix rule), we'd fall through to `unknown` and actually get canvas/camera/location defaults. But that would be a hack — the right fix is `gateway.nodes.allowCommands`.
+Our node should therefore send canonical Windows metadata instead of relying on
+gateway-wide `gateway.nodes.allowCommands` for the normal first-party Windows
+companion flow.
---
@@ -339,22 +307,20 @@ Recommended gateway defaults:
| Dangerous/privacy-heavy commands: `camera.snap`, `camera.clip`, `screen.record`, write commands like `contacts.add` | No | Existing gateway model already requires explicit `gateway.nodes.allowCommands` |
| Exec commands: `system.run`, `system.run.prepare`, `system.which`, `system.notify`, `browser.proxy` | Yes | Existing Windows headless-host behavior |
-Until the gateway expands Windows safe defaults, the practical local solution is:
+For the first-party Windows companion node, the practical local solution is:
1. Keep declaring the correct command names from the Windows node.
-2. Configure `gateway.nodes.allowCommands` for the Windows companion features.
+2. Send canonical connect metadata: `platform: "windows"` and
+ `deviceFamily: "Windows"`.
3. Re-pair after command-list changes because the gateway snapshots commands at approval time.
### 5.1 Gateway Node Allowlist Configuration
-`gateway.nodes.allowCommands` is the explicit opt-in list the gateway uses after platform defaults. It should contain exact command names, not broad wildcard grants, for commands that are safe but not yet in the Windows default policy.
-
-Recommended safe Windows companion allowlist:
-
-```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"]'
-openclaw gateway restart
-```
+`gateway.nodes.allowCommands` is the explicit opt-in list the gateway uses after
+platform defaults. It should contain exact command names, not broad wildcard
+grants, and should not be needed for the normal first-party Windows companion
+commands that are allowed by canonical Windows platform policy and declared by
+the live node.
`gateway.nodes.denyCommands` can be used as a final explicit blocklist when you want to suppress a command even if a platform default or allowlist entry would otherwise allow it.
@@ -384,66 +350,29 @@ After changing either `gateway.nodes.allowCommands` or `gateway.nodes.denyComman
- [x] Handle bootstrap token expiry gracefully when setup code payloads include expiry metadata (`expiresAt`, `expires_at`, `expires`, `expiry`, or `exp`)
- [x] Add Settings toggles for optional Windows node capability groups (`canvas`, `screen`, `camera`, `location`, `browser.proxy`)
-### 5.4 Upstream Contributions / Issues to File
+### 5.4 Upstream Alignment
-- [x] **Request Windows/macOS parity for safe declared commands** — Windows should allow the same safe companion commands macOS does, while dangerous commands stay explicit opt-in. Draft included below.
-- [x] **Document `gateway.nodes.allowCommands`** — local Windows integration docs now describe allowCommands, denyCommands, safe parity commands, privacy-sensitive opt-ins, and re-pair requirements.
+- [x] **Use canonical Windows node metadata** — Windows sends
+ `platform: "windows"` and `deviceFamily: "Windows"` so the gateway can apply
+ desktop command policy without a global allowlist workaround.
+- [x] **Keep privacy-sensitive commands explicit opt-in** — `camera.snap`,
+ `camera.clip`, and `screen.record` remain behind `gateway.nodes.allowCommands`.
- [x] **Add `canvas.a2ui.pushJSONL`** — current Mac supports it as a legacy JSONL alias; Windows routes it through the same A2UI push handler
-#### Upstream issue draft
-
-**Title:** Expand Windows node default allowlist for safe declared companion commands
-
-**Body:**
-
-Windows nodes are currently treated like Linux/headless exec hosts in `src/gateway/node-command-policy.ts`:
-
-```ts
-windows: [...SYSTEM_COMMANDS]
-```
-
-That means the gateway filters out safe companion-app commands that a Windows node explicitly declares, including `canvas.*`, `camera.list`, `location.get`, and `screen.snapshot`. The Windows tray app is now a full companion node, not just an exec host, so this causes confusing behavior: the node can implement and advertise a command, but the gateway drops/rejects it unless users manually configure `gateway.nodes.allowCommands`.
-
-Proposal:
-
-- Add safe declared companion commands to Windows defaults, similar to macOS:
- - `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`
-- Keep dangerous/privacy-heavy commands explicit opt-in via `gateway.nodes.allowCommands`:
- - `camera.snap`
- - `camera.clip`
- - `screen.record`
- - 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.
-
-Related documentation gap: `gateway.nodes.allowCommands` and `gateway.nodes.denyCommands` should be documented in the gateway configuration reference, including the requirement to re-pair after command-list changes because approved pairing records snapshot declared commands.
+The gateway still enforces both gates: the node must declare a command in
+`commands`, and gateway policy must allow it. Headless Windows node hosts that
+only declare `system.run` / `system.which` remain exec-only.
### 5.5 User-Facing Documentation
-When shipping the Windows node, README/wiki should tell users:
-
-> **First-time setup**: After pairing your Windows node, add these commands to your gateway config:
-> ```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", "screen.snapshot", "location.get", "device.info", "device.status", "system.execApprovals.get", "system.execApprovals.set"]'
-> openclaw gateway restart
-> ```
-> Then re-pair the node (`openclaw devices reject ` + re-approve).
->
-> Add `camera.snap`, `camera.clip`, and `screen.record` only when you explicitly want to allow privacy-sensitive camera or screen capture.
->
-> The Windows tray Command Center (`openclaw://commandcenter`) surfaces these policy problems directly: it separates safe companion allowlist fixes from privacy-sensitive opt-ins and provides copyable repair text for safe fixes or pending pairing approval.
+When shipping the Windows node, README/wiki should tell users that normal
+first-party companion commands are available after pairing when the node reports
+canonical Windows metadata. Users should add `camera.snap`, `camera.clip`, and
+`screen.record` to `gateway.nodes.allowCommands` only when they explicitly want
+to allow privacy-sensitive camera or screen capture.
+> The Windows tray Command Center (`openclaw://commandcenter`) surfaces policy
+> problems directly, including pending pairing approval and privacy-sensitive
+> opt-ins.
---
diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs
index b21bd82fc..3e3d6eddc 100644
--- a/src/OpenClaw.Shared/DeviceIdentity.cs
+++ b/src/OpenClaw.Shared/DeviceIdentity.cs
@@ -330,7 +330,24 @@ public string BuildConnectPayloadV3(
var safeToken = authToken ?? string.Empty;
var safeNonce = nonce ?? string.Empty;
- return $"v3|{_deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{safeToken}|{safeNonce}|{platform}|{deviceFamily}";
+ return $"v3|{_deviceId}|{clientId}|{clientMode}|{role}|{scopesCsv}|{signedAtMs}|{safeToken}|{safeNonce}|{NormalizeAuthMetadata(platform)}|{NormalizeAuthMetadata(deviceFamily)}";
+ }
+
+ private static string NormalizeAuthMetadata(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return string.Empty;
+
+ var trimmed = value.Trim();
+ var builder = new StringBuilder(trimmed.Length);
+ foreach (var character in trimmed)
+ {
+ builder.Append(character is >= 'A' and <= 'Z'
+ ? (char)(character + 32)
+ : character);
+ }
+
+ return builder.ToString();
}
///
diff --git a/src/OpenClaw.Shared/NodeCapabilities.cs b/src/OpenClaw.Shared/NodeCapabilities.cs
index 803e4e21f..1c3feed2a 100644
--- a/src/OpenClaw.Shared/NodeCapabilities.cs
+++ b/src/OpenClaw.Shared/NodeCapabilities.cs
@@ -198,6 +198,7 @@ public class NodeRegistration
public string Id { get; set; } = "";
public string Version { get; set; } = "";
public string Platform { get; set; } = "windows";
+ public string DeviceFamily { get; set; } = "Windows";
public string DisplayName { get; set; } = "";
public List Capabilities { get; set; } = new();
public List Commands { get; set; } = new();
diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs
index 53062377d..28fac6285 100644
--- a/src/OpenClaw.Shared/WindowsNodeClient.cs
+++ b/src/OpenClaw.Shared/WindowsNodeClient.cs
@@ -22,6 +22,8 @@ public class WindowsNodeClient : WebSocketClientBase
private readonly List _capabilities = new();
private FrozenDictionary _commandMap = FrozenDictionary.Empty;
private readonly NodeRegistration _registration;
+ private const string WindowsPlatform = "windows";
+ private const string WindowsDeviceFamily = "Windows";
// Connection state
private bool _isConnected;
@@ -121,7 +123,8 @@ public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpen
{
Id = _deviceIdentity.DeviceId,
Version = "1.0.0",
- Platform = "windows",
+ Platform = WindowsPlatform,
+ DeviceFamily = WindowsDeviceFamily,
DisplayName = $"Windows Node ({Environment.MachineName})"
};
}
@@ -592,7 +595,7 @@ private async Task SendNodeConnectAsync(string? nonce, long ts)
_logger.Info($"[HANDSHAKE] isBootstrap={usingBootstrap}, hasNodeDeviceToken={isPaired}");
_logger.Info($"[HANDSHAKE] deviceId={_deviceIdentity.DeviceId[..Math.Min(16, _deviceIdentity.DeviceId.Length)]}...");
_logger.Info($"[HANDSHAKE] nonce={nonce?[..Math.Min(15, nonce?.Length ?? 0)]}...");
- _logger.Info($"[HANDSHAKE] signature format={(_useV2Signature ? "v2" : "v3")}, platform=windows, family=desktop");
+ _logger.Info($"[HANDSHAKE] signature format={(_useV2Signature ? "v2" : "v3")}, platform={_registration.Platform}, family={_registration.DeviceFamily}");
_logger.Info($"[HANDSHAKE] auth: {{{authType}}}");
await SendRawAsync(BuildNodeConnectMessage(nonce, ts));
@@ -616,7 +619,7 @@ private string BuildNodeConnectMessage(string? nonce, long ts)
: _deviceIdentity.SignConnectPayloadV3(
nonce, signedAt, ClientId, "node", "node",
Array.Empty(), tokenForSignature,
- "windows", "desktop");
+ _registration.Platform, _registration.DeviceFamily);
}
catch (Exception ex)
{
@@ -639,6 +642,7 @@ private string BuildNodeConnectMessage(string? nonce, long ts)
id = ClientId, // Must match what we sign in payload
version = _registration.Version,
platform = _registration.Platform,
+ deviceFamily = _registration.DeviceFamily,
mode = "node",
displayName = _registration.DisplayName
},
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 5ee2ebd42..7ff87b861 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -730,6 +730,7 @@ public void SetMcpEnabled(bool enabled)
Mode = "node",
Status = IsConnected ? "connected" : "disconnected",
Platform = "windows",
+ DeviceFamily = "Windows",
LastSeen = DateTime.UtcNow,
IsOnline = IsConnected,
Capabilities = capabilities,
diff --git a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
index e9848ff5d..4bc6d859c 100644
--- a/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
+++ b/tests/OpenClaw.Shared.Tests/DeviceIdentityTests.cs
@@ -153,6 +153,31 @@ public void BuildConnectPayloadV3_HasCorrectFormat()
finally { Directory.Delete(dir, true); }
}
+ [Fact]
+ public void BuildConnectPayloadV3_NormalizesPlatformMetadataForGatewayAuth()
+ {
+ var dir = CreateTempDir();
+ try
+ {
+ var identity = new DeviceIdentity(dir);
+ identity.Initialize();
+
+ var payload = identity.BuildConnectPayloadV3(
+ nonce: "challenge-nonce",
+ signedAtMs: 1711648000000,
+ clientId: "node-host",
+ clientMode: "node",
+ role: "node",
+ scopes: Array.Empty(),
+ authToken: "mytoken123",
+ platform: " Windows ",
+ deviceFamily: " Windows ");
+
+ Assert.EndsWith("|windows|windows", payload);
+ }
+ finally { Directory.Delete(dir, true); }
+ }
+
[IntegrationFact]
public void BuildConnectPayloadV2_HasCorrectFormat()
{
diff --git a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
index ab44651ca..f386d602f 100644
--- a/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/WindowsNodeClientTests.cs
@@ -871,6 +871,30 @@ public void BuildNodeConnectMessage_UsesGatewayToken_WhenNoBootstrapOrDeviceToke
}
}
+ [Fact]
+ public void BuildNodeConnectMessage_IncludesCanonicalWindowsDeviceFamily()
+ {
+ var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dataPath);
+
+ try
+ {
+ using var client = new WindowsNodeClient("ws://localhost:18789", "gateway-token", dataPath);
+
+ var json = InvokeBuildNodeConnectMessage(client);
+ using var doc = JsonDocument.Parse(json);
+ var clientPayload = doc.RootElement.GetProperty("params").GetProperty("client");
+
+ Assert.Equal("windows", clientPayload.GetProperty("platform").GetString());
+ Assert.Equal("Windows", clientPayload.GetProperty("deviceFamily").GetString());
+ }
+ finally
+ {
+ if (Directory.Exists(dataPath))
+ Directory.Delete(dataPath, true);
+ }
+ }
+
[Fact]
public void RegisterCapability_AddsToCapabilitiesListAndRegistration()
{
From e9d545423e38715043e72adfa8f71975c7e0ddcc Mon Sep 17 00:00:00 2001
From: Barbara Kudiess
Date: Thu, 21 May 2026 00:18:07 -0700
Subject: [PATCH 039/115] polish(tray): tighten 'System tools' strings + add
missing tray toggle
* polish(tray): rename toggle to 'System tools' with shorter description
Aligns with the existing Permissions page convention (concise 'Lets agents
... on this PC.' template, no internal jargon, no command identifiers).
Label: Run system tools -> System tools
Description: drops 'system.run', 'exec approval policy', and 'drop the
capability from the node entirely' jargon. New text:
'Lets agents run shell commands and scripts on this PC. Turn this off
to block all command execution.'
Applied across en-us/fr-fr/nl-nl/zh-cn/zh-tw (English placeholders,
matching the existing pattern for not-yet-translated keys).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(tray): add 'System tools' toggle to tray right-click menu
The tray right-click menu has toggles for every other capability
(Browser, Camera, Canvas, Screen, Location, TTS, STT) but was missing
one for system.run, even though the Permissions page added it. Users
had to open Settings -> Permissions to flip it, which made the tray
menu feel inconsistent.
Adds:
- FluentIconCatalog.Terminal (\\uE756 CommandPrompt) - distinct from
the existing System glyph which already represents 'this PC as a node'
- AddPermToggle for NodeSystemRunEnabled in TrayMenuStateBuilder,
placed right after 'Windows node' to match the Permissions page
ordering (system tools first among capabilities)
- FluentIconCatalogTests entry for the new constant
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs | 1 +
src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs | 3 +++
src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw | 4 ++--
src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw | 4 ++--
src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw | 4 ++--
src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw | 4 ++--
src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw | 4 ++--
tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs | 2 +-
8 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index eb6a9bb9b..5c8137d2f 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -37,6 +37,7 @@ public static class FluentIconCatalog
public const string Voice = "\uE767"; // Volume (speaker, for TTS)
public const string Speech = "\uF12E"; // Dictate (speech-to-text)
public const string System = "\uE839"; // PC1 — "this PC as a node" (per CDR-0001 — was TVMonitor \uE7F4)
+ public const string Terminal = "\uE756"; // CommandPrompt — system.run shell-command capability
public const string Operator = "\uE77B"; // ContactInfo — operator role (a human controlling agents)
// ── Actions ────────────────────────────────────────────────────
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
index 483fcebd6..7b0ac13f7 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
@@ -1290,6 +1290,9 @@ private List BuildPermissionsFlyoutItems(SettingsManager set
AddPermToggle(items, "Windows node", FluentIconCatalog.System,
"Run OpenClaw as a local node on this PC",
() => settings.EnableNodeMode, v => settings.EnableNodeMode = v);
+ AddPermToggle(items, "System tools", FluentIconCatalog.Terminal,
+ "Let agents run shell commands and scripts on this PC",
+ () => settings.NodeSystemRunEnabled, v => settings.NodeSystemRunEnabled = v);
AddPermToggle(items, "Browser control", FluentIconCatalog.Browser,
"Let agents drive web browsers via proxy",
() => settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v);
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index bd443f1a3..21bdcea66 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2992,10 +2992,10 @@ On your gateway host (Mac/Linux), run:
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
- Run system tools
+ System tools
- Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+ Lets agents run shell commands and scripts on this PC. Turn this off to block all command execution.
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 36b169055..47c68f7b2 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2943,10 +2943,10 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
- Run system tools
+ System tools
- Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+ Lets agents run shell commands and scripts on this PC. Turn this off to block all command execution.
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 583b9de27..c2309fd62 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2944,10 +2944,10 @@ Voer op uw gateway-host (Mac/Linux) uit:
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
- Run system tools
+ System tools
- Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+ Lets agents run shell commands and scripts on this PC. Turn this off to block all command execution.
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 0767d1607..f485e0439 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2943,10 +2943,10 @@
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
- Run system tools
+ System tools
- Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+ Lets agents run shell commands and scripts on this PC. Turn this off to block all command execution.
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index e7b7030c9..e45c2e503 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2943,10 +2943,10 @@
Lets agents transcribe microphone audio locally using Whisper. Turning this on downloads the Whisper model (~140 MB) on first use.
- Run system tools
+ System tools
- Lets agents run shell commands on this PC via system.run. Individual commands are still gated by your exec approval policy; turn this off to drop the capability from the node entirely.
+ Lets agents run shell commands and scripts on this PC. Turn this off to block all command execution.
Whisper model is ready. Speech-to-text runs fully on this PC; no audio leaves the device.
diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
index a196e2711..5fad700f4 100644
--- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
@@ -18,7 +18,7 @@ public sealed class FluentIconCatalogTests
{
"StatusOk", "StatusWarn", "StatusErr",
"Sessions", "Approvals", "Devices", "Hostname", "Permissions",
- "Browser", "Camera", "Canvas", "Screen", "Location", "Voice", "Speech", "System", "Operator",
+ "Browser", "Camera", "Canvas", "Screen", "Location", "Voice", "Speech", "System", "Terminal", "Operator",
"Dashboard", "OpenInBrowser", "Chat", "CanvasAct", "VoiceAct", "Settings", "QuickSend",
"Setup", "About", "Exit",
"Add", "Back", "Sync", "Lock", "Plug", "MoreOverflow",
From 9478e953dc3877167ecf5b9e4edb68877fa7d9ac Mon Sep 17 00:00:00 2001
From: Christine Yan <34801076+christineyan4@users.noreply.github.com>
Date: Thu, 21 May 2026 13:45:00 -0400
Subject: [PATCH 040/115] feat: tool metadata cache, tool call toggle,
stale-closure fix, and perf optimizations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: tool metadata cache, tool call toggle, and stale-closure fix
- Cache tool metadata (name, label) to local JSON so tool call types
persist across app restarts instead of falling back to 'exec'
- Improve ClassifyFlattenedToolOutput heuristic to detect bash, view,
grep, git, edit, glob from output text patterns
- Add tool call show/hide toggle button to composer toolbar with
tooltips on all toolbar buttons
- Add CompactSummary as default tool burst style with expand/collapse
- Fix stale-closure bug in ChatExplorationState.Changed handlers
across Timeline, Composer, ChatRoot, and ExplorationsPanel — event
handler captured initial UseState value (0), so Set(1) only triggered
one re-render; subsequent toggles were no-ops
- Optimize SyncChildren with fast path for empty panels (skip
RemoveFromParent O(n) scan) and pre-clear panel on visibility toggle
- Enable NavigationCacheMode on ChatPage to avoid full reload on
navigation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: debounce cache writes, atomic file save, closure fixes, and new tests
- Debounce SaveToolMetaCache with 500ms timer instead of Task.Run per event
- Atomic file write (write .tmp then File.Move) to prevent partial JSON
- ChatRoot: read explorationRevRef.Current inside dispatcher lambda
- Revert in-place HashSet mutation to proper Set(new HashSet)
- Add ToolMetaCacheTests (10 tests): sequential matching, exhaustion, guards
- Add classifier edge case tests (9 tests): view, git, exec defaults
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(tray): serialize tool metadata cache writes
Move tool metadata timer replacement under the provider lock, version debounced saves so stale timer callbacks cannot overwrite newer snapshots, and serialize unique-temp-file writes to avoid cache corruption under rapid tool events.
Add coverage that concurrent metadata additions flush complete valid JSON without leaving temporary files behind.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Christine Yan
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Scott Hanselman
---
.../Chat/Explorations/ChatExplorationState.cs | 16 +-
.../Explorations/ChatExplorationsPanel.cs | 7 +-
.../Chat/FunctionalChatHostExtensions.cs | 4 +-
.../Chat/OpenClawChatDataProvider.cs | 316 +++++++++++++++++-
.../Chat/OpenClawChatRoot.cs | 6 +-
.../Chat/OpenClawChatTimeline.cs | 49 ++-
.../Chat/OpenClawComposer.cs | 30 +-
src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml | 3 +-
.../Pages/ChatPage.xaml.cs | 3 +-
src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 26 +-
.../FlattenedToolOutputDetectionTests.cs | 40 ++-
.../OpenClaw.Tray.Tests/ToolMetaCacheTests.cs | 223 ++++++++++++
12 files changed, 686 insertions(+), 37 deletions(-)
create mode 100644 tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
index 3abd1fe24..eadcbc6fe 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
@@ -340,6 +340,20 @@ public static bool ShowToolCalls
set { if (_showToolCalls != value) { _showToolCalls = value; RaiseChanged(); } }
}
+ ///
+ /// Monotonic counter incremented when all tool chip expanded states
+ /// should be reset. The timeline checks this and clears its local
+ /// expandedToolChips set when the value changes.
+ ///
+ public static int CollapseToolChipsVersion { get; internal set; }
+
+ /// Signal all tool chip expanded states to collapse.
+ public static void CollapseAllToolChips()
+ {
+ CollapseToolChipsVersion++;
+ RaiseChanged();
+ }
+
public static double BubbleMaxWidth
{
get => _bubbleMaxWidth;
@@ -394,7 +408,7 @@ public static bool ShowContextPercent
// ---- Tool burst (H) ----
- private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.Plain;
+ private static ToolBurstStyle _toolBurstStyle = ToolBurstStyle.CompactSummary;
private static bool _showStepNumbers;
///
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
index d9a555b8c..fbaed1964 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
@@ -25,9 +25,14 @@ public class ChatExplorationsPanel : Component
public override Element Render()
{
var rev = UseState(0, threadSafe: true);
+ var revRef = UseRef(0);
UseEffect((Func)(() =>
{
- EventHandler h = (_, _) => rev.Set(rev.Value + 1);
+ EventHandler h = (_, _) =>
+ {
+ revRef.Current++;
+ rev.Set(revRef.Current);
+ };
ChatExplorationState.Changed += h;
return () => ChatExplorationState.Changed -= h;
}));
diff --git a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs
index 0d9a2e075..a267925f7 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/FunctionalChatHostExtensions.cs
@@ -53,7 +53,8 @@ public static MountedFunctionalChat MountFunctionalChat(
Action? onSettingsClick = null,
Action? onSpeakerMuteChanged = null,
bool initialMuted = false,
- bool isCompact = false)
+ bool isCompact = false,
+ bool suppressAutoDispose = false)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(target);
@@ -61,6 +62,7 @@ public static MountedFunctionalChat MountFunctionalChat(
var root = new OpenClawChatRoot(provider, initialThreadId, onReadAloud, onStopSpeaking, onVoiceRequest, onAttachClick, onSettingsClick, onSpeakerMuteChanged, initialMuted, isCompact);
var host = new FunctionalHostControl();
+ host.SuppressAutoDispose = suppressAutoDispose;
host.Mount(root);
target.Child = host;
return new MountedFunctionalChat(target, host, root);
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 83dd83d7e..0358e889b 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -62,6 +62,10 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
private readonly IChatGatewayBridge _bridge;
private readonly Action? _post;
private readonly object _gate = new();
+ private readonly object _toolMetaSaveGate = new();
+ private readonly string _toolMetaCacheFilePath;
+ private System.Threading.Timer? _toolMetaSaveTimer; // debounce cache writes
+ private long _toolMetaSaveVersion;
private readonly Dictionary _timelines = new();
private readonly Dictionary _activeRunIds = new(); // sessionKey → runId
private readonly Dictionary _pendingAbortCounts = new(); // threads → count of pending aborts waiting for lifecycle.start
@@ -76,6 +80,10 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
private readonly Dictionary _sessionIds = new(); // sessionKey → immutable sessionId
private readonly HashSet _historyLoaded = new(); // sessionKey
private readonly HashSet _historyInFlight = new(); // sessionKey
+ // Per-session cache of tool metadata from live SSE events.
+ // Keyed by gateway sessionId (immutable UUID). Persisted to disk
+ // so that history reconstruction on restart can recover tool names.
+ private Dictionary> _toolMetaCache;
// Track recently-sent local user message texts so we can suppress
// SSE echoes while still displaying messages from other clients.
private readonly Dictionary> _localSentTexts = new();
@@ -114,11 +122,20 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
/// the source event on (acceptable in unit tests).
///
public OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post = null)
+ : this(bridge, post, DefaultToolMetaCacheFilePath)
+ {
+ }
+
+ internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? post, string toolMetaCacheFilePath)
{
_bridge = bridge ?? throw new ArgumentNullException(nameof(bridge));
_post = post;
+ _toolMetaCacheFilePath = !string.IsNullOrWhiteSpace(toolMetaCacheFilePath)
+ ? toolMetaCacheFilePath
+ : throw new ArgumentException("Tool metadata cache path is required.", nameof(toolMetaCacheFilePath));
_status = bridge.CurrentStatus;
_persistedAbortedIds = LoadAbortedIds();
+ _toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath);
// Seed models from whatever the bridge already knows about (a connect
// that completed before the provider was constructed will have its
@@ -374,6 +391,12 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr
Logger.Info($"[ChatHistory] Loading thread '{threadId}' — {ordered.Count} messages from gateway");
+ // Load cached tool metadata for this session to restore tool names
+ // that the gateway strips from history responses.
+ var cachedTools = GetCachedToolMetaForSession(history.SessionId);
+ if (cachedTools is not null)
+ Logger.Info($"[ChatHistory] Found {cachedTools.Count} cached tool metadata entries for session");
+
bool nextAssistantIsAborted = false;
foreach (var msg in ordered)
@@ -470,11 +493,13 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr
}
if (LooksLikeFlattenedToolOutput(text))
{
- var kind = ClassifyFlattenedToolOutput(text);
- Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}'");
+ var cached = TryMatchCachedTool(cachedTools, msg.Ts);
+ var kind = cached?.ToolName ?? ClassifyFlattenedToolOutput(text);
+ var label = cached?.Label ?? ExtractFlattenedToolSummary(text);
+ Logger.Debug($"[ChatHistory] → routed: TOOL chip kind='{kind}' cached={cached is not null}");
rebuilt = ApplyAndCaptureMeta(
rebuilt,
- new ChatToolStartEvent(kind, kind),
+ new ChatToolStartEvent(label, kind),
msgMeta);
rebuilt = ApplyAndCaptureMeta(
rebuilt,
@@ -499,11 +524,13 @@ ChatTimelineState ApplyAndCaptureMeta(ChatTimelineState s, ChatEvent e, ChatEntr
// the heuristic fires, since the role itself confirms
// it's tool output.
{
- var kind = ClassifyFlattenedToolOutput(text);
- Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}')");
+ var cached = TryMatchCachedTool(cachedTools, msg.Ts);
+ var kind = cached?.ToolName ?? ClassifyFlattenedToolOutput(text);
+ var label = cached?.Label ?? ExtractFlattenedToolSummary(text);
+ Logger.Debug($"[ChatHistory] → routed: TOOL chip (role=toolresult, kind='{kind}' cached={cached is not null})");
rebuilt = ApplyAndCaptureMeta(
rebuilt,
- new ChatToolStartEvent(kind, kind),
+ new ChatToolStartEvent(label, kind),
msgMeta);
rebuilt = ApplyAndCaptureMeta(
rebuilt,
@@ -747,6 +774,15 @@ public ValueTask DisposeAsync()
{
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
+ System.Threading.Timer? timerToDispose;
+ lock (_gate)
+ {
+ timerToDispose = _toolMetaSaveTimer;
+ _toolMetaSaveTimer = null;
+ _toolMetaSaveVersion++;
+ }
+ timerToDispose?.Dispose();
+ SaveToolMetaCache();
_bridge.StatusChanged -= OnStatusChanged;
_bridge.SessionsUpdated -= OnSessionsUpdated;
_bridge.ChatMessageReceived -= OnChatMessageReceived;
@@ -1003,7 +1039,8 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message)
lock (_gate) { trMeta = BuildLiveMetaLocked(trThread, message.Ts); }
var capped = TruncateForChatEntry(message.Text);
var kind = ClassifyFlattenedToolOutput(capped);
- ApplyEventAndPublish(trThread, new ChatToolStartEvent(kind, kind), trMeta);
+ var label = ExtractFlattenedToolSummary(capped);
+ ApplyEventAndPublish(trThread, new ChatToolStartEvent(label, kind), trMeta);
ApplyEventAndPublish(trThread, new ChatToolOutputEvent(capped), trMeta);
return;
}
@@ -1106,6 +1143,13 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
ChatEvent? mapped = MapAgentEvent(evt);
if (mapped is null) return;
+ // Cache tool metadata from live SSE events so it survives app restarts.
+ if (mapped is ChatToolStartEvent toolStart && !string.IsNullOrEmpty(toolStart.ToolName))
+ {
+ var tsMs0 = evt.Ts > 0 ? (long)evt.Ts : 0L;
+ CacheToolMeta(threadId, tsMs0, toolStart.ToolName, toolStart.Text);
+ }
+
// AgentEventInfo.Ts is a double of unix-epoch ms (per OpenClawGatewayClient).
var tsMs = evt.Ts > 0 ? (long)evt.Ts : 0L;
ChatEntryMetadata? meta;
@@ -1821,19 +1865,75 @@ internal static bool LooksLikeFlattenedToolOutput(string text)
///
/// Best-guess kind label for a flattened-tool-output assistant
/// message. Used to populate the tool chip's monospace kind suffix.
+ /// Detects tool types from common output patterns as a heuristic
+ /// fallback when cached metadata is unavailable.
///
internal static string ClassifyFlattenedToolOutput(string text)
{
+ if (string.IsNullOrEmpty(text)) return "exec";
+
+ // Shell/process markers
if (text.Contains("Command still running", StringComparison.Ordinal) ||
text.Contains("Process exited with code", StringComparison.Ordinal))
- return "process";
+ return "bash";
+
+ // File read patterns (numbered lines like "1. ", "42. ")
+ if (s_numberedLineRegex.IsMatch(text))
+ return "view";
+
+ // Grep / search result patterns ("path/file.ext:123:matched line")
+ if (s_grepResultRegex.IsMatch(text))
+ return "grep";
+
+ // Directory listing / glob patterns
+ if (text.Contains("Directory:", StringComparison.Ordinal) ||
+ text.Contains("Mode ", StringComparison.Ordinal))
+ return "glob";
+
+ // Git output
+ if (text.StartsWith("commit ", StringComparison.Ordinal) ||
+ text.StartsWith("diff --git", StringComparison.Ordinal) ||
+ text.Contains("Author:", StringComparison.Ordinal) && text.Contains("Date:", StringComparison.Ordinal))
+ return "git";
+
+ // Edit/write patterns
+ if (text.Contains("successfully created", StringComparison.OrdinalIgnoreCase) ||
+ text.Contains("File written", StringComparison.OrdinalIgnoreCase) ||
+ text.Contains("Applied edit", StringComparison.OrdinalIgnoreCase))
+ return "edit";
+
+ // Exec completed marker
if (text.Contains("Exec completed (", StringComparison.Ordinal))
return "exec";
- // Anything matching the CLI-help heuristics is also a shell exec
- // result — give it the same chip kind as live exec calls.
+
return "exec";
}
+ /// Matches numbered output lines typical of file view output (e.g. " 1. content").
+ private static readonly System.Text.RegularExpressions.Regex s_numberedLineRegex =
+ new(@"^\s*\d+\.\s", System.Text.RegularExpressions.RegexOptions.Compiled | System.Text.RegularExpressions.RegexOptions.Multiline);
+
+ /// Matches grep-style results (path:line:content).
+ private static readonly System.Text.RegularExpressions.Regex s_grepResultRegex =
+ new(@"^[^\s:]+\.\w+:\d+:", System.Text.RegularExpressions.RegexOptions.Compiled | System.Text.RegularExpressions.RegexOptions.Multiline);
+
+ ///
+ /// Extract a short one-line summary from flattened tool output text
+ /// for use as the tool chip label. Truncates to 80 chars.
+ ///
+ internal static string ExtractFlattenedToolSummary(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return "";
+ // Use the first non-empty line as the summary
+ var firstLine = text.AsSpan().TrimStart();
+ var lineEnd = firstLine.IndexOfAny('\r', '\n');
+ if (lineEnd > 0) firstLine = firstLine[..lineEnd];
+ var summary = firstLine.Length > 80
+ ? new string(firstLine[..77]) + "…"
+ : new string(firstLine);
+ return summary;
+ }
+
// ── State helpers ──
///
@@ -2184,6 +2284,202 @@ private void SaveAbortedIds()
catch { /* best-effort persistence */ }
}
+ // ── Tool metadata persistence ─────────────────────────────────────
+
+ /// Cached tool call metadata entry persisted to disk.
+ internal sealed class CachedToolMeta
+ {
+ public long Ts { get; set; }
+ public string ToolName { get; set; } = "";
+ public string Label { get; set; } = "";
+ }
+
+ private static string DefaultToolMetaCacheFilePath
+ {
+ get
+ {
+ var root = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } overrideDir
+ ? overrideDir
+ : Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OpenClawTray");
+ return Path.Combine(root, "tool-metadata.json");
+ }
+ }
+
+ /// Max sessions to keep in the tool metadata cache.
+ internal const int MaxCachedSessions = 20;
+
+ /// Max tool entries per session in the cache.
+ internal const int MaxToolEntriesPerSession = 500;
+
+ private static Dictionary> LoadToolMetaCache(string cacheFilePath)
+ {
+ try
+ {
+ if (!File.Exists(cacheFilePath))
+ return new();
+ var json = File.ReadAllText(cacheFilePath);
+ var dict = System.Text.Json.JsonSerializer.Deserialize>>(json);
+ return dict ?? new();
+ }
+ catch
+ {
+ return new();
+ }
+ }
+
+ private void SaveToolMetaCache(long? expectedVersion = null)
+ {
+ try
+ {
+ Dictionary> snapshot;
+ lock (_gate)
+ {
+ if (expectedVersion is long version && (version != _toolMetaSaveVersion || _disposed))
+ return;
+
+ snapshot = _toolMetaCache.ToDictionary(
+ kv => kv.Key,
+ kv => kv.Value.Select(e => new CachedToolMeta
+ {
+ Ts = e.Ts,
+ ToolName = e.ToolName,
+ Label = e.Label
+ }).ToList(),
+ StringComparer.Ordinal);
+ }
+
+ // Evict oldest sessions if over the cap
+ if (snapshot.Count > MaxCachedSessions)
+ {
+ var toRemove = snapshot
+ .OrderBy(kv => kv.Value.Count > 0 ? kv.Value[^1].Ts : 0)
+ .Take(snapshot.Count - MaxCachedSessions)
+ .Select(kv => kv.Key)
+ .ToList();
+ foreach (var k in toRemove) snapshot.Remove(k);
+ }
+
+ var json = System.Text.Json.JsonSerializer.Serialize(snapshot,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+
+ lock (_toolMetaSaveGate)
+ {
+ if (expectedVersion is long version)
+ {
+ lock (_gate)
+ {
+ if (version != _toolMetaSaveVersion || _disposed)
+ return;
+ }
+ }
+
+ var dir = Path.GetDirectoryName(_toolMetaCacheFilePath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ // Write to a unique temp file then atomic move to avoid partial JSON on crash.
+ var tempPath = _toolMetaCacheFilePath + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllText(tempPath, json);
+ File.Move(tempPath, _toolMetaCacheFilePath, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ // Best-effort cleanup; persistence remains best-effort.
+ }
+ }
+ }
+ }
+ catch { /* best-effort persistence */ }
+ }
+
+ ///
+ /// Cache a tool call's metadata so it can be recovered when the gateway
+ /// flattens it during history replay on a future app launch.
+ ///
+ internal void CacheToolMeta(string threadId, long tsMs, string toolName, string label)
+ {
+ System.Threading.Timer? timerToDispose = null;
+ long saveVersion;
+ lock (_gate)
+ {
+ if (_disposed)
+ return;
+
+ if (!_sessionIds.TryGetValue(threadId, out var sessionId) || string.IsNullOrEmpty(sessionId))
+ return;
+
+ if (!_toolMetaCache.TryGetValue(sessionId, out var list))
+ {
+ list = new List();
+ _toolMetaCache[sessionId] = list;
+ }
+
+ // Deduplicate by timestamp (same tool event shouldn't be cached twice)
+ if (list.Count > 0 && list[^1].Ts == tsMs && list[^1].ToolName == toolName)
+ return;
+
+ list.Add(new CachedToolMeta { Ts = tsMs, ToolName = toolName, Label = label });
+
+ // Cap per-session entries
+ if (list.Count > MaxToolEntriesPerSession)
+ list.RemoveRange(0, list.Count - MaxToolEntriesPerSession);
+
+ // Debounce save — reset the timer on each cache addition so we only
+ // write once after 500ms of quiescence, avoiding concurrent file writes.
+ saveVersion = ++_toolMetaSaveVersion;
+ timerToDispose = _toolMetaSaveTimer;
+ _toolMetaSaveTimer = new System.Threading.Timer(_ => SaveToolMetaCache(saveVersion), null, 500, Timeout.Infinite);
+ }
+ timerToDispose?.Dispose();
+ }
+
+ ///
+ /// Look up cached tool metadata for a session's history reconstruction.
+ /// Returns a queue of entries sorted by timestamp for sequential consumption.
+ ///
+ private Queue? GetCachedToolMetaForSession(string? sessionId)
+ {
+ if (string.IsNullOrEmpty(sessionId)) return null;
+ lock (_gate)
+ {
+ if (_toolMetaCache.TryGetValue(sessionId!, out var list) && list.Count > 0)
+ return new Queue(list.OrderBy(e => e.Ts));
+ }
+ return null;
+ }
+
+ ///
+ /// Try to match a history tool entry to a cached metadata entry.
+ /// Both the cache and history are chronologically ordered, so we consume
+ /// entries sequentially. The cache stores tool-start timestamps while
+ /// history stores tool-result timestamps (which can be minutes later),
+ /// so we match by order rather than timestamp proximity.
+ ///
+ internal static CachedToolMeta? TryMatchCachedTool(Queue? cache, long historyTsMs)
+ {
+ if (cache is null || cache.Count == 0) return null;
+
+ // Both sequences are chronological. Consume the next cached entry
+ // for each tool result we encounter in history.
+ // Guard: if the history timestamp is much OLDER than the next cached
+ // entry, this toolresult predates our cache — skip it.
+ var candidate = cache.Peek();
+ if (historyTsMs > 0 && candidate.Ts > 0 && candidate.Ts > historyTsMs + 300_000)
+ return null; // cached entry is >5 min after this history entry — not a match
+
+ return cache.Dequeue();
+ }
+
///
/// After a successful abort, reload chat.history to capture the __openclaw.id
/// of the aborted user message and persist it for future sessions.
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index ae297b5d6..1091ad2d4 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -107,6 +107,7 @@ public override Element Render()
// they receive don't change. Bumping this Root's state invalidates
// the whole tree so toggles always show in the live preview.
var explorationRev = UseState(0, threadSafe: true);
+ var explorationRevRef = UseRef(0);
var pendingAttachment = UseState(null, threadSafe: true);
var speakerMuted = UseState(_initialMuted, threadSafe: true);
var voiceTranscript = UseState(null, threadSafe: true);
@@ -137,10 +138,11 @@ public override Element Render()
var dq = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
EventHandler h = (_, _) =>
{
+ explorationRevRef.Current++;
if (dq is not null)
- dq.TryEnqueue(() => explorationRev.Set(explorationRev.Value + 1));
+ dq.TryEnqueue(() => explorationRev.Set(explorationRevRef.Current));
else
- explorationRev.Set(explorationRev.Value + 1);
+ explorationRev.Set(explorationRevRef.Current);
};
ChatExplorationState.Changed += h;
return () => ChatExplorationState.Changed -= h;
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index cdeb529b0..c5a7da491 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -206,9 +206,18 @@ public override Element Render()
// timeline. Same inline pattern as OpenClawComposer (UseState +
// UseEffect — extension methods can't access protected hooks).
var explorationRev = UseState(0, threadSafe: true);
+ var explorationRevRef = UseRef(0);
UseEffect((Func)(() =>
{
- EventHandler h = (_, _) => explorationRev.Set(explorationRev.Value + 1);
+ // Use a Ref for the counter to avoid stale-closure: the effect
+ // runs once, so explorationRev.Value would be stuck at 0. The
+ // Ref's .Current is always live, ensuring every Changed event
+ // produces a unique value → always triggers a re-render.
+ EventHandler h = (_, _) =>
+ {
+ explorationRevRef.Current++;
+ explorationRev.Set(explorationRevRef.Current);
+ };
ChatExplorationState.Changed += h;
return () => ChatExplorationState.Changed -= h;
}));
@@ -250,6 +259,28 @@ public override Element Render()
// collapsed" — matches the web's default-collapsed look.
var expandedToolChips = UseState>(new HashSet(), threadSafe: true);
+ // Track the last-seen CollapseToolChipsVersion so we clear expanded
+ // state when the user toggles tool calls off (collapsed view should
+ // start fresh when re-shown).
+ var lastCollapseVersion = UseRef(ChatExplorationState.CollapseToolChipsVersion);
+ if (lastCollapseVersion.Current != ChatExplorationState.CollapseToolChipsVersion)
+ {
+ lastCollapseVersion.Current = ChatExplorationState.CollapseToolChipsVersion;
+ if (expandedToolChips.Value.Count > 0)
+ expandedToolChips.Set(new HashSet());
+ }
+
+ // When showToolCalls changes, pre-clear the native StackPanel so the
+ // reconciler (SyncChildren) only does inserts into an empty panel
+ // instead of expensive per-element RemoveAt calls that cascade
+ // Unloaded events through deep visual subtrees.
+ var prevShowToolCallsRef = UseRef(showToolCalls);
+ if (prevShowToolCallsRef.Current != showToolCalls)
+ {
+ prevShowToolCallsRef.Current = showToolCalls;
+ contentRef.Current?.Children.Clear();
+ }
+
// Hover state — set of entry ids currently under the pointer. Used to
// reveal the trash / speak action icons beside user / assistant
// bubbles. Re-renders the whole timeline on hover transitions; that's
@@ -1667,7 +1698,7 @@ static bool SameBurstKind(ChatTimelineItemKind a, ChatTimelineItemKind b) =>
static bool IsAgentSide(ChatTimelineItemKind k) =>
k == ChatTimelineItemKind.Assistant || k == ChatTimelineItemKind.ToolCall;
- var renderedEntries = new Element[Props.Entries.Count];
+ var renderedEntries = new Element?[Props.Entries.Count];
for (int i = 0; i < Props.Entries.Count; i++)
{
var entry = Props.Entries[i];
@@ -1682,16 +1713,11 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
// block instead of N separate chips with repeated footers.
if (entry.Kind == ChatTimelineItemKind.ToolCall)
{
- if (!showToolCalls)
- {
- renderedEntries[i] = Empty().WithKey(entry.Id);
- continue;
- }
if (!startsBurst)
{
// Non-start tool entries collapsed into the burst rendered
- // at startsBurst; render Empty here to avoid duplication.
- renderedEntries[i] = Empty().WithKey(entry.Id);
+ // at startsBurst; render null here to avoid duplication.
+ renderedEntries[i] = null;
continue;
}
var burst = new System.Collections.Generic.List { entry };
@@ -1701,6 +1727,11 @@ static bool IsAgentSide(ChatTimelineItemKind k) =>
burst.Add(Props.Entries[j]);
j++;
}
+ if (!showToolCalls)
+ {
+ renderedEntries[i] = null;
+ continue;
+ }
renderedEntries[i] = RenderToolBurst(burst, showAvatar).WithKey(entry.Id);
continue;
}
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index 96c7fed00..4c8fbd308 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -86,9 +86,14 @@ public override Element Render()
// be called from an extension method). Same pattern in
// OpenClawChatTimeline + OpenClawChatRoot.
var explorationRev = UseState(0, threadSafe: true);
+ var explorationRevRef = UseRef(0);
UseEffect((Func)(() =>
{
- EventHandler h = (_, _) => explorationRev.Set(explorationRev.Value + 1);
+ EventHandler h = (_, _) =>
+ {
+ explorationRevRef.Current++;
+ explorationRev.Set(explorationRevRef.Current);
+ };
ChatExplorationState.Changed += h;
return () => ChatExplorationState.Changed -= h;
}));
@@ -468,7 +473,8 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
.Set("ButtonBorderBrush", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPointerOver", new SolidColorBrush(Colors.Transparent))
.Set("ButtonBorderBrushPressed", new SolidColorBrush(Colors.Transparent)))
- .AutomationName(tip);
+ .AutomationName(tip)
+ .SetToolTip(tip);
// 5 icons (Send/Stop/Attach/Voice/More) honor ChatExplorationState
// Show + Glyph overrides set from the explorations panel.
@@ -517,6 +523,22 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
Props.IsSpeakerMuted ? "Unmute" : "Mute",
() => Props.OnSpeakerToggle())
: Empty();
+ // Toggle tool-call visibility. Same wrench icon in both states;
+ // reduced opacity when tool calls are hidden to indicate "off"
+ // without looking disabled. Tooltip clarifies the action.
+ var showTools = ChatExplorationState.ShowToolCalls;
+ var toolToggleBtn = IconButton(
+ "\uE90F", // Wrench
+ showTools ? "Hide tool calls" : "Show tool calls",
+ () =>
+ {
+ var next = !ChatExplorationState.ShowToolCalls;
+ if (!next)
+ ChatExplorationState.CollapseToolChipsVersion++;
+ ChatExplorationState.ShowToolCalls = next;
+ })
+ .Set(b => b.Opacity = showTools ? 1.0 : 0.55);
+
var settingsBtn = Props.OnSettingsClick is not null
? IconButton("\uE713", "Settings", () => Props.OnSettingsClick())
: Empty();
@@ -739,7 +761,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
// Wrapping in FlexRow caused the Button to stretch to the Star column width.
var bottomRow = Grid([GridSize.Auto, GridSize.Star()], [GridSize.Auto],
combinedPill.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 0),
- (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, settingsBtn, actionBtn, stopBtn) with { ColumnGap = 4 }) .HAlign(HorizontalAlignment.Right).Grid(row: 0, column: 1)
+ (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, toolToggleBtn, settingsBtn, actionBtn, stopBtn) with { ColumnGap = 4 }) .HAlign(HorizontalAlignment.Right).Grid(row: 0, column: 1)
);
return VStack(0,
@@ -757,7 +779,7 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
}
var actionsRow = Grid([GridSize.Star(), GridSize.Auto], [GridSize.Auto],
- (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, settingsBtn, actionBtn, stopBtn)
+ (FlexRow(attachBtn, voiceCancelBtn, voiceBtn, speakerBtn, toolToggleBtn, settingsBtn, actionBtn, stopBtn)
with { ColumnGap = 4 })
.HAlign(HorizontalAlignment.Right)
.Grid(row: 0, column: 1)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
index b08a23439..2b0ec8fcd 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
@@ -2,7 +2,8 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ NavigationCacheMode="Enabled">
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
index b9b46bf15..b8f12d213 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
@@ -251,7 +251,8 @@ private void ShowFunctionalSurface()
onAttachClick: OnAttachClicked,
onSettingsClick: () => _hub?.NavigateTo("voice"),
onSpeakerMuteChanged: muted => (App.Current as App)?.SetChatSpeakerMuted(muted),
- initialMuted: CurrentApp.Settings?.VoiceTtsEnabled == false);
+ initialMuted: CurrentApp.Settings?.VoiceTtsEnabled == false,
+ suppressAutoDispose: true);
_mountedProvider = provider;
// If the V hotkey (or another caller) requested auto-start voice,
diff --git a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
index 1383e8235..b1b9dda40 100644
--- a/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
+++ b/src/OpenClawTray.FunctionalUI/FunctionalUI.cs
@@ -802,6 +802,14 @@ public sealed class FunctionalHostControl : ContentControl, IDisposable
private int _renderPending;
private bool _disposed;
+ ///
+ /// When true, the control will not auto-dispose on Unloaded.
+ /// Set this when the host lives inside a page with
+ /// NavigationCacheMode="Enabled" so the component tree
+ /// survives page navigation.
+ ///
+ public bool SuppressAutoDispose { get; set; }
+
public FunctionalHostControl()
{
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
@@ -810,7 +818,7 @@ public FunctionalHostControl()
VerticalContentAlignment = VerticalAlignment.Stretch;
Background = ThemeResources.ResolveBrush("SolidBackgroundFillColorBaseBrush");
IsTabStop = false;
- Unloaded += (_, _) => Dispose();
+ Unloaded += (_, _) => { if (!SuppressAutoDispose) Dispose(); };
}
public void Mount(Component component)
@@ -1425,6 +1433,7 @@ private static GridLength ParseGridLength(string value)
return new GridLength(double.Parse(value, CultureInfo.InvariantCulture), GridUnitType.Pixel);
}
+
private void SyncChildren(Panel panel, IReadOnlyList elements, string path, List effects)
{
var renderedChildren = elements
@@ -1448,6 +1457,21 @@ private void SyncChildren(Panel panel, IReadOnlyList elements, string
if (ChildrenMatch(panel, desiredChildren))
return;
+ // Fast path: when panel is empty (e.g. pre-cleared), just add children
+ // directly. Avoids EnsureChildAt → RemoveFromParent which scans ALL
+ // cached controls — O(controls × children) with no benefit when
+ // children have no parent.
+ if (panel.Children.Count == 0)
+ {
+ foreach (var child in desiredChildren)
+ {
+ if (child is FrameworkElement { Parent: { } })
+ RemoveFromParent(child);
+ panel.Children.Add(child);
+ }
+ return;
+ }
+
var desiredSet = new HashSet(desiredChildren);
for (var i = panel.Children.Count - 1; i >= 0; i--)
{
diff --git a/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs b/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs
index eccef9bd5..c6fcacb47 100644
--- a/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FlattenedToolOutputDetectionTests.cs
@@ -224,10 +224,14 @@ static void AssertCapped(string text)
}
[Theory]
- [InlineData("Command still running (session foo, pid 1)", "process")]
- [InlineData("Process exited with code 0", "process")]
+ [InlineData("Command still running (session foo, pid 1)", "bash")]
+ [InlineData("Process exited with code 0", "bash")]
[InlineData("Exec completed (oceanic, code 0)", "exec")]
[InlineData("OpenClaw 2026.4.23 — Usage: openclaw help", "exec")]
+ [InlineData(" 1. using System;\n 2. using System.IO;\n 3. namespace Foo;", "view")]
+ [InlineData("src/Main.cs:42: var x = 1;", "grep")]
+ [InlineData("commit abc123\nAuthor: User\nDate: Mon Jan 1", "git")]
+ [InlineData("diff --git a/foo.cs b/foo.cs", "git")]
public void ClassifiesKindCorrectly(string text, string expected)
{
Assert.Equal(expected, OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text));
@@ -240,10 +244,34 @@ public void ClassifiesKindCorrectly(string text, string expected)
public void ToolresultRoleAlwaysClassified(string text)
{
// ``toolresult`` role sidesteps the heuristic — but the kind label
- // must still come out as either "exec" or "process" so the chip
- // header reads sensibly. Default fallthrough is "exec".
+ // must still produce a recognized tool type so the chip header reads
+ // sensibly. Default fallthrough is "exec".
var kind = OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text);
- Assert.True(kind == "exec" || kind == "process",
- $"Unexpected kind '{kind}' for: {text}");
+ Assert.False(string.IsNullOrEmpty(kind), $"Empty kind for: {text}");
+ }
+
+ // ── Additional classifier edge cases ──
+
+ [Theory]
+ [InlineData("Cloning into 'repo'...\nremote: Enumerating objects: 100", "exec")]
+ [InlineData("On branch main\nYour branch is up to date with 'origin/main'.", "exec")]
+ [InlineData("fatal: not a git repository", "exec")]
+ [InlineData("src/foo.cs\nsrc/bar.cs\nsrc/baz.cs", "exec")]
+ [InlineData(" 1. first line\n 2. second line\n 3. third line", "view")]
+ [InlineData("--- a/src/main.cs\n+++ b/src/main.cs\n@@ -1,3 +1,4 @@", "exec")]
+ public void ClassifiesAdditionalKindsCorrectly(string text, string expected)
+ {
+ Assert.Equal(expected, OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("x")]
+ public void ClassifyFlattenedToolOutput_EmptyOrTiny_ReturnsExecDefault(string text)
+ {
+ // Even empty/tiny text should return a non-empty kind (default "exec")
+ var kind = OpenClawChatDataProvider.ClassifyFlattenedToolOutput(text);
+ Assert.False(string.IsNullOrEmpty(kind));
}
}
diff --git a/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
new file mode 100644
index 000000000..55588d6d4
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/ToolMetaCacheTests.cs
@@ -0,0 +1,223 @@
+using OpenClaw.Chat;
+using OpenClaw.Shared;
+using OpenClawTray.Chat;
+using System.Text.Json;
+using Xunit;
+
+namespace OpenClaw.Tray.Tests;
+
+///
+/// Tests for the tool metadata cache matching logic used to recover tool
+/// names/labels after gateway history flattening.
+///
+public class ToolMetaCacheTests
+{
+ private static OpenClawChatDataProvider.CachedToolMeta Meta(long ts, string tool, string label) =>
+ new() { Ts = ts, ToolName = tool, Label = label };
+
+ // ── TryMatchCachedTool ──
+
+ [Fact]
+ public void TryMatch_NullCache_ReturnsNull()
+ {
+ Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(null, 1000));
+ }
+
+ [Fact]
+ public void TryMatch_EmptyCache_ReturnsNull()
+ {
+ var cache = new Queue();
+ Assert.Null(OpenClawChatDataProvider.TryMatchCachedTool(cache, 1000));
+ }
+
+ [Fact]
+ public void TryMatch_SingleEntry_DequeuesAndReturns()
+ {
+ var cache = new Queue();
+ cache.Enqueue(Meta(100, "bash", "ls -la"));
+
+ var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200);
+
+ Assert.NotNull(result);
+ Assert.Equal("bash", result!.ToolName);
+ Assert.Equal("ls -la", result.Label);
+ Assert.Empty(cache); // consumed
+ }
+
+ [Fact]
+ public void TryMatch_SequentialOrder_MatchesByPosition()
+ {
+ var cache = new Queue();
+ cache.Enqueue(Meta(100, "bash", "first"));
+ cache.Enqueue(Meta(200, "grep", "second"));
+ cache.Enqueue(Meta(300, "view", "third"));
+
+ // Each call should dequeue the next entry regardless of timestamp
+ var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500);
+ var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600);
+ var r3 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 700);
+
+ Assert.Equal("bash", r1!.ToolName);
+ Assert.Equal("grep", r2!.ToolName);
+ Assert.Equal("view", r3!.ToolName);
+ Assert.Empty(cache);
+ }
+
+ [Fact]
+ public void TryMatch_MoreHistoryThanCache_ReturnsNullWhenExhausted()
+ {
+ var cache = new Queue();
+ cache.Enqueue(Meta(100, "bash", "only entry"));
+
+ var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 200);
+ var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 300);
+
+ Assert.NotNull(r1);
+ Assert.Null(r2); // exhausted
+ }
+
+ [Fact]
+ public void TryMatch_CachedEntryFarAfterHistory_SkipsMatch()
+ {
+ // Cache entry is >5 minutes (300_000ms) after the history entry —
+ // means this history tool result predates the cache.
+ var cache = new Queue();
+ cache.Enqueue(Meta(500_000, "bash", "future entry"));
+
+ var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000);
+
+ Assert.Null(result);
+ Assert.Single(cache); // NOT consumed — entry stays for later
+ }
+
+ [Fact]
+ public void TryMatch_CachedEntrySlightlyAfterHistory_StillMatches()
+ {
+ // Cache entry is <5 min after history — normal SSE delay, should match.
+ var cache = new Queue();
+ cache.Enqueue(Meta(200_000, "bash", "recent entry"));
+
+ var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 100_000);
+
+ Assert.NotNull(result);
+ Assert.Equal("bash", result!.ToolName);
+ }
+
+ [Fact]
+ public void TryMatch_ZeroTimestamps_AlwaysMatch()
+ {
+ // When timestamps are 0, the guard is skipped — always dequeue.
+ var cache = new Queue();
+ cache.Enqueue(Meta(0, "bash", "no timestamp"));
+
+ var result = OpenClawChatDataProvider.TryMatchCachedTool(cache, 0);
+
+ Assert.NotNull(result);
+ }
+
+ [Fact]
+ public void TryMatch_RepeatedToolNames_PreservesOrder()
+ {
+ // Multiple entries with the same tool name should be matched in order.
+ var cache = new Queue();
+ cache.Enqueue(Meta(100, "bash", "first bash"));
+ cache.Enqueue(Meta(200, "bash", "second bash"));
+ cache.Enqueue(Meta(300, "bash", "third bash"));
+
+ var r1 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 500);
+ var r2 = OpenClawChatDataProvider.TryMatchCachedTool(cache, 600);
+
+ Assert.Equal("first bash", r1!.Label);
+ Assert.Equal("second bash", r2!.Label);
+ }
+
+ // ── Constants ──
+
+ [Fact]
+ public void SessionLimits_AreReasonable()
+ {
+ Assert.Equal(20, OpenClawChatDataProvider.MaxCachedSessions);
+ Assert.Equal(500, OpenClawChatDataProvider.MaxToolEntriesPerSession);
+ }
+
+ [Fact]
+ public async Task CacheToolMeta_ConcurrentAdds_FlushesCompleteValidJson()
+ {
+ using var tempDir = new TempDirectory();
+ var cachePath = Path.Combine(tempDir.DirectoryPath, "tool-metadata.json");
+ var bridge = new FakeBridge
+ {
+ History = new ChatHistoryInfo
+ {
+ SessionKey = "main",
+ SessionId = "session-1"
+ }
+ };
+ var provider = new OpenClawChatDataProvider(bridge, post: null, toolMetaCacheFilePath: cachePath);
+ await provider.LoadHistoryAsync("main");
+
+ Parallel.For(0, 100, i =>
+ provider.CacheToolMeta("main", 1_000 + i, "bash", $"echo {i}"));
+
+ await provider.DisposeAsync();
+
+ var json = File.ReadAllText(cachePath);
+ var cache = JsonSerializer.Deserialize>>(json);
+
+ Assert.NotNull(cache);
+ Assert.True(cache!.TryGetValue("session-1", out var entries));
+ Assert.Equal(100, entries!.Count);
+ Assert.Empty(Directory.EnumerateFiles(tempDir.DirectoryPath, "*.tmp"));
+ }
+
+ private sealed class FakeBridge : IChatGatewayBridge
+ {
+ public bool IsConnected { get; set; }
+ public ConnectionStatus CurrentStatus { get; set; }
+ public string? MainSessionKey { get; set; }
+ public bool HasHandshakeSnapshot { get; set; }
+ public ChatHistoryInfo History { get; set; } = new() { SessionKey = "main" };
+
+ public SessionInfo[] GetSessionList() => Array.Empty();
+ public ModelsListInfo? GetCurrentModelsList() => null;
+ public Task SendChatMessageAsync(string message, string? sessionKey, string? sessionId, IReadOnlyList? attachments = null) => Task.CompletedTask;
+ public Task PatchSessionModelAsync(string sessionKey, string model) => Task.CompletedTask;
+ public Task PatchSessionThinkingLevelAsync(string sessionKey, string thinkingLevel) => Task.CompletedTask;
+ public Task RequestChatHistoryAsync(string? sessionKey) => Task.FromResult(History);
+ public Task SendChatAbortAsync(string runId, string? sessionKey = null) => Task.CompletedTask;
+ public event EventHandler? StatusChanged;
+ public event EventHandler? SessionsUpdated;
+ public event EventHandler? ChatMessageReceived;
+ public event EventHandler? AgentEventReceived;
+ public event EventHandler? ModelsListUpdated;
+ public void RaiseStatus(ConnectionStatus status) => StatusChanged?.Invoke(this, status);
+ public void RaiseSessions(SessionInfo[] sessions) => SessionsUpdated?.Invoke(this, sessions);
+ public void RaiseChat(ChatMessageInfo message) => ChatMessageReceived?.Invoke(this, message);
+ public void RaiseAgent(AgentEventInfo evt) => AgentEventReceived?.Invoke(this, evt);
+ public void RaiseModels(ModelsListInfo models) => ModelsListUpdated?.Invoke(this, models);
+ public void Dispose() { }
+ }
+
+ private sealed class TempDirectory : IDisposable
+ {
+ public string DirectoryPath { get; } = Path.Combine(Path.GetTempPath(), "openclaw-tool-meta-" + Guid.NewGuid().ToString("N"));
+
+ public TempDirectory()
+ {
+ Directory.CreateDirectory(DirectoryPath);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(DirectoryPath))
+ Directory.Delete(DirectoryPath, recursive: true);
+ }
+ catch
+ {
+ // Test cleanup is best-effort.
+ }
+ }
+ }
+}
From f3d3fd2772f33c87b920b0533514dd0b68572ed8 Mon Sep 17 00:00:00 2001
From: Regis Brid
Date: Wed, 20 May 2026 18:12:09 -0700
Subject: [PATCH 041/115] feat(tray): consolidate app version + add user
feedback to Check for Updates
Introduces OpenClaw.Shared.AppVersionInfo as the single source of truth for the app version (resolved at runtime from the tray assembly), and replaces ~9 duplicated version lookups across the UI.
Rewrites the update-check flow as a two-stage pipeline (metadata gate + install flag) with bounded waits, fresh UpdateInfo writes, and catches for COM/Cancel/InvalidOperation exceptions. Adds a ContentDialog so 'Check for Updates' gives visible feedback when the app is current, when a check fails, or (DEBUG) when skipped.
Adds 7 localized Update_* keys across en-us/fr-fr/nl-nl/zh-cn/zh-tw, with a LatinScriptInvariantResourceKeys allow-list so fr/nl keep 'OK' verbatim.
Bumps version to 0.4.7.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/ci.yml | 7 +-
docs/VERSIONING.md | 26 ++
src/OpenClaw.Shared/AppVersionInfo.cs | 101 +++++
.../Capabilities/DeviceCapability.cs | 8 +-
src/OpenClaw.Tray.WinUI/App.xaml.cs | 378 ++++++++++++++++--
.../Dialogs/UpdateDialog.cs | 3 +-
.../OpenClaw.Tray.WinUI.csproj | 109 ++++-
src/OpenClaw.Tray.WinUI/Package.appxmanifest | 5 +-
src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml | 2 +-
.../Pages/AboutPage.xaml.cs | 4 +-
.../Services/NodeService.cs | 2 +-
.../Strings/en-us/Resources.resw | 26 +-
.../Strings/fr-fr/Resources.resw | 28 +-
.../Strings/nl-nl/Resources.resw | 28 +-
.../Strings/zh-cn/Resources.resw | 26 +-
.../Strings/zh-tw/Resources.resw | 26 +-
.../LocalizationValidationTests.cs | 37 ++
.../A2UIDashboardScaleTest.cs | 2 +-
18 files changed, 750 insertions(+), 68 deletions(-)
create mode 100644 src/OpenClaw.Shared/AppVersionInfo.cs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 383387975..af4f04c5e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -281,18 +281,19 @@ jobs:
- name: Patch MSIX manifest metadata
shell: pwsh
run: |
- $version = "${{ needs.test.outputs.majorMinorPatch }}.0"
+ # NOTE: Identity/Version is auto-synced from /p:Version by the SyncAppxManifestVersion
+ # target in OpenClaw.Tray.WinUI.csproj during the MSIX build below; we only patch the
+ # alpha/non-alpha identity and display name here.
$isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true"
$identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" }
$displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" }
$manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest"
[xml]$xml = Get-Content $manifest
$xml.Package.Identity.Name = $identityName
- $xml.Package.Identity.Version = $version
$xml.Package.Properties.DisplayName = $displayName
$xml.Package.Applications.Application.VisualElements.DisplayName = $displayName
$xml.Save((Resolve-Path $manifest))
- Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version"
+ Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName' (Version will be synced from /p:Version by msbuild target)"
- name: Build MSIX Package
run: >
diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md
index 674f018d0..de1ad8f03 100644
--- a/docs/VERSIONING.md
+++ b/docs/VERSIONING.md
@@ -70,6 +70,32 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n
2. **Let GitVersion and CI control the version** - the csproj's `` is just a fallback for local development builds
3. **Test version detection** - after building, check the EXE properties to ensure FileVersion matches expectations
4. **Use semantic versioning** - tags should follow `v{major}.{minor}.{patch}` format (e.g., `v0.4.0`)
+5. **Use `OpenClaw.Shared.AppVersionInfo` for any user-visible or wire-exposed version string** - never re-roll
+ `typeof(...).Assembly.GetName().Version` or hardcode literals like `"v0.1.0"`. `AppVersionInfo` is the single
+ source of truth driven by ``, used by the About page, Update dialog, support-context dump,
+ `device.info` capability, MCP `serverVersion` handshake, and the update-check diagnostics.
+
+## Runtime Version Resolution (AppVersionInfo)
+
+`src/OpenClaw.Shared/AppVersionInfo.cs` exposes:
+
+- `AppVersionInfo.Version` → bare string, e.g. `"0.4.7"`
+- `AppVersionInfo.DisplayVersion` → `"v"` prefix, e.g. `"v0.4.7"`
+
+It resolves the version by:
+
+1. Looking for the `OpenClaw.Tray.WinUI` assembly in the current `AppDomain` (so `dotnet test` and CLI siblings
+ still report the tray's version rather than the testhost / dotnet host).
+2. Falling back to `Assembly.GetEntryAssembly()`, then to the Shared assembly.
+3. Reading `AssemblyInformationalVersionAttribute` (preferred) or `AssemblyVersion`.
+4. Stripping SourceLink build metadata (`+abc123`) **and** the SemVer pre-release suffix (`-beta.1`) so the
+ displayed value matches what Updatum compares (Updatum reads the numeric `AssemblyVersion` only).
+
+For tests that need a deterministic value regardless of host process, set the `internal` test hook:
+
+```csharp
+AppVersionInfo.TestOverride = "9.9.9";
+```
## References
diff --git a/src/OpenClaw.Shared/AppVersionInfo.cs b/src/OpenClaw.Shared/AppVersionInfo.cs
new file mode 100644
index 000000000..3f292bc58
--- /dev/null
+++ b/src/OpenClaw.Shared/AppVersionInfo.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Reflection;
+
+namespace OpenClaw.Shared;
+
+///
+/// Single source of truth for the OpenClaw Companion app version that is
+/// surfaced to users. Reads
+/// (or falls back to AssemblyVersion) from the tray executable so every
+/// UI/diagnostic/handshake site reports the same number driven by the csproj
+/// <Version> property.
+///
+///
+/// Under dotnet test and inside CLI siblings,
+/// is the host process (testhost / dotnet), not the tray exe — so we first
+/// search the current for the tray assembly by name.
+/// Tests that need a deterministic value can set .
+///
+public static class AppVersionInfo
+{
+ private const string TrayAssemblyName = "OpenClaw.Tray.WinUI";
+
+ private static readonly string _version = ResolveVersion();
+
+ ///
+ /// Test-only override. When non-null, returns this
+ /// value instead of the reflected one, giving tests a deterministic
+ /// version string regardless of the host process.
+ ///
+ internal static string? TestOverride { get; set; }
+
+ /// Bare version string, e.g. "0.4.7".
+ public static string Version => TestOverride ?? _version;
+
+ /// Version prefixed with v, e.g. "v0.4.7".
+ public static string DisplayVersion => "v" + Version;
+
+ private static string ResolveVersion()
+ {
+ try
+ {
+ var assembly = FindTrayAssembly()
+ ?? Assembly.GetEntryAssembly()
+ ?? typeof(AppVersionInfo).Assembly;
+
+ var informational = assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+ if (!string.IsNullOrWhiteSpace(informational))
+ {
+ return NormalizeSemVer(informational);
+ }
+
+ var name = assembly.GetName().Version;
+ if (name != null)
+ {
+ // System.Version uses -1 for unspecified components; coerce to 0.
+ var build = Math.Max(0, name.Build);
+ var revision = Math.Max(0, name.Revision);
+ return revision == 0
+ ? $"{name.Major}.{name.Minor}.{build}"
+ : $"{name.Major}.{name.Minor}.{build}.{revision}";
+ }
+
+ return "0.0.0";
+ }
+ catch
+ {
+ // Never let the type initializer fail — a TypeInitializationException
+ // would poison every future access from every caller.
+ return "0.0.0";
+ }
+ }
+
+ private static Assembly? FindTrayAssembly()
+ {
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (string.Equals(asm.GetName().Name, TrayAssemblyName, StringComparison.Ordinal))
+ return asm;
+ }
+ return null;
+ }
+
+ private static string NormalizeSemVer(string s)
+ {
+ // Strip SourceLink build metadata, e.g. "0.4.7+abc123" -> "0.4.7".
+ var plus = s.IndexOf('+');
+ if (plus >= 0) s = s.Substring(0, plus);
+
+ // Strip the SemVer pre-release suffix, e.g. "0.4.7-beta.1" -> "0.4.7".
+ // This keeps the UI string aligned with Updatum, which compares the
+ // numeric AssemblyVersion only. Revisit if pre-release labels should
+ // ever be surfaced to users.
+ var dash = s.IndexOf('-');
+ if (dash >= 0) s = s.Substring(0, dash);
+
+ return s;
+ }
+}
+
diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs
index 2c05ae9ac..01298377a 100644
--- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs
+++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs
@@ -4,7 +4,6 @@
using System.IO;
using System.Linq;
using System.Net.NetworkInformation;
-using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
@@ -53,10 +52,7 @@ private NodeInvokeResponse HandleInfo()
{
Logger.Info("device.info");
- var assembly = typeof(DeviceCapability).Assembly;
- var version = assembly.GetCustomAttribute()?.InformationalVersion
- ?? assembly.GetName().Version?.ToString()
- ?? "unknown";
+ var version = AppVersionInfo.Version;
return Success(new
{
@@ -65,7 +61,7 @@ private NodeInvokeResponse HandleInfo()
systemName = OperatingSystem.IsWindows() ? "Windows" : RuntimeInformation.OSDescription,
systemVersion = RuntimeInformation.OSDescription,
appVersion = version,
- appBuild = assembly.GetName().Version?.ToString() ?? version,
+ appBuild = version,
locale = CultureInfo.CurrentCulture.Name
});
}
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 248b67ac1..b9819bdb1 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -3369,29 +3369,64 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e)
private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new()
{
Status = "Not checked",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown"
+ CurrentVersion = AppVersionInfo.Version
};
- private async Task CheckForUpdatesAsync()
+ // Cross-path concurrency for update checks, split into two phases:
+ // - _updateCheckGate: held only during the metadata/network check.
+ // Short timeout so contended callers don't block on user thinking.
+ // - _updateInstallInProgress: Interlocked flag covering the user-facing
+ // UpdateDialog + download + install. Prevents two parallel installs
+ // without holding a lock across user interaction.
+ private readonly System.Threading.SemaphoreSlim _updateCheckGate = new(1, 1);
+ private int _updateInstallInProgress;
+
+ private async Task CheckForUpdatesAsync(bool userInitiated = false)
{
- try
+ // === Stage 1: metadata check (gate-protected) ===
+ if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30)))
{
+ Logger.Warn("Update check gate timed out: another check is in progress");
+ if (_appState != null)
+ {
+ _appState.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "another update check is already in progress; try again in a moment"
+ };
+ }
+ return true; // Don't block launch
+ }
+
#if DEBUG
+ try
+ {
Logger.Info("Skipping update check in debug build");
_appState!.UpdateInfo = new UpdateCommandCenterInfo
{
Status = "Skipped",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
+ CurrentVersion = AppVersionInfo.Version,
CheckedAt = DateTime.UtcNow,
Detail = "debug build"
};
return true;
+ }
+ finally
+ {
+ _updateCheckGate.Release();
+ }
#else
+ string releaseTag;
+ string changelog;
+ try
+ {
Logger.Info("Checking for updates...");
_appState!.UpdateInfo = new UpdateCommandCenterInfo
{
Status = "Checking",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
+ CurrentVersion = AppVersionInfo.Version,
CheckedAt = DateTime.UtcNow
};
var updateFound = await AppUpdater.CheckForUpdatesAsync();
@@ -3402,7 +3437,7 @@ private async Task CheckForUpdatesAsync()
_appState!.UpdateInfo = new UpdateCommandCenterInfo
{
Status = "Current",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
+ CurrentVersion = AppVersionInfo.Version,
CheckedAt = DateTime.UtcNow,
Detail = "no updates available"
};
@@ -3410,72 +3445,330 @@ private async Task CheckForUpdatesAsync()
}
var release = AppUpdater.LatestRelease!;
- var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available.";
- Logger.Info($"Update available: {release.TagName}");
+ if (string.IsNullOrEmpty(release.TagName))
+ {
+ // Defensive: AppUpdater says an update is available but the
+ // release has no tag. Don't silently claim "up to date" —
+ // surface as Failed so the user sees something is off.
+ Logger.Warn("Update reported available but release has no TagName");
+ _appState!.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "update metadata incomplete (missing version tag)"
+ };
+ return true;
+ }
+
+ releaseTag = release.TagName;
+ changelog = AppUpdater.GetChangelog(true) ?? "No release notes available.";
+ Logger.Info($"Update available: {releaseTag}");
_appState!.UpdateInfo = new UpdateCommandCenterInfo
{
Status = "Available",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
- LatestVersion = release.TagName,
+ CurrentVersion = AppVersionInfo.Version,
+ LatestVersion = releaseTag,
CheckedAt = DateTime.UtcNow,
Detail = "prompted"
};
if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) &&
- string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase))
+ string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) &&
+ !userInitiated)
{
- Logger.Info($"Skipping update prompt for remembered version {release.TagName}");
+ Logger.Info($"Skipping update prompt for remembered version {releaseTag}");
_appState!.UpdateInfo.Detail = "skipped by user";
return true;
}
+ }
+ catch (OperationCanceledException)
+ {
+ Logger.Info("Update check cancelled");
+ if (_appState != null)
+ {
+ // Avoid leaving Status="Checking" stale for the manual flow
+ // or the command-center UI. Surface as Failed with a clear
+ // "cancelled" detail.
+ _appState.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "update check cancelled"
+ };
+ }
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logger.Warn($"Update check failed: {ex.Message}");
+ if (_appState != null)
+ {
+ _appState.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = ex.Message
+ };
+ }
+ return true;
+ }
+ finally
+ {
+ // Release the gate BEFORE user interaction & download/install.
+ // Holding it across these long phases would silently time-out
+ // any concurrent manual click.
+ _updateCheckGate.Release();
+ }
+
+ // === Stage 2: user-interactive prompt + download/install ===
+ // Gate is released. Use Interlocked flag so concurrent callers can't
+ // start a second parallel install while we're prompting/downloading.
+ if (System.Threading.Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0)
+ {
+ Logger.Info("Update prompt/install already in progress; skipping");
+ if (_appState != null)
+ {
+ _appState.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "an update is already being downloaded or installed"
+ };
+ }
+ return true;
+ }
- var dialog = new UpdateDialog(release.TagName, changelog);
- var result = await dialog.ShowAsync();
+ try
+ {
+ var dialog = new UpdateDialog(releaseTag, changelog);
+ UpdateDialogResult result;
+ try
+ {
+ result = await dialog.ShowAsync();
+ }
+ catch (System.Runtime.InteropServices.COMException ex)
+ {
+ // Visual tree torn down mid-await (e.g. window closed).
+ // Treat as "remind me later" rather than tainting Status with
+ // "Failed" — the network check itself succeeded.
+ Logger.Warn($"[Update] Prompt dialog dismissed before completion: 0x{ex.HResult:X8}");
+ return true;
+ }
+ catch (InvalidOperationException ex)
+ {
+ // Another ContentDialog is already open on this XamlRoot.
+ Logger.Warn($"[Update] Prompt dialog could not be shown: {ex.Message}");
+ return true;
+ }
if (result == UpdateDialogResult.Download)
{
- _appState!.UpdateInfo.Detail = "download requested";
+ // Assign a fresh object rather than mutating .Detail in place:
+ // a concurrent loser of the install-flag CAS may have just
+ // overwritten _appState.UpdateInfo with a "Failed" object,
+ // and mutating its Detail would leave Status="Failed" with
+ // our "download requested" detail — briefly inconsistent.
+ _appState!.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Available",
+ CurrentVersion = AppVersionInfo.Version,
+ LatestVersion = releaseTag,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "download requested"
+ };
if (_settings != null)
{
_settings.SkippedUpdateTag = string.Empty;
_settings.Save();
}
var installed = await DownloadAndInstallUpdateAsync();
+ if (!installed)
+ {
+ // Surface the failure so callers (and the manual-check
+ // dialog) don't show stale "download requested" state.
+ _appState!.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "download or install failed"
+ };
+ }
return !installed; // Don't launch if update succeeded
}
if (result == UpdateDialogResult.Skip && _settings != null)
{
- _settings.SkippedUpdateTag = release.TagName ?? string.Empty;
+ _settings.SkippedUpdateTag = releaseTag;
+ _settings.Save();
+ _appState!.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Available",
+ CurrentVersion = AppVersionInfo.Version,
+ LatestVersion = releaseTag,
+ CheckedAt = DateTime.UtcNow,
+ Detail = "skipped by user"
+ };
+ }
+ else if (userInitiated && _settings != null
+ && string.Equals(_settings.SkippedUpdateTag, releaseTag,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ // User explicitly bypassed the remembered skip for THIS
+ // release and picked RemindLater — clear the stale tag.
+ _settings.SkippedUpdateTag = string.Empty;
_settings.Save();
- _appState!.UpdateInfo.Detail = "skipped by user";
}
- return true; // RemindLater or Skip - continue
-#endif
+ return true; // RemindLater or Skip - continue launch
}
catch (Exception ex)
{
- Logger.Warn($"Update check failed: {ex.Message}");
- _appState!.UpdateInfo = new UpdateCommandCenterInfo
+ Logger.Warn($"Update prompt/install failed: {ex.Message}");
+ if (_appState != null)
{
- Status = "Failed",
- CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown",
- CheckedAt = DateTime.UtcNow,
- Detail = ex.Message
- };
+ _appState.UpdateInfo = new UpdateCommandCenterInfo
+ {
+ Status = "Failed",
+ CurrentVersion = AppVersionInfo.Version,
+ CheckedAt = DateTime.UtcNow,
+ Detail = ex.Message
+ };
+ }
return true;
}
+ finally
+ {
+ System.Threading.Interlocked.Exchange(ref _updateInstallInProgress, 0);
+ }
+#endif
}
+ // Re-entrancy guard: the button/menu/deep-link are all fire-and-forget
+ // (`_ = CheckForUpdatesUserInitiatedAsync()`), so a double-click would
+ // otherwise open two ContentDialogs on the same XamlRoot which throws
+ // COMException. One in-flight manual check at a time is enough.
+ private int _manualUpdateCheckInFlight;
+
private async Task CheckForUpdatesUserInitiatedAsync()
{
- Logger.Info("Manual update check requested");
- var shouldContinue = await CheckForUpdatesAsync();
- UpdateStatusDetailWindow();
- if (!shouldContinue)
+ if (System.Threading.Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0)
{
- Exit();
+ Logger.Info("Manual update check ignored: another check is already in progress");
+ return;
+ }
+
+ try
+ {
+ Logger.Info("Manual update check requested");
+ // Pass userInitiated=true so an explicit click bypasses the
+ // "remind me later" SkippedUpdateTag — the user is asking *now*.
+ var shouldContinue = await CheckForUpdatesAsync(userInitiated: true);
+ UpdateStatusDetailWindow();
+
+ // The "Available" path already prompts via UpdateDialog. For the
+ // other terminal states a manual click would otherwise produce no
+ // UI at all, leaving users wondering whether the click registered.
+ // Surface each explicitly with a small OK dialog.
+ var info = _appState?.UpdateInfo;
+ if (info != null)
+ {
+ switch (info.Status)
+ {
+ case "Current":
+ await ShowUpdateInfoDialogAsync(
+ "UpToDate",
+ LocalizationHelper.GetString("Update_Title_UpToDate"),
+ LocalizationHelper.Format("Update_Message_UpToDate", info.CurrentVersion));
+ break;
+ case "Failed":
+ // Format string ends with "\n\n{0}"; an empty Detail
+ // would leave a dangling blank line. Trim only the
+ // newline characters we added, never arbitrary
+ // whitespace from the localized string.
+ var failedMessage = LocalizationHelper
+ .Format("Update_Message_Failed", info.Detail ?? "")
+ .TrimEnd('\r', '\n');
+ await ShowUpdateInfoDialogAsync(
+ "Failed",
+ LocalizationHelper.GetString("Update_Title_Failed"),
+ failedMessage);
+ break;
+#if DEBUG
+ // Status="Skipped" is only produced by the DEBUG short-circuit
+ // in CheckForUpdatesAsync. User-skipped versions keep
+ // Status="Available", so this case must not exist in RELEASE
+ // or it would surface a confusing "disabled in debug builds"
+ // dialog to end users.
+ case "Skipped":
+ await ShowUpdateInfoDialogAsync(
+ "Skipped",
+ LocalizationHelper.GetString("Update_Title_Skipped"),
+ LocalizationHelper.GetString("Update_Message_Skipped_Debug"));
+ break;
+#endif
+ }
+ }
+
+ if (!shouldContinue)
+ {
+ Exit();
+ }
+ }
+ finally
+ {
+ System.Threading.Interlocked.Exchange(ref _manualUpdateCheckInFlight, 0);
+ }
+ }
+
+ private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string message)
+ {
+ // Prefer the Hub window when open so the dialog appears modal to what
+ // the user is actually looking at; fall back to the hidden keep-alive
+ // window so the dialog still renders if the Hub has been dismissed.
+ XamlRoot? xamlRoot = null;
+ if (_hubWindow != null && !_hubWindow.IsClosed)
+ xamlRoot = (_hubWindow.Content as FrameworkElement)?.XamlRoot;
+ if (xamlRoot == null)
+ xamlRoot = (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot;
+ if (xamlRoot == null)
+ {
+ // Log the stable English key, not the localized title, so log
+ // grepping works across locales.
+ Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}");
+ return;
+ }
+
+ var dialog = new ContentDialog
+ {
+ Title = title,
+ Content = message,
+ CloseButtonText = LocalizationHelper.GetString("Update_OK"),
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = xamlRoot
+ };
+ try
+ {
+ await dialog.ShowAsync();
+ }
+ catch (System.Runtime.InteropServices.COMException ex)
+ {
+ // ContentDialog.ShowAsync throws COMException if its XamlRoot's
+ // visual tree is torn down mid-await (e.g. Hub window closed).
+ Logger.Warn($"[Update] Dialog dismissed before completion ({logKey}): 0x{ex.HResult:X8}");
+ }
+ catch (InvalidOperationException ex)
+ {
+ // WinUI throws InvalidOperationException when another ContentDialog
+ // is already open on the same thread/XamlRoot. The re-entrancy
+ // guard only blocks duplicate *update* dialogs; collisions with
+ // other features' dialogs (onboarding, connection, etc.) must be
+ // tolerated here so the fire-and-forget call sites don't crash.
+ Logger.Warn($"[Update] Dialog could not be shown ({logKey}): {ex.Message}");
}
}
@@ -3489,7 +3782,7 @@ private async Task DownloadAndInstallUpdateAsync()
var downloadedAsset = await AppUpdater.DownloadUpdateAsync();
- progressDialog?.Close();
+ TryCloseProgressDialog(progressDialog);
if (downloadedAsset == null || !System.IO.File.Exists(downloadedAsset.FilePath))
{
@@ -3504,11 +3797,30 @@ private async Task DownloadAndInstallUpdateAsync()
catch (Exception ex)
{
Logger.Error($"Update failed: {ex.Message}");
- progressDialog?.Close();
+ TryCloseProgressDialog(progressDialog);
return false;
}
}
+ private static void TryCloseProgressDialog(DownloadProgressDialog? dialog)
+ {
+ if (dialog == null) return;
+ try
+ {
+ dialog.Close();
+ }
+ catch (System.Runtime.InteropServices.COMException)
+ {
+ // Window already closed — closing a closed WinUI window throws
+ // COMException 0x80070578. Swallow so a real exception in the
+ // outer catch isn't masked by this cleanup failure.
+ }
+ catch (InvalidOperationException)
+ {
+ // Same as above for other "already-disposed" race variants.
+ }
+ }
+
#endregion
#region Deep Links
diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs
index 5767a2d97..3df7ea909 100644
--- a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs
+++ b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
+using OpenClaw.Shared;
using OpenClawTray.Helpers;
using OpenClawTray.Services;
using System;
@@ -54,7 +55,7 @@ public UpdateDialog(string version, string changelog)
// Content
var content = new StackPanel { Spacing = 12 };
- var currentVersion = typeof(UpdateDialog).Assembly.GetName().Version?.ToString() ?? "Unknown";
+ var currentVersion = AppVersionInfo.Version;
content.Children.Add(new TextBlock
{
Text = string.Format(LocalizationHelper.GetString("Update_CurrentVersion"), currentVersion),
diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
index cb77f8daa..7d78a0987 100644
--- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
+++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
@@ -9,7 +9,7 @@
true
Assets\openclaw.ico
OpenClawTray
- 0.4.4
+ 0.4.7
en-US
x64;ARM64
win-x64;win-arm64
@@ -38,8 +38,9 @@
true
Never
SideloadOnly
-
+
@@ -79,6 +80,108 @@
$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\'))
+
+
+
+
+
+
+
+
+ ]*?\\bVersion\\s*=\\s*[\"'])[^\"']+([\"'])",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+ if (!regex.IsMatch(text)) {
+ Log.LogError("SyncAppxManifestVersion: could not find Identity/@Version in " + ManifestPath);
+ return false;
+ }
+ var updated = regex.Replace(text, "${1}" + FourPartVersion + "$2", 1);
+ if (updated == text) {
+ return true;
+ }
+
+ // Clear any read-only attribute (some non-Git SCMs mark files read-only) so the
+ // write doesn't fail with an opaque UnauthorizedAccessException.
+ try {
+ var attrs = System.IO.File.GetAttributes(ManifestPath);
+ if ((attrs & System.IO.FileAttributes.ReadOnly) != 0) {
+ System.IO.File.SetAttributes(ManifestPath, attrs & ~System.IO.FileAttributes.ReadOnly);
+ }
+ } catch (System.Exception ex) {
+ Log.LogWarning("SyncAppxManifestVersion: could not clear read-only on " + ManifestPath + ": " + ex.Message);
+ }
+
+ // Atomic write: stage to a sibling temp file then replace, so parallel readers never
+ // see a truncated manifest. File.Replace is atomic on NTFS. The finally block makes
+ // sure the temp file never lingers (it isn't in .gitignore and would otherwise show
+ // up in `git status` if Replace throws something other than FileNotFoundException).
+ var tempPath = ManifestPath + ".sync-tmp";
+ System.IO.File.WriteAllText(tempPath, updated);
+ try {
+ try {
+ System.IO.File.Replace(tempPath, ManifestPath, destinationBackupFileName: null);
+ } catch (System.IO.FileNotFoundException) {
+ // Replace requires destination to exist; if a concurrent build deleted it, fall
+ // back to a plain move (no 3-arg overload on .NET Framework, which is what the
+ // inline task compiler targets).
+ System.IO.File.Move(tempPath, ManifestPath);
+ }
+ } finally {
+ try {
+ if (System.IO.File.Exists(tempPath)) {
+ System.IO.File.Delete(tempPath);
+ }
+ } catch { /* best-effort cleanup */ }
+ }
+ Log.LogMessage(MessageImportance.High, "Synced Package.appxmanifest Identity/@Version to " + FourPartVersion);
+ ]]>
+
+
+
+
+
+
+
+
+ <_AppxManifestPath>$(MSBuildThisFileDirectory)Package.appxmanifest
+ <_StrippedVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '[-+].*$', ''))
+ <_VersionDotCount>$([System.Text.RegularExpressions.Regex]::Matches('$(_StrippedVersion)', '\.').Count)
+ <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '3'">$(_StrippedVersion)
+ <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '2'">$(_StrippedVersion).0
+ <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0.0
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest
index cbab15c60..7016ca5c7 100644
--- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest
+++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest
@@ -9,10 +9,13 @@
+
+ Version="0.0.0.0" />
OpenClaw Companion
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml
index e65636683..345baa6c8 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml
@@ -18,7 +18,7 @@
-
{
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
@@ -107,7 +109,7 @@ private async void OnCopySupportClick(object sender, RoutedEventArgs e)
}
else
{
- context = $"OpenClaw Hub v0.1.0\n"
+ context = $"OpenClaw Hub {AppVersionInfo.DisplayVersion}\n"
+ $"OS: {Environment.OSVersion}\n"
+ $"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n"
+ $"Connection: {CurrentApp.AppState?.Status}\n"
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index d03d69494..f05081430 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -598,7 +598,7 @@ private void StartMcpServer()
() => { lock (_capabilitiesLock) return _capabilities.ToArray(); },
_logger,
serverName: "openclaw-tray-mcp",
- serverVersion: typeof(NodeService).Assembly.GetName().Version?.ToString() ?? "0.0.0");
+ serverVersion: AppVersionInfo.Version);
// Bearer-token auth. Token is created on first start and persists
// alongside other OpenClawTray data (so OPENCLAW_TRAY_DATA_DIR
// isolation in tests scopes the token too); CLI/agent registration
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 052ca9801..05e3fcdbc 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -403,6 +403,29 @@
Download & Install
+
+ You're up to date
+
+
+ You're running the latest version (v{0}).
+
+
+ Couldn't check for updates
+
+
+ Something went wrong while checking for updates.
+
+{0}
+
+
+ Update check skipped
+
+
+ Update checks are disabled in debug builds.
+
+
+ OK
+
Open Dashboard
@@ -1527,9 +1550,6 @@ On your gateway host (Mac/Linux), run:
OpenClaw Hub
-
- v0.1.0
-
.NET 10 / WinUI 3 / WinAppSDK 1.8
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 69c912520..9925ff15b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -374,6 +374,29 @@
Télécharger & Installer
+
+ Vous êtes à jour
+
+
+ Vous utilisez la dernière version (v{0}).
+
+
+ Impossible de vérifier les mises à jour
+
+
+ Une erreur s'est produite lors de la vérification des mises à jour.
+
+{0}
+
+
+ Vérification ignorée
+
+
+ La vérification des mises à jour est désactivée dans les builds de débogage.
+
+
+ OK
+
Ouvrir le tableau de bord
@@ -1084,7 +1107,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
OpenClaw a encore besoin d’être configuré avant de pouvoir ouvrir le Hub. Utilisez Retour pour revenir à l’assistant ou aux étapes de connexion, corrigez la configuration manquante, puis réessayez Terminer.
- D'accord
+ OK
Bienvenue dans OpenClaw
@@ -1478,9 +1501,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
OpenClaw Hub
-
- v0.1.0
-
.NET 10 / WinUI 3 / WinAppSDK 1.8
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index fe766d944..7d4e4f19f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -375,6 +375,29 @@
Downloaden en installeren
+
+ Je bent up-to-date
+
+
+ Je gebruikt de nieuwste versie (v{0}).
+
+
+ Kan niet op updates controleren
+
+
+ Er is iets misgegaan tijdens het controleren op updates.
+
+{0}
+
+
+ Updatecontrole overgeslagen
+
+
+ Updatecontroles zijn uitgeschakeld in debug-builds.
+
+
+ OK
+
Dashboard openen
@@ -1085,7 +1108,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
OpenClaw heeft nog setup nodig voordat de Hub kan worden geopend. Gebruik Terug om naar de wizard of verbindingsstappen te gaan, los de ontbrekende setup op en probeer daarna Voltooien opnieuw.
- Oké
+ OK
Welkom bij OpenClaw
@@ -1479,9 +1502,6 @@ Voer op uw gateway-host (Mac/Linux) uit:
OpenClaw Hub
-
- v0.1.0
-
.NET 10 / WinUI 3 / WinAppSDK 1.8
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index c1b9c1ed9..be369d14d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -374,6 +374,29 @@
下载并安装
+
+ 已是最新版本
+
+
+ 您正在使用最新版本 (v{0})。
+
+
+ 无法检查更新
+
+
+ 检查更新时出错。
+
+{0}
+
+
+ 已跳过更新检查
+
+
+ 调试版本已禁用更新检查。
+
+
+ 确定
+
打开仪表板
@@ -1478,9 +1501,6 @@
OpenClaw Hub
-
- v0.1.0
-
.NET 10 / WinUI 3 / WinAppSDK 1.8
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index c9218f538..b7eb7a627 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -374,6 +374,29 @@
下載並安裝
+
+ 已是最新版本
+
+
+ 您正在使用最新版本 (v{0})。
+
+
+ 無法檢查更新
+
+
+ 檢查更新時發生錯誤。
+
+{0}
+
+
+ 已略過更新檢查
+
+
+ 偵錯版本已停用更新檢查。
+
+
+ 確定
+
打開儀表板
@@ -1478,9 +1501,6 @@
OpenClaw Hub
-
- v0.1.0
-
.NET 10 / WinUI 3 / WinAppSDK 1.8
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index 655a27bba..a03b2b161 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -221,6 +221,34 @@ private static bool IsInvariantValue(string value) =>
value.Contains("https://", StringComparison.Ordinal) ||
value.Contains("~/", StringComparison.Ordinal);
+ ///
+ /// Keys whose value is a Latin-script loanword (e.g. "OK") that reads
+ /// natively in English/French/Dutch but should still be translated for
+ /// non-Latin scripts (zh-CN, zh-TW). For these keys the test permits
+ /// fr-fr and nl-nl to be identical to en-us while zh-cn and zh-tw differ —
+ /// the "all-or-nothing" rule does not apply.
+ ///
+ private static readonly HashSet LatinScriptInvariantResourceKeys = new(StringComparer.Ordinal)
+ {
+ "Update_OK",
+ "Onboarding_IncompleteSetup_Close",
+ };
+
+ // Locales whose translations are allowed to remain identical to en-us
+ // for keys in LatinScriptInvariantResourceKeys (e.g. "OK"). The check in
+ // Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales requires the
+ // set of locales sharing the en-us value to *exactly* equal this set.
+ //
+ // Pitfall: adding a new Latin-script locale (say de-de) that also uses
+ // "OK" verbatim will break that test unless de-de is added here too. If
+ // you add such a locale, update this set; if you add a non-Latin-script
+ // locale, do nothing.
+ private static readonly HashSet LatinScriptLocales = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "fr-fr",
+ "nl-nl",
+ };
+
private static bool IsInvariantOrDeferred(string key, string value) =>
InvariantOrDeferredResourceKeys.Contains(key)
|| IsInvariantValue(value)
@@ -516,6 +544,15 @@ public void Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales()
if (identicalLocales.Count != localeResw.Count)
{
+ // Allow Latin-script loanwords (e.g. "OK") to be identical
+ // across en-us/fr-fr/nl-nl while still being translated for
+ // non-Latin-script locales (zh-CN, zh-TW).
+ if (LatinScriptInvariantResourceKeys.Contains(key)
+ && identicalLocales.All(l => LatinScriptLocales.Contains(l))
+ && LatinScriptLocales.All(l => identicalLocales.Contains(l, StringComparer.OrdinalIgnoreCase)))
+ {
+ continue;
+ }
partial.Add($"{key} ({enValue}) identical in [{string.Join(", ", identicalLocales)}]");
continue;
}
diff --git a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs
index 1d2d0ac27..bea35e8c2 100644
--- a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs
+++ b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs
@@ -320,7 +320,7 @@ private static string BuildDashboardJsonl()
// ── Footer row ────────────────────────────────────────────────────
components.Add(Component("ftr", "Row", new() { ["children"] = Children("ftrVer", "ftrDiv", "ftrConn") }));
- components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.4"), ["usageHint"] = "caption" }));
+ components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.7"), ["usageHint"] = "caption" }));
components.Add(Component("ftrDiv", "Divider", new() { ["axis"] = "vertical" }));
components.Add(Component("ftrConn", "Text", new() { ["text"] = Lit("Connected"), ["usageHint"] = "caption" }));
From 190a53fba49cc788bf79df9d16b6b38e20cb98a9 Mon Sep 17 00:00:00 2001
From: AlexAlves87
Date: Thu, 21 May 2026 21:56:31 +0200
Subject: [PATCH 042/115] refactor: remove dead recording handler and extract
two static helpers from App.xaml.cs (#493)
* refactor: remove dead OnRecordingStateChanged handler and its localization strings
NodeService.RecordingStateChanged was never subscribed in App.xaml.cs,
so the handler and its six Activity_Recording* resource keys were unreachable
at runtime. Removing them and the matching entries from all five locale
resw files so localization tests remain green.
Co-Authored-By: Claude Sonnet 4.6
* refactor: extract AppRunMarker from App.xaml.cs
The three static run-marker methods (CheckPreviousRun, MarkRunStarted,
MarkRunEnded) depended only on a file path. Moving them to a dedicated
class removes 37 lines from App and gives the behavior a named home.
Co-Authored-By: Claude Sonnet 4.6
* refactor: extract CliUninstallHandler from App.xaml.cs
The --uninstall CLI path (RunCliUninstallAsync, CliRedact, AttachConsole
P/Invoke) had no dependency on App instance state. Moving it to a
dedicated static class removes ~150 lines from App and gives the
headless uninstall entry point a clear, named home.
Co-Authored-By: Claude Sonnet 4.6
---------
Co-authored-by: AlexAlves87
Co-authored-by: Claude Sonnet 4.6
---
src/OpenClaw.Tray.WinUI/App.xaml.cs | 221 +-----------------
.../CliUninstallHandler.cs | 146 ++++++++++++
.../Services/AppRunMarker.cs | 47 ++++
.../Strings/en-us/Resources.resw | 19 --
.../Strings/fr-fr/Resources.resw | 19 --
.../Strings/nl-nl/Resources.resw | 19 --
.../Strings/zh-cn/Resources.resw | 19 --
.../Strings/zh-tw/Resources.resw | 19 --
8 files changed, 198 insertions(+), 311 deletions(-)
create mode 100644 src/OpenClaw.Tray.WinUI/CliUninstallHandler.cs
create mode 100644 src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 4cf9caa75..5b3ea316e 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -22,7 +22,6 @@
using System.IO;
using System.IO.Pipes;
using System.Linq;
-using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -279,7 +278,7 @@ public IntPtr GetHubWindowHandle()
?? 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");
+ private static readonly AppRunMarker s_runMarker = new(Path.Combine(DataPath, "run.marker"));
public App()
{
@@ -297,8 +296,8 @@ public App()
InitializeComponent();
- CheckPreviousRun();
- MarkRunStarted();
+ s_runMarker.Check();
+ s_runMarker.MarkStarted();
// Hook up crash handlers
this.UnhandledException += OnUnhandledException;
@@ -326,7 +325,7 @@ private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEv
private void OnProcessExit(object? sender, EventArgs e)
{
- MarkRunEnded();
+ s_runMarker.MarkEnded();
try
{
Logger.Info($"Process exiting (ExitCode={Environment.ExitCode})");
@@ -361,192 +360,6 @@ private static void LogCrash(string source, Exception? ex)
catch { /* Ignore logging failures */ }
}
- // -----------------------------------------------------------------------
- // CLI uninstall path
- // Invoked when --uninstall is present in argv. Runs headlessly without
- // creating the tray UI. Attaches to the parent console so stdout/stderr
- // are visible when invoked from PowerShell or cmd.
- // -----------------------------------------------------------------------
-
- [DllImport("kernel32.dll", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static extern bool AttachConsole(int dwProcessId);
-
- private const int AttachParentProcess = -1;
-
- private static async Task RunCliUninstallAsync(string[] args)
- {
- // Attach to parent console so output is visible when invoked from
- // PowerShell or cmd. Fails silently if no parent console exists.
- AttachConsole(AttachParentProcess);
-
- bool dryRun = args.Contains("--dry-run", StringComparer.OrdinalIgnoreCase);
- bool confirmDestructive = args.Contains("--confirm-destructive", StringComparer.OrdinalIgnoreCase);
-
- // Locate --json-output argument
- string? jsonOutputPath = null;
- for (int i = 0; i < args.Length - 1; i++)
- {
- if (string.Equals(args[i], "--json-output", StringComparison.OrdinalIgnoreCase))
- {
- jsonOutputPath = args[i + 1];
- break;
- }
- }
-
- if (!confirmDestructive && !dryRun)
- {
- Console.Error.WriteLine(
- "ERROR: --uninstall requires --confirm-destructive (or --dry-run).");
- Environment.Exit(2);
- return;
- }
-
- var settings = new SettingsManager();
- var engine = LocalGatewayUninstall.Build(settings, logger: new AppLogger());
-
- LocalGatewayUninstallResult result;
- try
- {
- result = await engine.RunAsync(new LocalGatewayUninstallOptions
- {
- DryRun = dryRun,
- ConfirmDestructive = confirmDestructive
- });
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"ERROR: Uninstall engine threw: {ex.Message}");
- Environment.Exit(1);
- return;
- }
-
- // Human-readable summary (tokens already redacted inside engine steps)
- Console.WriteLine("OpenClaw Local Gateway Uninstall");
- Console.WriteLine($"DryRun: {dryRun}");
- Console.WriteLine($"Success: {result.Success}");
- Console.WriteLine($"Steps: {result.Steps.Count} ({result.SkippedSteps.Count} skipped)");
- Console.WriteLine($"Errors: {result.Errors.Count}");
- foreach (var e in result.Errors)
- Console.Error.WriteLine($" ERROR: {CliRedact(e)}");
- Console.WriteLine("Postconditions:");
- Console.WriteLine($" WslDistroAbsent: {result.Postconditions.WslDistroAbsent}");
- Console.WriteLine($" AutostartCleared: {result.Postconditions.AutostartCleared}");
- Console.WriteLine($" SetupStateAbsent: {result.Postconditions.SetupStateAbsent}");
- Console.WriteLine($" DeviceTokenCleared: {result.Postconditions.DeviceTokenCleared}");
- Console.WriteLine($" McpTokenPreserved: {result.Postconditions.McpTokenPreserved}");
- Console.WriteLine($" KeepalivesAbsent: {result.Postconditions.KeepalivesAbsent}");
- Console.WriteLine($" VhdDirAbsent: {result.Postconditions.VhdDirAbsent}");
- Console.WriteLine($" LocalGatewayRecordsAbsent: {result.Postconditions.LocalGatewayRecordsAbsent}");
- Console.WriteLine($" LocalGatewayIdentityDirsAbsent: {result.Postconditions.LocalGatewayIdentityDirsAbsent}");
-
- // JSON output — redaction applied to step details and error strings
- if (jsonOutputPath != null)
- {
- try
- {
- var dir = Path.GetDirectoryName(jsonOutputPath);
- if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
- Directory.CreateDirectory(dir);
-
- var payload = new
- {
- success = result.Success,
- dry_run = dryRun,
- steps = result.Steps.Select(s => new
- {
- name = s.Name,
- status = s.Status.ToString(),
- detail = CliRedact(s.Detail)
- }),
- errors = result.Errors.Select(CliRedact),
- skipped_steps = result.SkippedSteps,
- postconditions = new
- {
- wsl_distro_absent = result.Postconditions.WslDistroAbsent,
- autostart_cleared = result.Postconditions.AutostartCleared,
- setup_state_absent = result.Postconditions.SetupStateAbsent,
- device_token_cleared = result.Postconditions.DeviceTokenCleared,
- mcp_token_preserved = result.Postconditions.McpTokenPreserved,
- keepalives_absent = result.Postconditions.KeepalivesAbsent,
- vhd_dir_absent = result.Postconditions.VhdDirAbsent,
- local_gateway_records_absent = result.Postconditions.LocalGatewayRecordsAbsent,
- local_gateway_identity_dirs_absent = result.Postconditions.LocalGatewayIdentityDirsAbsent
- }
- };
-
- File.WriteAllText(jsonOutputPath, JsonSerializer.Serialize(
- payload, new JsonSerializerOptions { WriteIndented = true }));
-
- Console.WriteLine($"JSON result: {jsonOutputPath}");
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine(
- $"WARNING: Failed to write JSON output to '{jsonOutputPath}': {ex.Message}");
- }
- }
-
- Environment.Exit(result.Success ? 0 : 1);
- }
-
- ///
- /// Redacts token/key material from a string before writing it to CLI
- /// stdout or a JSON output file. Mirrors the PowerShell Invoke-Redact
- /// pattern in validate-wsl-gateway-uninstall.ps1.
- ///
- private static string? CliRedact(string? value)
- {
- if (string.IsNullOrEmpty(value)) return value;
- // Redact JSON field values for known secret fields.
- value = System.Text.RegularExpressions.Regex.Replace(
- value,
- @"(""(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)""\s*:\s*"")[^""]+("")",
- "$1$2");
- // Redact bare key=value / key: value patterns.
- value = System.Text.RegularExpressions.Regex.Replace(
- value,
- @"(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,""'}{]+",
- "$1");
- return value;
- }
-
- private static void CheckPreviousRun()
- {
- try
- {
- if (File.Exists(RunMarkerPath))
- {
- var startedAt = File.ReadAllText(RunMarkerPath);
- Logger.Error($"Previous session did not exit cleanly (started {startedAt})");
- File.Delete(RunMarkerPath);
- }
- }
- catch { }
- }
-
- private static void MarkRunStarted()
- {
- try
- {
- var dir = Path.GetDirectoryName(RunMarkerPath);
- if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
- Directory.CreateDirectory(dir);
- File.WriteAllText(RunMarkerPath, DateTime.Now.ToString("O"));
- }
- catch { }
- }
-
- private static void MarkRunEnded()
- {
- try
- {
- if (File.Exists(RunMarkerPath))
- File.Delete(RunMarkerPath);
- }
- catch { }
- }
-
private void OnUiThread(Microsoft.UI.Dispatching.DispatcherQueueHandler action) => _dispatcherQueue?.TryEnqueue(action);
///
@@ -582,7 +395,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
// -----------------------------------------------------------------------
if (_startupArgs.Contains("--uninstall", StringComparer.OrdinalIgnoreCase))
{
- await RunCliUninstallAsync(_startupArgs);
+ await CliUninstallHandler.RunAsync(_startupArgs);
return; // Environment.Exit called inside; defensive return
}
@@ -2129,30 +1942,6 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
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)
{
diff --git a/src/OpenClaw.Tray.WinUI/CliUninstallHandler.cs b/src/OpenClaw.Tray.WinUI/CliUninstallHandler.cs
new file mode 100644
index 000000000..d64be2edb
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/CliUninstallHandler.cs
@@ -0,0 +1,146 @@
+using OpenClawTray.Services;
+using OpenClawTray.Services.LocalGatewaySetup;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+
+namespace OpenClawTray;
+
+///
+/// Headless CLI handler for the --uninstall flag. Attaches to the parent console
+/// and drives the local gateway uninstall engine without creating any tray UI.
+///
+internal static class CliUninstallHandler
+{
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool AttachConsole(int dwProcessId);
+
+ private const int AttachParentProcess = -1;
+
+ public static async Task RunAsync(string[] args)
+ {
+ AttachConsole(AttachParentProcess);
+
+ bool dryRun = args.Contains("--dry-run", StringComparer.OrdinalIgnoreCase);
+ bool confirmDestructive = args.Contains("--confirm-destructive", StringComparer.OrdinalIgnoreCase);
+
+ string? jsonOutputPath = null;
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (string.Equals(args[i], "--json-output", StringComparison.OrdinalIgnoreCase))
+ {
+ jsonOutputPath = args[i + 1];
+ break;
+ }
+ }
+
+ if (!confirmDestructive && !dryRun)
+ {
+ Console.Error.WriteLine("ERROR: --uninstall requires --confirm-destructive (or --dry-run).");
+ Environment.Exit(2);
+ return;
+ }
+
+ var settings = new SettingsManager();
+ var engine = LocalGatewayUninstall.Build(settings, logger: new AppLogger());
+
+ LocalGatewayUninstallResult result;
+ try
+ {
+ result = await engine.RunAsync(new LocalGatewayUninstallOptions
+ {
+ DryRun = dryRun,
+ ConfirmDestructive = confirmDestructive
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"ERROR: Uninstall engine threw: {ex.Message}");
+ Environment.Exit(1);
+ return;
+ }
+
+ Console.WriteLine("OpenClaw Local Gateway Uninstall");
+ Console.WriteLine($"DryRun: {dryRun}");
+ Console.WriteLine($"Success: {result.Success}");
+ Console.WriteLine($"Steps: {result.Steps.Count} ({result.SkippedSteps.Count} skipped)");
+ Console.WriteLine($"Errors: {result.Errors.Count}");
+ foreach (var e in result.Errors)
+ Console.Error.WriteLine($" ERROR: {Redact(e)}");
+ Console.WriteLine("Postconditions:");
+ Console.WriteLine($" WslDistroAbsent: {result.Postconditions.WslDistroAbsent}");
+ Console.WriteLine($" AutostartCleared: {result.Postconditions.AutostartCleared}");
+ Console.WriteLine($" SetupStateAbsent: {result.Postconditions.SetupStateAbsent}");
+ Console.WriteLine($" DeviceTokenCleared: {result.Postconditions.DeviceTokenCleared}");
+ Console.WriteLine($" McpTokenPreserved: {result.Postconditions.McpTokenPreserved}");
+ Console.WriteLine($" KeepalivesAbsent: {result.Postconditions.KeepalivesAbsent}");
+ Console.WriteLine($" VhdDirAbsent: {result.Postconditions.VhdDirAbsent}");
+ Console.WriteLine($" LocalGatewayRecordsAbsent: {result.Postconditions.LocalGatewayRecordsAbsent}");
+ Console.WriteLine($" LocalGatewayIdentityDirsAbsent: {result.Postconditions.LocalGatewayIdentityDirsAbsent}");
+
+ if (jsonOutputPath != null)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(jsonOutputPath);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ var payload = new
+ {
+ success = result.Success,
+ dry_run = dryRun,
+ steps = result.Steps.Select(s => new
+ {
+ name = s.Name,
+ status = s.Status.ToString(),
+ detail = Redact(s.Detail)
+ }),
+ errors = result.Errors.Select(Redact),
+ skipped_steps = result.SkippedSteps,
+ postconditions = new
+ {
+ wsl_distro_absent = result.Postconditions.WslDistroAbsent,
+ autostart_cleared = result.Postconditions.AutostartCleared,
+ setup_state_absent = result.Postconditions.SetupStateAbsent,
+ device_token_cleared = result.Postconditions.DeviceTokenCleared,
+ mcp_token_preserved = result.Postconditions.McpTokenPreserved,
+ keepalives_absent = result.Postconditions.KeepalivesAbsent,
+ vhd_dir_absent = result.Postconditions.VhdDirAbsent,
+ local_gateway_records_absent = result.Postconditions.LocalGatewayRecordsAbsent,
+ local_gateway_identity_dirs_absent = result.Postconditions.LocalGatewayIdentityDirsAbsent
+ }
+ };
+
+ File.WriteAllText(jsonOutputPath,
+ JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
+
+ Console.WriteLine($"JSON result: {jsonOutputPath}");
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"WARNING: Failed to write JSON output to '{jsonOutputPath}': {ex.Message}");
+ }
+ }
+
+ Environment.Exit(result.Success ? 0 : 1);
+ }
+
+ ///
+ /// Redacts token/key material from a string before writing it to CLI stdout or a JSON output file.
+ /// Mirrors the PowerShell Invoke-Redact pattern in validate-wsl-gateway-uninstall.ps1.
+ ///
+ internal static string? Redact(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return value;
+ value = System.Text.RegularExpressions.Regex.Replace(
+ value,
+ @"(""(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)""\s*:\s*"")[^""]+("")",
+ "$1$2");
+ value = System.Text.RegularExpressions.Regex.Replace(
+ value,
+ @"(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,""'}{]+",
+ "$1");
+ return value;
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs b/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs
new file mode 100644
index 000000000..4c3b3e7eb
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/AppRunMarker.cs
@@ -0,0 +1,47 @@
+namespace OpenClawTray.Services;
+
+///
+/// Writes and clears a run marker file so the next launch can detect an unclean exit.
+///
+internal sealed class AppRunMarker
+{
+ private readonly string _path;
+
+ public AppRunMarker(string path) => _path = path;
+
+ public void Check()
+ {
+ try
+ {
+ if (File.Exists(_path))
+ {
+ var startedAt = File.ReadAllText(_path);
+ Logger.Error($"Previous session did not exit cleanly (started {startedAt})");
+ File.Delete(_path);
+ }
+ }
+ catch { }
+ }
+
+ public void MarkStarted()
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(_path);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(_path, DateTime.Now.ToString("O"));
+ }
+ catch { }
+ }
+
+ public void MarkEnded()
+ {
+ try
+ {
+ if (File.Exists(_path))
+ File.Delete(_path);
+ }
+ catch { }
+ }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 21bdcea66..872730cba 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -816,25 +816,6 @@ Use one of these options:
Allow camera recording
-
-
- Screen recording started
-
-
- Screen recording complete
-
-
- Camera recording started
-
-
- Camera recording complete
-
-
- {0} recording requested by agent
-
-
- {0} recording sent to agent
-
⚡ New: Activity Stream
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 47c68f7b2..a458ff7a2 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -772,25 +772,6 @@ Utilisez l'une de ces options :
Autoriser l'enregistrement caméra
-
-
- Enregistrement d'écran démarré
-
-
- Enregistrement d'écran terminé
-
-
- Enregistrement caméra démarré
-
-
- Enregistrement caméra terminé
-
-
- Enregistrement {0} demandé par l'agent
-
-
- Enregistrement {0} envoyé à l'agent
-
⚡ Nouveau: Fil d'activité
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index c2309fd62..fff2bb843 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -773,25 +773,6 @@ Gebruik een van deze opties:
Camera-opname toestaan
-
-
- Schermopname gestart
-
-
- Schermopname voltooid
-
-
- Camera-opname gestart
-
-
- Camera-opname voltooid
-
-
- {0}-opname aangevraagd door agent
-
-
- {0}-opname verzonden naar agent
-
⚡ Nieuw: Activiteitenstroom
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index f485e0439..6ff7b9051 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -772,25 +772,6 @@
允许摄像头录制
-
-
- 屏幕录制已开始
-
-
- 屏幕录制已完成
-
-
- 摄像头录制已开始
-
-
- 摄像头录制已完成
-
-
- {0}录制由代理请求
-
-
- {0}录制已发送给代理
-
⚡ 新功能: 活动流
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index e45c2e503..0b12de062 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -772,25 +772,6 @@
允許攝影機錄製
-
-
- 螢幕錄製已開始
-
-
- 螢幕錄製已完成
-
-
- 攝影機錄製已開始
-
-
- 攝影機錄製已完成
-
-
- {0}錄製由代理請求
-
-
- {0}錄製已傳送給代理
-
⚡ 新功能: 串流活動
From 12416d282a23b8f40f426ab73c68f9f712ab7553 Mon Sep 17 00:00:00 2001
From: AlexAlves87
Date: Thu, 21 May 2026 21:56:35 +0200
Subject: [PATCH 043/115] feat: add ExecApprovalsCoordinator and
ICanPresentEvaluator (#471)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: add ExecApprovalsCoordinator and ICanPresentEvaluator
Wires the full two-pass approval pipeline: validate → normalize →
buildContext → evaluate(pass1) → prompt/fallback → evaluate(pass2).
ICanPresentEvaluator keeps the coordinator UI-free and testable without
Win32 APIs. SemaphoreSlim serializes prompt and second pass for
concurrent requests. Allowlist persistence and use recording are stubs.
Coordinator not wired in production; enforced by test.
Co-Authored-By: Claude Sonnet 4.6
* fix: wrap HandleAsync in outer catch to guarantee typed deny on unexpected exceptions
Without an outer catch, exceptions from ResolveReadOnly, CanPresent,
FallbackDecision, or an out-of-range prompt outcome escaped HandleAsync
untyped, breaking the fail-closed contract. Any unhandled exception now
returns InternalError("unexpected-exception") with an Error-level log
instead of propagating to the caller. Regression test added.
Co-Authored-By: Claude Sonnet 4.6
---------
Co-authored-by: AlexAlves87
Co-authored-by: Claude Sonnet 4.6
---
.../ExecApprovals/ExecApprovalV2Result.cs | 12 +-
.../ExecApprovals/ExecApprovalsCoordinator.cs | 274 ++++++++++
.../ExecApprovals/ICanPresentEvaluator.cs | 30 ++
.../ExecApprovalsCoordinatorTests.cs | 502 ++++++++++++++++++
4 files changed, 817 insertions(+), 1 deletion(-)
create mode 100644 src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs
create mode 100644 src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs
create mode 100644 tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs
diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs
index 9e74a32f2..3c1658fbb 100644
--- a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs
+++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalV2Result.cs
@@ -11,7 +11,9 @@ public enum ExecApprovalV2Code
AllowlistMiss,
UserDenied,
ValidationFailed,
- ResolutionFailed
+ ResolutionFailed,
+ InternalError, // invariant violations and unexpected internal bugs detected at runtime
+ Allow, // coordinator approved; caller may execute the command
}
///
@@ -50,5 +52,13 @@ public static ExecApprovalV2Result ValidationFailed(string reason)
public static ExecApprovalV2Result ResolutionFailed(string reason)
=> new(ExecApprovalV2Code.ResolutionFailed, reason);
+ public static ExecApprovalV2Result InternalError(string reason)
+ => new(ExecApprovalV2Code.InternalError, reason);
+
+ public static ExecApprovalV2Result Allow()
+ => new(ExecApprovalV2Code.Allow, "approved");
+
+ public bool IsAllow => Code == ExecApprovalV2Code.Allow;
+
public override string ToString() => $"{Code}: {Reason}";
}
diff --git a/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs
new file mode 100644
index 000000000..28df41b55
--- /dev/null
+++ b/src/OpenClaw.Shared/ExecApprovals/ExecApprovalsCoordinator.cs
@@ -0,0 +1,274 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenClaw.Shared;
+
+namespace OpenClaw.Shared.ExecApprovals;
+
+// Full coordinator pipeline: validate → normalize → buildContext → evaluate(pass1) →
+// prompt/fallback → [persistAllowlistEntry stub] → evaluate(pass2) → final decision.
+// Rail 10: no WinUI types. Rail 17: SemaphoreSlim serializes the prompt+pass2 block.
+// Rail 19: not wired in production src in PR7 — verified by test 15.
+// Must be registered as singleton when wired (PR8+): the SemaphoreSlim is per-instance.
+public sealed class ExecApprovalsCoordinator : IExecApprovalV2Handler
+{
+ private readonly ExecApprovalsStore _store;
+ private readonly ICanPresentEvaluator _canPresent;
+ private readonly IExecApprovalV2PromptHandler _prompt;
+ private readonly IOpenClawLogger _logger;
+
+ // Serializes the prompt call + second-pass block (rail 17).
+ // Does NOT protect validate/normalize/buildContext — those are stateless.
+ private readonly SemaphoreSlim _promptLock = new(1, 1);
+
+ public ExecApprovalsCoordinator(
+ ExecApprovalsStore store,
+ ICanPresentEvaluator canPresentEvaluator,
+ IExecApprovalV2PromptHandler promptHandler,
+ IOpenClawLogger logger)
+ {
+ _store = store;
+ _canPresent = canPresentEvaluator;
+ _prompt = promptHandler;
+ _logger = logger;
+ }
+
+ public async Task HandleAsync(NodeInvokeRequest request, string correlationId)
+ {
+ if (string.IsNullOrEmpty(correlationId))
+ correlationId = Guid.NewGuid().ToString("N");
+
+ try
+ {
+ // Step 1: validate
+ var validation = ExecApprovalV2InputValidator.Validate(request);
+ if (!validation.IsValid)
+ return LogAndReturn(validation.Error!, correlationId,
+ promptAttempted: false, fallbackUsed: false);
+
+ // Step 2: normalize (unwrap shell wrappers, resolve executables, build canonical identity)
+ var norm = ExecApprovalV2Normalizer.Normalize(validation.Request!);
+ if (!norm.IsResolved)
+ return LogAndReturn(norm.Error!, correlationId,
+ promptAttempted: false, fallbackUsed: false);
+ var identity = norm.Identity!;
+
+ // Step 3: buildContext
+ var resolved = _store.ResolveReadOnly(identity.AgentId);
+
+ // Env injection guard — preserves SystemCapability.HandleRunAsync:343-351 behavior.
+ // identity.Env is IReadOnlyDictionary; copy to Dictionary for Sanitize.
+ var envInput = identity.Env is null
+ ? null
+ : new Dictionary(identity.Env, StringComparer.OrdinalIgnoreCase);
+ var envResult = ExecEnvSanitizer.Sanitize(envInput);
+
+ if (envResult.Blocked.Length > 0)
+ {
+ var blockedNames = (string[])envResult.Blocked.Clone();
+ Array.Sort(blockedNames, StringComparer.OrdinalIgnoreCase);
+ _logger.Warn($"[EXEC-APPROVALS] [{correlationId}] env-blocked: [{string.Join(", ", blockedNames)}]");
+ return LogAndReturn(ExecApprovalV2Result.ValidationFailed("env-blocked"),
+ correlationId, promptAttempted: false, fallbackUsed: false);
+ }
+
+ var sanitizedEnv = envResult.Allowed as IReadOnlyDictionary;
+ IReadOnlyList matches = resolved.Defaults.Security == ExecSecurity.Allowlist
+ ? ExecAllowlistMatcher.MatchAll(resolved.Allowlist, identity.AllowlistResolutions)
+ : [];
+
+ var context = new ExecApprovalEvaluation(
+ identity.Command,
+ identity.DisplayCommand,
+ identity.AgentId,
+ resolved.Defaults.Security,
+ resolved.Defaults.Ask,
+ sanitizedEnv,
+ identity.AllowlistResolutions,
+ identity.AllowAlwaysPatterns,
+ matches);
+
+ // Step 4: first pass (approvalDecision always null in PR7 — CVE #8682, ADR-0002 Phase 2)
+ var pass1 = ExecApprovalEvaluator.Evaluate(context, null);
+ if (pass1 is ExecHostPolicyDecision.DenyOutcome denyPass1)
+ return LogAndReturn(denyPass1.Error, correlationId,
+ promptAttempted: false, fallbackUsed: false, canonical: context.DisplayCommand);
+ if (pass1 is ExecHostPolicyDecision.AllowOutcome)
+ {
+ // Pre-approved path (security=Full, ask=Off or allowlist satisfied): skip prompt
+ _logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " +
+ $"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " +
+ $"reason=approved fallbackUsed=false promptAttempted=false");
+ return ExecApprovalV2Result.Allow();
+ }
+ // RequiresPromptOutcome → continue to prompt/fallback block
+
+ // Steps 5-7: prompt/fallback + second pass (critical section)
+ bool promptAttempted = false;
+ bool fallbackUsed = false;
+
+ await _promptLock.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ ExecApprovalDecision followupDecision;
+
+ if (_canPresent.CanPresent(identity.SessionKey))
+ {
+ promptAttempted = true;
+ ExecApprovalPromptOutcome promptResult;
+ try
+ {
+ promptResult = await _prompt.PromptAsync(
+ BuildPromptRequest(context, identity, correlationId),
+ cancellationToken: default).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Presenter failure → fail-closed, no fallback delegation
+ return LogAndReturn(ExecApprovalV2Result.UserDenied("prompt-failed"),
+ correlationId, promptAttempted: true, fallbackUsed: false,
+ canonical: context.DisplayCommand);
+ }
+
+ // Allow (plain) from a prompt handler is an invariant violation —
+ // only AllowOnce and AllowAlways are semantically valid from UI.
+ if (promptResult == ExecApprovalPromptOutcome.Allow)
+ {
+ _logger.Error($"[EXEC-APPROVALS] [{correlationId}] invariant: " +
+ "prompt returned Allow — treating as invariant violation deny");
+ return LogAndReturn(ExecApprovalV2Result.InternalError("prompt-returned-allow"),
+ correlationId, promptAttempted: true, fallbackUsed: false,
+ canonical: context.DisplayCommand);
+ }
+
+ // Exhaustive mapping without _ so the compiler warns if ExecApprovalPromptOutcome
+ // gains a new value. Allow is unreachable here — handled by the check above.
+ followupDecision = promptResult switch
+ {
+ ExecApprovalPromptOutcome.Deny => ExecApprovalDecision.Deny,
+ ExecApprovalPromptOutcome.AllowOnce => ExecApprovalDecision.AllowOnce,
+ ExecApprovalPromptOutcome.AllowAlways => ExecApprovalDecision.AllowAlways,
+ ExecApprovalPromptOutcome.Allow => throw new UnreachableException("prompt-returned-allow handled above"),
+ };
+ }
+ else
+ {
+ fallbackUsed = true;
+ followupDecision = FallbackDecision(context, resolved.Defaults.AskFallback);
+ }
+
+ // Step 6: AddAllowlistEntry stub (PR9 implements for AllowAlways + security==Allowlist)
+
+ // Step 7: second pass — must never return RequiresPrompt
+ var pass2 = ExecApprovalEvaluator.Evaluate(context, followupDecision);
+ if (pass2 is ExecHostPolicyDecision.DenyOutcome denyPass2)
+ return LogAndReturn(denyPass2.Error, correlationId, promptAttempted, fallbackUsed,
+ canonical: context.DisplayCommand);
+ if (pass2 is ExecHostPolicyDecision.RequiresPromptOutcome)
+ {
+ _logger.Error($"[EXEC-APPROVALS] [{correlationId}] invariant: " +
+ "second pass returned RequiresPrompt");
+ return LogAndReturn(ExecApprovalV2Result.InternalError("second-pass-requires-prompt"),
+ correlationId, promptAttempted, fallbackUsed, canonical: context.DisplayCommand);
+ }
+ // AllowOutcome → fall through to steps 8-10
+ }
+ finally
+ {
+ _promptLock.Release();
+ }
+
+ // Step 8: RecordAllowlistUse stub (PR9)
+
+ // Step 9: final allow log
+ _logger.Info($"[EXEC-APPROVALS] [{correlationId}] path=new " +
+ $"canonical=\"{SanitizeForLog(context.DisplayCommand)}\" decision=allow " +
+ $"reason=approved fallbackUsed={fallbackUsed} promptAttempted={promptAttempted}");
+
+ // Step 10: return Allow
+ return ExecApprovalV2Result.Allow();
+ }
+ catch (Exception ex)
+ {
+ // Outer safety net: any unhandled exception in buildContext, CanPresent, FallbackDecision,
+ // or an out-of-range prompt outcome produces a typed deny instead of escaping HandleAsync.
+ // Rail 1: failures in the new path must never be silent or untyped.
+ var msg = $"[EXEC-APPROVALS] [{correlationId}] path=new " +
+ $"canonical=\"\" decision=deny reason=unexpected-exception " +
+ $"fallbackUsed=false promptAttempted=false";
+ _logger.Error(msg, ex);
+ return ExecApprovalV2Result.InternalError("unexpected-exception");
+ }
+ }
+
+ // Fail-safe defaults when no UI is available (Saltzer/Schroeder fail-safe defaults, OWASP ASVS 4.1.4).
+ // ask=Always → Deny: human approval is a precondition; without UI the only safe outcome is deny.
+ private static ExecApprovalDecision FallbackDecision(
+ ExecApprovalEvaluation context,
+ ExecAsk askFallback)
+ {
+ return askFallback switch
+ {
+ ExecAsk.Off => ExecApprovalDecision.AllowOnce,
+ ExecAsk.OnMiss => context.AllowlistSatisfied
+ ? ExecApprovalDecision.AllowOnce
+ : ExecApprovalDecision.Deny,
+ ExecAsk.Always => ExecApprovalDecision.Deny,
+ ExecAsk.Deny => ExecApprovalDecision.Deny,
+ _ => ExecApprovalDecision.Deny, // defensive
+ };
+ }
+
+ private static ExecApprovalV2PromptRequest BuildPromptRequest(
+ ExecApprovalEvaluation context,
+ CanonicalCommandIdentity identity,
+ string correlationId)
+ => new()
+ {
+ DisplayCommand = context.DisplayCommand, // NOT sanitized — presenter's responsibility (rail 11)
+ Cwd = identity.Cwd,
+ Security = context.Security,
+ Ask = context.Ask,
+ AgentId = context.AgentId ?? "main",
+ ResolvedPath = context.Resolution?.ResolvedPath,
+ SessionKey = identity.SessionKey,
+ CorrelationId = correlationId,
+ // Host omitted in PR7 (no gateway wiring yet)
+ };
+
+ // Anti log-injection: replaces control characters in DisplayCommand before writing to logs.
+ // Truncates to 200 chars — sufficient for triage, bounded for disk-bound logs.
+ private static string SanitizeForLog(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return "";
+ Span buffer = stackalloc char[Math.Min(value.Length, 200)];
+ var count = 0;
+ foreach (var ch in value)
+ {
+ if (count == buffer.Length) break;
+ buffer[count++] = char.IsControl(ch) ? ' ' : ch;
+ }
+ var sanitized = new string(buffer[..count]);
+ return value.Length > count ? sanitized + "..." : sanitized;
+ }
+
+ private ExecApprovalV2Result LogAndReturn(
+ ExecApprovalV2Result result,
+ string correlationId,
+ bool promptAttempted,
+ bool fallbackUsed,
+ string? canonical = null)
+ {
+ var safeCanonical = SanitizeForLog(canonical);
+ var msg = $"[EXEC-APPROVALS] [{correlationId}] path=new " +
+ $"canonical=\"{safeCanonical}\" decision=deny reason={result.Reason} " +
+ $"fallbackUsed={fallbackUsed} promptAttempted={promptAttempted}";
+ if (result.Code == ExecApprovalV2Code.InternalError)
+ _logger.Error(msg);
+ else
+ _logger.Warn(msg);
+ return result;
+ }
+}
diff --git a/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs b/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs
new file mode 100644
index 000000000..7e53c244e
--- /dev/null
+++ b/src/OpenClaw.Shared/ExecApprovals/ICanPresentEvaluator.cs
@@ -0,0 +1,30 @@
+namespace OpenClaw.Shared.ExecApprovals;
+
+// Determines whether the coordinator can present a UI prompt for this request.
+// Doc 08 F1 lists four inputs to canPresent: requestSessionKey, activeSessionKey,
+// lastInputSeconds, desktopInteractive. Only requestSessionKey is passed by the
+// coordinator — the other three are encapsulated inside the implementation:
+// activeSessionKey: provided by whatever tracks the active tray session.
+// lastInputSeconds: read via Win32 GetLastInputInfo (OQ-F1).
+// desktopInteractive: read via OpenInputDesktop / WTSQuerySessionInformation (OQ-F1).
+// Keeping these out of the interface keeps the coordinator UI-free (rail 10) and
+// testable without Win32. Must never throw — fail to false (no UI available).
+public interface ICanPresentEvaluator
+{
+ bool CanPresent(string? requestSessionKey);
+}
+
+// Default for PR7: UI not wired yet. Everything routes to FallbackDecision.
+public sealed class AlwaysCannotPresentEvaluator : ICanPresentEvaluator
+{
+ public static readonly AlwaysCannotPresentEvaluator Instance = new();
+ public bool CanPresent(string? requestSessionKey) => false;
+}
+
+// Test double: always reports UI available. Used in coordinator tests to
+// exercise the prompt path with the null prompt handler.
+public sealed class AlwaysCanPresentEvaluator : ICanPresentEvaluator
+{
+ public static readonly AlwaysCanPresentEvaluator Instance = new();
+ public bool CanPresent(string? requestSessionKey) => true;
+}
diff --git a/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs b/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs
new file mode 100644
index 000000000..e3a9af200
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/ExecApprovalsCoordinatorTests.cs
@@ -0,0 +1,502 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using OpenClaw.Shared;
+using OpenClaw.Shared.ExecApprovals;
+
+namespace OpenClaw.Shared.Tests;
+
+///
+/// Tests for PR7: ExecApprovalsCoordinator full pipeline.
+/// Covers rail 8 (observability), rail 10 (UI-free), rail 17 (concurrency),
+/// rail 19 (production wiring inert), env injection guard, and log injection prevention.
+///
+public class ExecApprovalsCoordinatorTests : IDisposable
+{
+ private readonly string _dir;
+
+ public ExecApprovalsCoordinatorTests()
+ {
+ _dir = Path.Combine(Path.GetTempPath(), $"oca-coord-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_dir);
+ }
+
+ public void Dispose() => Directory.Delete(_dir, recursive: true);
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static JsonElement Parse(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+
+ // ["cmd","/c","echo","hello"] reliably resolves cmd.exe on Windows via WellKnownPaths.
+ // Shell wrapper form: singular resolution succeeds; allowlistResolutions=[] (echo is a builtin).
+ private static NodeInvokeRequest Req(string argsJson)
+ => new() { Id = "r1", Command = "system.run", Args = Parse(argsJson) };
+
+ private static NodeInvokeRequest DefaultReq()
+ => Req("""{"command":["cmd","/c","echo","hello"]}""");
+
+ private void WriteStoreFile(string json)
+ => File.WriteAllText(Path.Combine(_dir, "exec-approvals.json"), json);
+
+ private ExecApprovalsCoordinator MakeCoordinator(
+ ICanPresentEvaluator? canPresent = null,
+ IExecApprovalV2PromptHandler? prompt = null,
+ IOpenClawLogger? logger = null)
+ {
+ var log = logger ?? NullLogger.Instance;
+ return new(
+ new ExecApprovalsStore(_dir, log),
+ canPresent ?? AlwaysCannotPresentEvaluator.Instance,
+ prompt ?? ExecApprovalV2NullPromptHandler.Instance,
+ log);
+ }
+
+ // ── 1. No file → SecurityDeny (default-deny on first activation) ──────────
+
+ [Fact]
+ public async Task NoFile_ReturnsSecurityDeny()
+ {
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c1");
+ Assert.Equal(ExecApprovalV2Code.SecurityDeny, result.Code);
+ }
+
+ // ── 2. security=full → Allow ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task SecurityFull_AskOff_ReturnsAllow()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c2");
+ Assert.True(result.IsAllow);
+ }
+
+ // ── 3. security=deny → SecurityDeny ──────────────────────────────────────
+
+ [Fact]
+ public async Task SecurityDeny_ReturnsSecurityDeny()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c3");
+ Assert.Equal(ExecApprovalV2Code.SecurityDeny, result.Code);
+ }
+
+ // ── 4. ask=always, canPresent=false, askFallback=deny → UserDenied ────────
+
+ [Fact]
+ public async Task AskAlways_CannotPresent_FallbackDeny_ReturnsUserDenied()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"deny"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c4");
+ // FallbackDecision(ExecAsk.Deny) → ExecApprovalDecision.Deny → pass2 step2 → UserDenied
+ Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code);
+ Assert.Equal("user-denied", result.Reason);
+ }
+
+ // ── 5. ask=always, canPresent=false, askFallback=off → Allow ─────────────
+
+ [Fact]
+ public async Task AskAlways_CannotPresent_FallbackOff_ReturnsAllow()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"off"}}""");
+ var log = new CapturingLogger();
+ var result = await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), "c5");
+ Assert.True(result.IsAllow);
+ Assert.NotNull(log.LastInfo);
+ Assert.Contains("fallbackUsed=True", log.LastInfo, StringComparison.Ordinal);
+ }
+
+ // ── 6. canPresent=true, NullPromptHandler → UserDenied ───────────────────
+
+ [Fact]
+ public async Task CanPresent_NullPrompt_ReturnsUserDenied()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var result = await MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: ExecApprovalV2NullPromptHandler.Instance).HandleAsync(DefaultReq(), "c6");
+ Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code);
+ }
+
+ // ── 7. canPresent=true, AllowOnce → Allow ────────────────────────────────
+
+ [Fact]
+ public async Task CanPresent_AllowOnce_ReturnsAllow()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var log = new CapturingLogger();
+ var result = await MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.AllowOnce),
+ logger: log).HandleAsync(DefaultReq(), "c7");
+ Assert.True(result.IsAllow);
+ Assert.Contains("promptAttempted=True", log.LastInfo!, StringComparison.Ordinal);
+ Assert.DoesNotContain("fallbackUsed=True", log.LastInfo!, StringComparison.Ordinal);
+ }
+
+ // ── 8. canPresent=true, AllowAlways → Allow ───────────────────────────────
+
+ [Fact]
+ public async Task CanPresent_AllowAlways_ReturnsAllow()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var result = await MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.AllowAlways))
+ .HandleAsync(DefaultReq(), "c8");
+ Assert.True(result.IsAllow);
+ }
+
+ // ── 9. Invariant: prompt returns Allow → InternalError ────────────────────
+
+ [Fact]
+ public async Task PromptReturnsAllowPlain_ReturnsInternalError()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var result = await MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: new FixedDecisionPromptHandler(ExecApprovalPromptOutcome.Allow))
+ .HandleAsync(DefaultReq(), "c9");
+ Assert.Equal(ExecApprovalV2Code.InternalError, result.Code);
+ Assert.Equal("prompt-returned-allow", result.Reason);
+ }
+
+ // ── 10. Prompt throws → UserDenied, no fallback ───────────────────────────
+
+ [Fact]
+ public async Task PromptThrows_ReturnsUserDenied_FallbackNotUsed()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var log = new CapturingLogger();
+ var result = await MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: new ThrowingPromptHandler(),
+ logger: log).HandleAsync(DefaultReq(), "c10");
+ Assert.Equal(ExecApprovalV2Code.UserDenied, result.Code);
+ Assert.Equal("prompt-failed", result.Reason);
+ // Must not delegate to fallback after presenter failure
+ Assert.Contains("fallbackUsed=False", log.LastWarn!, StringComparison.Ordinal);
+ }
+
+ // ── 11. Input invalid → ValidationFailed ─────────────────────────────────
+
+ [Fact]
+ public async Task InvalidInput_ReturnsValidationFailed()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full"}}""");
+ var result = await MakeCoordinator().HandleAsync(
+ Req("""{}"""), "c11");
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, result.Code);
+ }
+
+ // ── 12. security=allowlist, allowlist empty, ask=off → AllowlistMiss ──────
+
+ [Fact]
+ public async Task SecurityAllowlist_EmptyList_ReturnsAllowlistMiss()
+ {
+ // ["cmd","/c","echo","hello"] → shell wrapper → allowlistResolutions=[] → AllowlistSatisfied=false
+ WriteStoreFile("""{"version":1,"defaults":{"security":"allowlist","ask":"off"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c12");
+ Assert.Equal(ExecApprovalV2Code.AllowlistMiss, result.Code);
+ }
+
+ // ── 13. FallbackDecision(ask=Always) → Deny, not AllowOnce ───────────────
+
+ [Fact]
+ public async Task FallbackDecision_AskFallbackAlways_ReturnsDeny()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"always"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c13");
+ // ExecAsk.Always → ExecApprovalDecision.Deny → pass2 → UserDenied (fail-safe)
+ Assert.False(result.IsAllow);
+ Assert.NotEqual(ExecApprovalV2Code.Allow, result.Code);
+ }
+
+ // ── 14. Rail 8 — 7 log fields present ────────────────────────────────────
+
+ [Fact]
+ public async Task Rail8_AllSevenLogFieldsPresent()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}""");
+ var log = new CapturingLogger();
+ await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), "corr-14");
+
+ // security=deny → LogAndReturn → Warn; check all 7 rail-8 fields
+ Assert.NotNull(log.LastWarn);
+ var msg = log.LastWarn!;
+ Assert.Contains("corr-14", msg, StringComparison.Ordinal);
+ Assert.Contains("path=new", msg, StringComparison.Ordinal);
+ Assert.Contains("canonical=", msg, StringComparison.Ordinal);
+ Assert.Contains("decision=deny", msg, StringComparison.Ordinal);
+ Assert.Contains("reason=", msg, StringComparison.Ordinal);
+ Assert.Contains("fallbackUsed=", msg, StringComparison.Ordinal);
+ Assert.Contains("promptAttempted=", msg, StringComparison.Ordinal);
+ }
+
+ // ── 15. Coordinator not wired in production src ───────────────────────────
+
+ [Fact]
+ public void ProductionWiring_CoordinatorNotReferencedInSrc()
+ {
+ var repoRoot = FindRepoRoot();
+ Assert.NotNull(repoRoot);
+ var srcDir = Path.Combine(repoRoot, "src");
+ var violations = Directory
+ .GetFiles(srcDir, "*.cs", SearchOption.AllDirectories)
+ .Where(f => !f.EndsWith("ExecApprovalsCoordinator.cs", StringComparison.OrdinalIgnoreCase))
+ .Where(f => File.ReadAllText(f).Contains("ExecApprovalsCoordinator", StringComparison.Ordinal))
+ .ToList();
+ Assert.Empty(violations);
+ }
+
+ // ── 16. Rail 10 — coordinator in OpenClaw.Shared, not Tray ───────────────
+
+ [Fact]
+ public void Rail10_CoordinatorAssemblyIsOpenClawShared()
+ {
+ var asm = typeof(ExecApprovalsCoordinator).Assembly.GetName().Name;
+ Assert.Equal("OpenClaw.Shared", asm);
+ }
+
+ // ── 17. Concurrency — 5 simultaneous requests don't corrupt state ─────────
+
+ [Fact]
+ public async Task Concurrency_FiveConcurrentRequests_AllReturnValidResults()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}""");
+ var coordinator = MakeCoordinator();
+ var tasks = Enumerable.Range(0, 5)
+ .Select(i => coordinator.HandleAsync(DefaultReq(), $"conc-{i}"))
+ .ToList();
+ var results = await Task.WhenAll(tasks);
+ Assert.All(results, r => Assert.NotNull(r));
+ Assert.All(results, r => Assert.True(r.IsAllow));
+ }
+
+ // ── 18. Env injection → ValidationFailed("env-blocked") ──────────────────
+
+ [Fact]
+ public async Task EnvInjection_BlockedEnvVar_ReturnsValidationFailed()
+ {
+ // security=full,ask=off rules out other denies; env PATH is always blocked
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}""");
+ var log = new CapturingLogger();
+ var result = await MakeCoordinator(logger: log)
+ .HandleAsync(Req("""{"command":["cmd","/c","echo","hello"],"env":{"PATH":"C:\\evil"}}"""), "c18");
+
+ Assert.Equal(ExecApprovalV2Code.ValidationFailed, result.Code);
+ Assert.Equal("env-blocked", result.Reason);
+ // Separate Warn with blocked names (emitted before LogAndReturn)
+ Assert.Contains(log.Warns, w =>
+ w.Contains("env-blocked", StringComparison.Ordinal) &&
+ w.Contains("PATH", StringComparison.Ordinal));
+ }
+
+ // ── 19. Log injection — DisplayCommand control chars replaced in log ───────
+
+ [Fact]
+ public async Task LogInjection_ControlCharsInCommand_SanitizedInLog()
+ {
+ // \r\n in JSON string → actual CR+LF in the parsed command argument
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"off"}}""");
+ var log = new CapturingLogger();
+ await MakeCoordinator(logger: log)
+ .HandleAsync(Req("""{"command":["cmd","/c","x\r\n[EXEC-APPROVALS] [fake] FAKE"]}"""), "c19");
+
+ // Should allow (security=full, ask=off)
+ Assert.NotNull(log.LastInfo);
+ // CR+LF must not appear literally in the log line
+ Assert.DoesNotContain("\r\n", log.LastInfo!, StringComparison.Ordinal);
+ }
+
+ // ── 20. Lock released after prompt throws — second call must not deadlock ────
+
+ [Fact]
+ public async Task PromptThrows_LockReleasedForSubsequentCall()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var coordinator = MakeCoordinator(
+ canPresent: AlwaysCanPresentEvaluator.Instance,
+ prompt: new ThrowingPromptHandler());
+
+ var first = await coordinator.HandleAsync(DefaultReq(), "lock-1");
+ Assert.Equal(ExecApprovalV2Code.UserDenied, first.Code);
+
+ // Second call must complete — if lock was not released this would deadlock
+ var second = await coordinator.HandleAsync(DefaultReq(), "lock-2");
+ Assert.Equal(ExecApprovalV2Code.UserDenied, second.Code);
+ }
+
+ // ── 21a. Concurrency with actual lock contention ───────────────────────────
+
+ [Fact]
+ public async Task Concurrency_PromptPathWithLockContention_AllReturnValidResults()
+ {
+ // ask=always + canPresent=true → all requests enter the locked block
+ // NullPromptHandler returns Deny → all should be UserDenied (no deadlock, no corruption)
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var coordinator = MakeCoordinator(canPresent: AlwaysCanPresentEvaluator.Instance);
+ var tasks = Enumerable.Range(0, 5)
+ .Select(i => coordinator.HandleAsync(DefaultReq(), $"cont-{i}"))
+ .ToList();
+ var results = await Task.WhenAll(tasks);
+ Assert.All(results, r => Assert.NotNull(r));
+ // NullPromptHandler returns Deny → UserDenied for all
+ Assert.All(results, r => Assert.Equal(ExecApprovalV2Code.UserDenied, r.Code));
+ }
+
+ // ── 22a. ExecApprovalV2Result — new codes constructible (InternalError, Allow) ──
+
+ [Fact]
+ public void V2Result_InternalError_CodeAndReason()
+ {
+ var r = ExecApprovalV2Result.InternalError("invariant-violation");
+ Assert.Equal(ExecApprovalV2Code.InternalError, r.Code);
+ Assert.Equal("invariant-violation", r.Reason);
+ Assert.False(r.IsAllow);
+ }
+
+ [Fact]
+ public void V2Result_Allow_IsAllowTrueAndReasonApproved()
+ {
+ var r = ExecApprovalV2Result.Allow();
+ Assert.Equal(ExecApprovalV2Code.Allow, r.Code);
+ Assert.Equal("approved", r.Reason);
+ Assert.True(r.IsAllow);
+ }
+
+ [Fact]
+ public void V2Result_IsAllow_FalseForAllDenyCodes()
+ {
+ Assert.False(ExecApprovalV2Result.SecurityDeny("x").IsAllow);
+ Assert.False(ExecApprovalV2Result.UserDenied("x").IsAllow);
+ Assert.False(ExecApprovalV2Result.ValidationFailed("x").IsAllow);
+ Assert.False(ExecApprovalV2Result.InternalError("x").IsAllow);
+ }
+
+ // ── 21. ICanPresentEvaluator stubs ────────────────────────────────────────
+
+ [Fact]
+ public void AlwaysCannotPresent_AlwaysReturnsFalse()
+ {
+ Assert.False(AlwaysCannotPresentEvaluator.Instance.CanPresent(null));
+ Assert.False(AlwaysCannotPresentEvaluator.Instance.CanPresent("session-key"));
+ }
+
+ [Fact]
+ public void AlwaysCanPresent_AlwaysReturnsTrue()
+ {
+ Assert.True(AlwaysCanPresentEvaluator.Instance.CanPresent(null));
+ Assert.True(AlwaysCanPresentEvaluator.Instance.CanPresent("session-key"));
+ }
+
+ // ── 22. Empty correlationId → auto-generated 32-char hex ─────────────────
+
+ [Fact]
+ public async Task EmptyCorrelationId_AutoGeneratedInLog()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"deny"}}""");
+ var log = new CapturingLogger();
+ await MakeCoordinator(logger: log).HandleAsync(DefaultReq(), "");
+
+ Assert.NotNull(log.LastWarn);
+ // log format: "[EXEC-APPROVALS] [] path=new ..."
+ // auto-generated correlationId: Guid.NewGuid().ToString("N") → 32 hex chars
+ var msg = log.LastWarn!;
+ var second = msg.IndexOf('[', msg.IndexOf(']') + 1) + 1;
+ var end = msg.IndexOf(']', second);
+ Assert.True(end > second);
+ var id = msg[second..end];
+ Assert.Equal(32, id.Length);
+ Assert.True(id.All(c => char.IsAsciiHexDigit(c)), $"Expected 32 hex chars, got: {id}");
+ }
+
+ // ── 23. FallbackDecision(OnMiss, AllowlistSatisfied=false) → Deny ─────────
+
+ [Fact]
+ public async Task FallbackDecision_AskFallbackOnMiss_NotSatisfied_ReturnsDeny()
+ {
+ // security=full, ask=always → RequiresPrompt in pass1
+ // canPresent=false → FallbackDecision(context, ExecAsk.OnMiss)
+ // AllowlistSatisfied=false (security=Full, not Allowlist) → Deny
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always","askFallback":"on-miss"}}""");
+ var result = await MakeCoordinator().HandleAsync(DefaultReq(), "c23");
+ Assert.False(result.IsAllow);
+ }
+
+ // ── 24. Outer safety net — CanPresent throws → InternalError, not exception ───
+
+ [Fact]
+ public async Task CanPresent_Throws_ReturnsInternalError_NotException()
+ {
+ WriteStoreFile("""{"version":1,"defaults":{"security":"full","ask":"always"}}""");
+ var log = new CapturingLogger();
+ var result = await MakeCoordinator(
+ canPresent: new ThrowingCanPresentEvaluator(),
+ logger: log).HandleAsync(DefaultReq(), "outer-1");
+
+ Assert.Equal(ExecApprovalV2Code.InternalError, result.Code);
+ Assert.Equal("unexpected-exception", result.Reason);
+ Assert.Contains(log.Errors, e => e.Contains("unexpected-exception"));
+ }
+
+ // ── Test doubles ──────────────────────────────────────────────────────────
+
+ private sealed class FixedDecisionPromptHandler : IExecApprovalV2PromptHandler
+ {
+ private readonly ExecApprovalPromptOutcome _outcome;
+ public FixedDecisionPromptHandler(ExecApprovalPromptOutcome o) => _outcome = o;
+ public Task PromptAsync(
+ ExecApprovalV2PromptRequest _,
+ CancellationToken cancellationToken = default)
+ => Task.FromResult(_outcome);
+ }
+
+ private sealed class ThrowingCanPresentEvaluator : ICanPresentEvaluator
+ {
+ public bool CanPresent(string? requestSessionKey)
+ => throw new InvalidOperationException("simulated canPresent crash");
+ }
+
+ private sealed class ThrowingPromptHandler : IExecApprovalV2PromptHandler
+ {
+ public Task PromptAsync(
+ ExecApprovalV2PromptRequest _,
+ CancellationToken cancellationToken = default)
+ => throw new InvalidOperationException("simulated presenter crash");
+ }
+
+ private sealed class CapturingLogger : IOpenClawLogger
+ {
+ public List Infos { get; } = [];
+ public List Warns { get; } = [];
+ public List Errors { get; } = [];
+ public string? LastInfo => Infos.Count > 0 ? Infos[^1] : null;
+ public string? LastWarn => Warns.Count > 0 ? Warns[^1] : null;
+ public string? LastError => Errors.Count > 0 ? Errors[^1] : null;
+ public void Info(string m) => Infos.Add(m);
+ public void Debug(string m) { }
+ public void Warn(string m) => Warns.Add(m);
+ public void Error(string m, Exception? _ = null) => Errors.Add(m);
+ }
+
+ private static string? FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir != null)
+ {
+ if (File.Exists(Path.Combine(dir.FullName, "openclaw-windows-node.slnx")))
+ return dir.FullName;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+}
From e0224e37bc93d28563b9fa9706bc4339b525e994 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 13:00:59 -0700
Subject: [PATCH 044/115] test(tray): add unit tests for
NodeCapabilityGating.GetLocalNodeCapabilities (#495)
Add 8 tests covering the null-guard, case-insensitive lookup, and
return-value contracts of the untested GetLocalNodeCapabilities helper.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../NodeCapabilityGatingTests.cs | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
index b9d747614..5edb58499 100644
--- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
+++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs
@@ -1,3 +1,4 @@
+using OpenClaw.Shared;
using OpenClawTray.Services;
namespace OpenClaw.Tray.Tests;
@@ -144,4 +145,80 @@ public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse()
Assert.False(NodeCapabilityGating.ShouldRegisterBrowserProxy(s));
Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s));
}
+
+ // ── GetLocalNodeCapabilities ──────────────────────────────────────────────
+
+ [Fact]
+ public void GetLocalNodeCapabilities_NullNodes_ReturnsNull()
+ {
+ Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(null, "device-1"));
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_EmptyNodes_ReturnsNull()
+ {
+ Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities([], "device-1"));
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_NullDeviceId_ReturnsNull()
+ {
+ var nodes = new[] { new GatewayNodeInfo { NodeId = "device-1" } };
+ Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, null));
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_EmptyDeviceId_ReturnsNull()
+ {
+ var nodes = new[] { new GatewayNodeInfo { NodeId = "device-1" } };
+ Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, ""));
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_NoMatchingNode_ReturnsNull()
+ {
+ var nodes = new[]
+ {
+ new GatewayNodeInfo { NodeId = "device-1", Capabilities = ["canvas"] },
+ new GatewayNodeInfo { NodeId = "device-2", Capabilities = ["screen"] },
+ };
+ Assert.Null(NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-99"));
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_MatchingNode_ReturnsCapabilities()
+ {
+ var nodes = new[]
+ {
+ new GatewayNodeInfo { NodeId = "device-1", Capabilities = ["canvas", "screen"] },
+ new GatewayNodeInfo { NodeId = "device-2", Capabilities = ["location"] },
+ };
+ var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-1");
+ Assert.NotNull(result);
+ Assert.Equal(new[] { "canvas", "screen" }, result);
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_MatchingNodeCaseInsensitive_ReturnsCapabilities()
+ {
+ var nodes = new[]
+ {
+ new GatewayNodeInfo { NodeId = "Device-ABC", Capabilities = ["canvas"] },
+ };
+ var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-abc");
+ Assert.NotNull(result);
+ Assert.Equal(new[] { "canvas" }, result);
+ }
+
+ [Fact]
+ public void GetLocalNodeCapabilities_NodeWithNoCapabilities_ReturnsEmptyList()
+ {
+ var nodes = new[]
+ {
+ new GatewayNodeInfo { NodeId = "device-1", Capabilities = [] },
+ };
+ var result = NodeCapabilityGating.GetLocalNodeCapabilities(nodes, "device-1");
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
}
From 8446ea5527795cdbda048e218d5f06c155c9fbef Mon Sep 17 00:00:00 2001
From: Mike Harsh
Date: Thu, 21 May 2026 13:02:46 -0700
Subject: [PATCH 045/115] Add easy setup diagnostics logging (#497)
Emit redacted human and JSONL setup traces for install, pairing, gateway, repair, and remove flows.
Co-authored-by: Mike Harsh
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
DEVELOPMENT.md | 6 +
README.md | 2 +
docs/SETUP.md | 3 +
.../Onboarding/V2/OnboardingV2Bridge.cs | 16 +-
.../LocalGatewaySetup/LocalGatewaySetup.cs | 156 +++-
.../LocalGatewaySetupDiagnostics.cs | 751 ++++++++++++++++++
.../LocalGatewaySetupDiagnosticsTests.cs | 154 ++++
.../OpenClaw.Tray.Tests.csproj | 1 +
8 files changed, 1061 insertions(+), 28 deletions(-)
create mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs
create mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index e70126015..fb70f4ec8 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -436,6 +436,12 @@ File-based logging with automatic rotation:
- Rotation: When log exceeds 5MB, old log → `openclaw-tray.log.old`
- Thread-safe: Uses lock for concurrent writes
+**Easy-button setup diagnostics:**
+- Human summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt`
+- Machine-readable latest trace: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl`
+- Per-run traces: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl`
+- Contents are redacted and cover setup phases, WSL commands, pairing, gateway checks, repair, and remove lifecycle steps.
+
**Log Levels:**
- `INFO` - Normal operation (connections, events)
- `WARN` - Recoverable issues (reconnects, timeouts)
diff --git a/README.md b/README.md
index f51fae84f..bb92e8be4 100644
--- a/README.md
+++ b/README.md
@@ -409,6 +409,8 @@ openclaw-windows-node/
Settings are stored in:
- Settings: `%APPDATA%\OpenClawTray\settings.json`
- Logs: `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log`
+- Easy-button setup summary: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt`
+- Easy-button setup JSONL: `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl`
Default gateway: `ws://localhost:18789`
diff --git a/docs/SETUP.md b/docs/SETUP.md
index 2ae877136..556b4e2d4 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -139,6 +139,7 @@ Download and install WebView2 from [Microsoft](https://developer.microsoft.com/m
- Make sure the OpenClaw gateway process is running.
- Check Windows Firewall — if your gateway runs on a different machine, allow inbound traffic on port 18789.
- See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for connection errors.
+- For easy-button setup, repair, or remove failures, start with `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt`; Copilot CLI/debugging tools can use `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.jsonl`.
### "Not yet paired" message on reconnect
@@ -155,6 +156,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for
- Make sure you paste the **entire** setup code — it's a single base64url-encoded string.
- Check for accidental leading/trailing whitespace.
- The code must be from a compatible gateway version. Try entering the gateway URL and token manually instead.
+- If the easy-button setup flow generated the code, check `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\easy-setup-latest.txt` for the failing phase and next action.
### Connection test fails
@@ -162,6 +164,7 @@ See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for
- Check that your token is valid and hasn't expired.
- If the gateway is on another machine, ensure Windows Firewall allows traffic on the gateway port.
- See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for detailed error messages.
+- Easy-button setup diagnostics keep per-run JSONL traces at `%LOCALAPPDATA%\OpenClawTray\Logs\Setup\setup-*.jsonl` and update `easy-setup-latest.txt`/`.jsonl` after each run.
### Wizard shows "offline"
diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs b/src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs
index 23f44c2a6..ee4fa4555 100644
--- a/src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs
+++ b/src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs
@@ -341,7 +341,7 @@ private void EnsureEngineStarted()
DispatchToUi(() =>
{
MarkAllStagesIdle();
- _state.LocalSetupErrorMessage = $"Could not start setup engine: {ex.Message}";
+ _state.LocalSetupErrorMessage = AppendSetupDiagnosticsHint($"Could not start setup engine: {ex.Message}");
_state.LocalSetupCanRetry = true;
});
return;
@@ -388,7 +388,7 @@ private void EnsureEngineStarted()
Logger.Error($"[V2Bridge] RunLocalOnlyAsync threw synchronously: {ex.Message}");
DispatchToUi(() =>
{
- _state.LocalSetupErrorMessage = ex.Message;
+ _state.LocalSetupErrorMessage = AppendSetupDiagnosticsHint(ex.Message);
_state.LocalSetupCanRetry = true;
});
}
@@ -402,7 +402,7 @@ private void OnEngineStateChanged(LocalGatewaySetupState st)
var errorMessage = (status == LocalGatewaySetupStatus.FailedRetryable
|| status == LocalGatewaySetupStatus.FailedTerminal
|| status == LocalGatewaySetupStatus.Blocked)
- ? st.UserMessage
+ ? AppendSetupDiagnosticsHint(st.UserMessage)
: null;
// Hanselman review: only FailedRetryable should expose Try-again.
@@ -747,11 +747,19 @@ private void DispatchFreshLocalReplacementFailure(string message, int capturedGe
var rows = AllRowsIdle();
rows[V2Stage.RemovingExistingGateway] = V2RowState.Failed;
_state.LocalSetupRows = rows;
- _state.LocalSetupErrorMessage = message;
+ _state.LocalSetupErrorMessage = AppendSetupDiagnosticsHint(message);
_state.LocalSetupCanRetry = true;
});
}
+ private static string AppendSetupDiagnosticsHint(string? message)
+ {
+ var baseMessage = string.IsNullOrWhiteSpace(message)
+ ? "Local setup failed."
+ : message;
+ return baseMessage + Environment.NewLine + "Setup diagnostics: " + LocalGatewaySetupDiagnosticsService.LatestSummaryPathForCurrentUser();
+ }
+
private void ApplyFreshLocalReplacementRow(Dictionary rows)
{
if (_state.ExistingGateway == OnboardingV2State.ExistingGatewayKind.AppOwnedLocalWsl
diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs
index 850722702..72b4f2c6b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs
@@ -138,6 +138,7 @@ public sealed class LocalGatewaySetupState
{
public int SchemaVersion { get; set; } = 1;
public string RunId { get; set; } = Guid.NewGuid().ToString("N");
+ public string InstallId { get; set; } = Guid.NewGuid().ToString("N");
public LocalGatewaySetupPhase Phase { get; set; } = LocalGatewaySetupPhase.NotStarted;
public LocalGatewaySetupStatus Status { get; set; } = LocalGatewaySetupStatus.Pending;
public string DistroName { get; set; } = "OpenClawGateway";
@@ -266,12 +267,18 @@ public interface IWslCommandRunner
public sealed class WslExeCommandRunner : IWslCommandRunner
{
private readonly IOpenClawLogger _logger;
+ private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics;
private readonly TimeSpan _defaultTimeout;
private readonly TimeSpan _streamDrainTimeout;
- public WslExeCommandRunner(IOpenClawLogger? logger = null, TimeSpan? defaultTimeout = null, TimeSpan? streamDrainTimeout = null)
+ public WslExeCommandRunner(
+ IOpenClawLogger? logger = null,
+ TimeSpan? defaultTimeout = null,
+ TimeSpan? streamDrainTimeout = null,
+ ILocalGatewaySetupDiagnosticsSink? diagnostics = null)
{
_logger = logger ?? NullLogger.Instance;
+ _diagnostics = diagnostics ?? NullLocalGatewaySetupDiagnosticsSink.Instance;
_defaultTimeout = defaultTimeout ?? TimeSpan.FromSeconds(30);
_streamDrainTimeout = streamDrainTimeout ?? TimeSpan.FromSeconds(5);
}
@@ -377,7 +384,9 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL
ApplyEnvironment(psi, environment);
- _logger.Info($"[WSL] {fileName} {string.Join(" ", arguments.Select(RedactArgument))}");
+ _logger.Info($"[WSL] {fileName} {string.Join(" ", RedactArguments(arguments))}");
+ var commandId = _diagnostics.CommandStarted(fileName, arguments, _defaultTimeout);
+ var sw = Stopwatch.StartNew();
using var process = new Process { StartInfo = psi };
try
@@ -386,7 +395,10 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL
}
catch (Exception ex)
{
- return new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}");
+ var result = new WslCommandResult(-1, string.Empty, $"Failed to start wsl.exe: {ex.Message}");
+ sw.Stop();
+ _diagnostics.CommandCompleted(commandId, fileName, arguments, sw.Elapsed, result, timedOut: false);
+ return result;
}
var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
@@ -424,6 +436,14 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL
{
_logger.Warn($"[WSL] Failed to kill cancelled process: {ex.Message}");
}
+ sw.Stop();
+ _diagnostics.CommandCompleted(
+ commandId,
+ fileName,
+ arguments,
+ sw.Elapsed,
+ new WslCommandResult(-1, string.Empty, "wsl.exe cancelled"),
+ timedOut: false);
throw;
}
@@ -437,10 +457,13 @@ private async Task RunProcessAsync(string fileName, IReadOnlyL
var stdout = await DrainAsync(stdoutTask, _streamDrainTimeout, _logger, isStderr: false);
var stderr = await DrainAsync(stderrTask, _streamDrainTimeout, _logger, isStderr: true);
- if (timedOut)
- return new WslCommandResult(-1, stdout, "wsl.exe timed out");
+ var finalResult = timedOut
+ ? new WslCommandResult(-1, stdout, "wsl.exe timed out")
+ : new WslCommandResult(process.ExitCode, stdout, stderr);
- return new WslCommandResult(process.ExitCode, stdout, stderr);
+ sw.Stop();
+ _diagnostics.CommandCompleted(commandId, fileName, arguments, sw.Elapsed, finalResult, timedOut);
+ return finalResult;
}
internal static async Task DrainAsync(Task readTask, TimeSpan drainTimeout, IOpenClawLogger logger, bool isStderr)
@@ -509,12 +532,8 @@ private static void AppendWslEnvPassthrough(IDictionary environm
environment["WSLENV"] = string.IsNullOrWhiteSpace(existing) ? entry : existing + ":" + entry;
}
- private static string RedactArgument(string argument) =>
- SecretRedactor.Redact(argument.Contains("token", StringComparison.OrdinalIgnoreCase)
- || argument.Contains("private", StringComparison.OrdinalIgnoreCase)
- || argument.Contains("setupCode", StringComparison.OrdinalIgnoreCase)
- ? ""
- : argument);
+ private static IEnumerable RedactArguments(IReadOnlyList arguments) =>
+ SetupDiagnosticsRedactor.RedactArguments(arguments);
}
public sealed record LocalGatewayPreflightResult(
@@ -3367,6 +3386,7 @@ public sealed class LocalGatewaySetupEngine
private readonly IOperatorPairingService _operatorPairing;
private readonly IWindowsTrayNodeProvisioner _windowsTrayNode;
private readonly IOpenClawLogger _logger;
+ private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics;
public event Action? StateChanged;
@@ -3414,7 +3434,8 @@ public LocalGatewaySetupEngine(
IGatewayConfigurationPreparer? gatewayConfigurationPreparer = null,
IGatewayServiceManager? gatewayServiceManager = null,
ILocalGatewayEndpointResolver? endpointResolver = null,
- ISharedGatewayTokenProvisioner? sharedGatewayTokenProvisioner = null)
+ ISharedGatewayTokenProvisioner? sharedGatewayTokenProvisioner = null,
+ ILocalGatewaySetupDiagnosticsSink? diagnosticsSink = null)
{
_options = options;
_stateStore = stateStore;
@@ -3432,13 +3453,19 @@ public LocalGatewaySetupEngine(
_operatorPairing = operatorPairing;
_windowsTrayNode = windowsTrayNode;
_logger = logger ?? NullLogger.Instance;
+ _diagnostics = diagnosticsSink ?? NullLocalGatewaySetupDiagnosticsSink.Instance;
}
public async Task RunLocalOnlyAsync(CancellationToken cancellationToken = default)
{
+ var runStopwatch = Stopwatch.StartNew();
var state = await _stateStore.LoadAsync(cancellationToken) ?? LocalGatewaySetupState.Create(_options);
+ state.RunId = Guid.NewGuid().ToString("N");
+ if (string.IsNullOrWhiteSpace(state.InstallId))
+ state.InstallId = Guid.NewGuid().ToString("N");
state.DistroName = _options.DistroName;
state.GatewayUrl = LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options);
+ _diagnostics.RunStarted(state, _options);
var distroExists = await HasDistroAsync(cancellationToken);
var allowExistingDistroForRun = ShouldAllowExistingDistroForRun(state, distroExists, _options.AllowExistingDistro);
var preflightOptions = _options with { AllowExistingDistro = allowExistingDistroForRun };
@@ -3509,6 +3536,8 @@ await RunPhaseAsync(state, LocalGatewaySetupPhase.ConfigureWslInstance, "Configu
await RunPhaseAsync(state, LocalGatewaySetupPhase.InstallOpenClawCli, "Installing OpenClaw inside WSL", async () =>
{
var result = await _openClawLinuxInstaller.InstallAsync(_options, cancellationToken);
+ foreach (var installerEvent in result.Events ?? Array.Empty())
+ _diagnostics.InstallerEvent(LocalGatewaySetupPhase.InstallOpenClawCli, installerEvent);
if (!result.Success)
{
if (!string.IsNullOrWhiteSpace(result.Detail))
@@ -3609,6 +3638,9 @@ await RunProvisioningPhaseAsync(state, LocalGatewaySetupPhase.PairWindowsTrayNod
await SaveAndPublishAsync(state, cancellationToken);
}
+ runStopwatch.Stop();
+ _diagnostics.RunCompleted(state, runStopwatch.Elapsed);
+ await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken);
return state;
}
@@ -3672,7 +3704,9 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup
if (state.Status is not LocalGatewaySetupStatus.Pending and not LocalGatewaySetupStatus.Running)
return;
+ var phaseStopwatch = Stopwatch.StartNew();
state.StartPhase(phase, message);
+ _diagnostics.PhaseStarted(state, phase, message);
await SaveAndPublishAsync(state, cancellationToken);
bool completed;
try
@@ -3686,6 +3720,9 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup
// Persist cancelled state so restarts don't resume from stale Running phase
try { await _stateStore.SaveAsync(state, CancellationToken.None); } catch { }
StateChanged?.Invoke(state);
+ phaseStopwatch.Stop();
+ _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed);
+ await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), CancellationToken.None);
throw;
}
catch (Exception ex)
@@ -3693,18 +3730,27 @@ private async Task RunPhaseAsync(LocalGatewaySetupState state, LocalGatewaySetup
_logger.Error($"Local gateway setup phase {phase} failed.", ex);
var retryable = ex is not (UnauthorizedAccessException or NotSupportedException or InvalidOperationException or ArgumentException);
state.Block($"{phase.ToString().ToLowerInvariant()}_failed", ex.Message, retryable: retryable, detail: SecretRedactor.Redact(ex.ToString()));
+ phaseStopwatch.Stop();
+ _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed);
await SaveAndPublishAsync(state, cancellationToken);
+ await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken);
return;
}
if (completed && state.Status == LocalGatewaySetupStatus.Running)
{
state.CompletePhase(phase, message);
+ phaseStopwatch.Stop();
+ _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed);
await SaveAndPublishAsync(state, cancellationToken);
}
else if (!completed)
{
+ phaseStopwatch.Stop();
+ _diagnostics.PhaseCompleted(state, phase, message, phaseStopwatch.Elapsed);
await SaveAndPublishAsync(state, cancellationToken);
+ if (state.Status is LocalGatewaySetupStatus.FailedRetryable or LocalGatewaySetupStatus.FailedTerminal or LocalGatewaySetupStatus.Blocked)
+ await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken);
}
}
@@ -3764,71 +3810,106 @@ public sealed class LocalGatewayLifecycleManager : ILocalGatewayLifecycleManager
private readonly ILocalGatewayHealthProbe _healthProbe;
private readonly ILocalGatewaySetupSettings? _settings;
private readonly IOpenClawLogger? _logger;
+ private readonly ILocalGatewaySetupDiagnosticsSink _diagnostics;
- public LocalGatewayLifecycleManager(LocalGatewaySetupOptions options, IWslCommandRunner wsl, ILocalGatewayHealthProbe healthProbe, ILocalGatewaySetupSettings? settings = null, IOpenClawLogger? logger = null)
+ public LocalGatewayLifecycleManager(
+ LocalGatewaySetupOptions options,
+ IWslCommandRunner wsl,
+ ILocalGatewayHealthProbe healthProbe,
+ ILocalGatewaySetupSettings? settings = null,
+ IOpenClawLogger? logger = null,
+ ILocalGatewaySetupDiagnosticsSink? diagnosticsSink = null)
{
_options = options;
_wsl = wsl;
_healthProbe = healthProbe;
_settings = settings;
_logger = logger;
+ _diagnostics = diagnosticsSink ?? NullLocalGatewaySetupDiagnosticsSink.Instance;
}
public async Task RepairAsync(CancellationToken cancellationToken = default)
{
+ var lifecycleStopwatch = Stopwatch.StartNew();
+ _diagnostics.LifecycleStarted("repair");
var steps = new List();
var distros = await _wsl.ListDistrosAsync(cancellationToken);
if (!distros.Any(d => d.Name.Equals(_options.DistroName, StringComparison.OrdinalIgnoreCase) && d.Version == 2))
- return Fail("distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.", steps);
+ {
+ _diagnostics.LifecycleStep("repair", "distro_present", success: false, "distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.");
+ return await CompleteLifecycleAsync("repair", Fail("distro_missing", $"The OpenClaw WSL distro '{_options.DistroName}' was not found.", steps), lifecycleStopwatch, cancellationToken);
+ }
// Tear down any stale keepalive before terminating; we'll spawn a fresh one
// after the gateway becomes healthy. Without this, the old keepalive lingers
// pointing at a now-restarted VM but is no longer tracked by our marker.
WslDistroKeepAlive.Stop(_options.DistroName, _logger);
steps.Add("keepalive_stopped");
+ _diagnostics.LifecycleStep("repair", "keepalive_stopped", success: true);
await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken);
steps.Add("distro_terminated");
+ _diagnostics.LifecycleStep("repair", "distro_terminated", success: true);
var daemonReload = await RunInDistroAsRootAsync(["systemctl", "daemon-reload"], cancellationToken);
steps.Add("daemon_reloaded");
if (!daemonReload.Success)
- return Fail("daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.", steps);
+ {
+ _diagnostics.LifecycleStep("repair", "daemon_reloaded", success: false, "daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.");
+ return await CompleteLifecycleAsync("repair", Fail("daemon_reload_failed", "Failed to reload OpenClaw Gateway systemd units.", steps), lifecycleStopwatch, cancellationToken);
+ }
+ _diagnostics.LifecycleStep("repair", "daemon_reloaded", success: true);
var gateway = await RestartGatewayServiceAsync(steps, cancellationToken);
if (!gateway.Success)
- return gateway;
+ return await CompleteLifecycleAsync("repair", gateway, lifecycleStopwatch, cancellationToken);
var health = await _healthProbe.WaitForHealthyAsync(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(_options), cancellationToken);
steps.Add("gateway_health_checked");
if (!health.Success)
- return Fail("gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."), steps);
+ {
+ _diagnostics.LifecycleStep("repair", "gateway_health_checked", success: false, "gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."));
+ return await CompleteLifecycleAsync("repair", Fail("gateway_unhealthy", health.Error ?? WslLogsHelp("Gateway did not become healthy after repair."), steps), lifecycleStopwatch, cancellationToken);
+ }
+ _diagnostics.LifecycleStep("repair", "gateway_health_checked", success: true);
// Re-arm the keepalive so the VM stays up after repair completes, even if the
// tray that triggered repair exits before the next OnLaunched hook runs.
WslDistroKeepAlive.EnsureStarted(_options.DistroName, _logger);
steps.Add("keepalive_started");
+ _diagnostics.LifecycleStep("repair", "keepalive_started", success: true);
- return new LocalGatewayLifecycleResult(true, Steps: steps);
+ return await CompleteLifecycleAsync("repair", new LocalGatewayLifecycleResult(true, Steps: steps), lifecycleStopwatch, cancellationToken);
}
public async Task RemoveAsync(LocalGatewayRemoveRequest request, CancellationToken cancellationToken = default)
{
+ var lifecycleStopwatch = Stopwatch.StartNew();
+ _diagnostics.LifecycleStarted("remove");
var steps = new List();
if (!request.ConfirmRemove)
- return Fail("confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.", steps);
+ {
+ _diagnostics.LifecycleStep("remove", "confirmation_required", success: false, "confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.");
+ return await CompleteLifecycleAsync("remove", Fail("confirmation_required", "Removing the local OpenClaw Gateway requires explicit confirmation.", steps), lifecycleStopwatch, cancellationToken);
+ }
// Stop the keepalive before terminating so the marker file does not survive
// distro removal and confuse a future install with the same name.
WslDistroKeepAlive.Stop(_options.DistroName, _logger);
steps.Add("keepalive_stopped");
+ _diagnostics.LifecycleStep("remove", "keepalive_stopped", success: true);
await _wsl.TerminateDistroAsync(_options.DistroName, cancellationToken);
steps.Add("distro_terminated");
+ _diagnostics.LifecycleStep("remove", "distro_terminated", success: true);
var unregister = await _wsl.UnregisterDistroAsync(_options.DistroName, cancellationToken);
steps.Add("distro_unregistered");
if (!unregister.Success)
- return Fail("distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.", steps);
+ {
+ _diagnostics.LifecycleStep("remove", "distro_unregistered", success: false, "distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.");
+ return await CompleteLifecycleAsync("remove", Fail("distro_unregister_failed", $"Failed to unregister WSL distro '{_options.DistroName}'.", steps), lifecycleStopwatch, cancellationToken);
+ }
+ _diagnostics.LifecycleStep("remove", "distro_unregistered", success: true);
if (request.ClearLocalCredentials && _settings is not null)
{
@@ -3838,12 +3919,13 @@ public async Task RemoveAsync(LocalGatewayRemoveReq
_settings.UseSshTunnel = false;
_settings.Save();
steps.Add("local_credentials_cleared");
+ _diagnostics.LifecycleStep("remove", "local_credentials_cleared", success: true);
}
if (request.PreserveRelayRegistration)
steps.Add("relay_registration_preserved");
- return new LocalGatewayLifecycleResult(true, Steps: steps);
+ return await CompleteLifecycleAsync("remove", new LocalGatewayLifecycleResult(true, Steps: steps), lifecycleStopwatch, cancellationToken);
}
private async Task RestartGatewayServiceAsync(List steps, CancellationToken cancellationToken)
@@ -3852,17 +3934,29 @@ private async Task RestartGatewayServiceAsync(List<
var enable = await RunInDistroAsRootAsync(["systemctl", "enable", "--now", $"{serviceName}.service"], cancellationToken);
steps.Add($"{serviceName}_enabled");
if (!enable.Success)
+ {
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_enabled", success: false, "service_enable_failed", $"Failed to enable {serviceName}.service.");
return Fail("service_enable_failed", $"Failed to enable {serviceName}.service.", steps);
+ }
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_enabled", success: true);
var restart = await RunInDistroAsRootAsync(["systemctl", "restart", $"{serviceName}.service"], cancellationToken);
steps.Add($"{serviceName}_restarted");
if (!restart.Success)
+ {
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_restarted", success: false, "service_restart_failed", $"Failed to restart {serviceName}.service.");
return Fail("service_restart_failed", $"Failed to restart {serviceName}.service.", steps);
+ }
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_restarted", success: true);
var active = await RunInDistroAsRootAsync(["systemctl", "is-active", "--quiet", $"{serviceName}.service"], cancellationToken);
steps.Add($"{serviceName}_active_checked");
if (!active.Success)
+ {
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_active_checked", success: false, "service_inactive", $"{serviceName}.service is not active after repair.");
return Fail("service_inactive", $"{serviceName}.service is not active after repair.", steps);
+ }
+ _diagnostics.LifecycleStep("repair", $"{serviceName}_active_checked", success: true);
return new LocalGatewayLifecycleResult(true, Steps: steps);
}
@@ -3874,6 +3968,18 @@ private Task RunInDistroAsRootAsync(IReadOnlyList comm
return _wsl.RunAsync(args, cancellationToken);
}
+ private async Task CompleteLifecycleAsync(
+ string operation,
+ LocalGatewayLifecycleResult result,
+ Stopwatch stopwatch,
+ CancellationToken cancellationToken)
+ {
+ stopwatch.Stop();
+ _diagnostics.LifecycleCompleted(operation, result, stopwatch.Elapsed);
+ await _diagnostics.FlushAsync(TimeSpan.FromSeconds(2), cancellationToken);
+ return result;
+ }
+
private static string WslLogsHelp(string message) => message + " Follow aka.ms/wsllogs for WSL diagnostic collection instructions.";
private static LocalGatewayLifecycleResult Fail(string errorCode, string errorMessage, IReadOnlyList steps) => new(false, errorCode, errorMessage, steps);
}
@@ -3950,7 +4056,8 @@ public static LocalGatewaySetupEngine CreateLocalOnly(
catch { /* best-effort — engine will overwrite on first save */ }
}
- var wsl = new WslExeCommandRunner(logger, TimeSpan.FromMinutes(30));
+ var diagnostics = new LocalGatewaySetupDiagnosticsService();
+ var wsl = new WslExeCommandRunner(logger, TimeSpan.FromMinutes(30), diagnostics: diagnostics);
var settingsAdapter = new SettingsManagerLocalGatewaySetupSettings(settings, gatewayRegistry);
var bootstrapTokenProvider = new WslGatewayCliBootstrapTokenProvider(wsl, options.OpenClawInstallPrefix + "/bin/openclaw");
var sharedGatewayTokenProvider = new WslGatewayCliSharedGatewayTokenProvider(wsl);
@@ -3968,7 +4075,8 @@ public static LocalGatewaySetupEngine CreateLocalOnly(
new SettingsWindowsTrayNodeProvisioner(settingsAdapter, windowsNodeConnector, pendingDeviceApprover),
logger,
gatewayConfigurationPreparer: gatewayConfigurationPreparer,
- sharedGatewayTokenProvisioner: new SettingsSharedGatewayTokenProvisioner(settingsAdapter, sharedGatewayTokenProvider, gatewayConfigurationPreparer));
+ sharedGatewayTokenProvisioner: new SettingsSharedGatewayTokenProvisioner(settingsAdapter, sharedGatewayTokenProvider, gatewayConfigurationPreparer),
+ diagnosticsSink: diagnostics);
}
private static string ResolveDistroName(LocalGatewaySetupRuntimeConfiguration runtime, string? explicitDistroName)
diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs
new file mode 100644
index 000000000..c05777fe1
--- /dev/null
+++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs
@@ -0,0 +1,751 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security.Principal;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenClaw.Shared;
+using OpenClawTray.Onboarding.Services;
+using OpenClawTray.Services;
+
+namespace OpenClawTray.Services.LocalGatewaySetup;
+
+///
+/// Schema v1 for easy-button setup diagnostics.
+///
+/// Required top-level JSONL fields:
+/// schema_version, timestamp_utc, run_id, install_id, level, event.
+///
+/// Optional top-level JSONL fields:
+/// phase, visible_stage, status, message, failure_code, retryable,
+/// duration_ms, details.
+///
+public interface ILocalGatewaySetupDiagnosticsSink
+{
+ string? RunTracePath { get; }
+ string? LatestTracePath { get; }
+ string? LatestSummaryPath { get; }
+
+ void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options);
+ void RunCompleted(LocalGatewaySetupState state, TimeSpan duration);
+ void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message);
+ void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration);
+ string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout);
+ void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut);
+ void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent);
+ void LifecycleStarted(string operation);
+ void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null);
+ void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration);
+ Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
+}
+
+public sealed class NullLocalGatewaySetupDiagnosticsSink : ILocalGatewaySetupDiagnosticsSink
+{
+ public static readonly NullLocalGatewaySetupDiagnosticsSink Instance = new();
+
+ public string? RunTracePath => null;
+ public string? LatestTracePath => null;
+ public string? LatestSummaryPath => null;
+
+ public void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options) { }
+ public void RunCompleted(LocalGatewaySetupState state, TimeSpan duration) { }
+ public void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message) { }
+ public void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration) { }
+ public string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout) => string.Empty;
+ public void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut) { }
+ public void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent) { }
+ public void LifecycleStarted(string operation) { }
+ public void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null) { }
+ public void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration) { }
+ public Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.CompletedTask;
+}
+
+public sealed class LocalGatewaySetupDiagnosticsService : ILocalGatewaySetupDiagnosticsSink
+{
+ public const int SchemaVersion = 1;
+ private const int MaxCommandOutputChars = 4096;
+ private const int MaxStoredRecords = 512;
+
+ private static readonly JsonSerializerOptions s_jsonOptions = new()
+ {
+ WriteIndented = false
+ };
+
+ private readonly object _lock = new();
+ private readonly string _setupLogDirectory;
+ private readonly List _records = new();
+ private string? _runId;
+ private string? _installId;
+ private bool _initialized;
+
+ public LocalGatewaySetupDiagnosticsService(string? localDataPath = null)
+ {
+ _setupLogDirectory = Path.Combine(localDataPath ?? ResolveLocalDataPath(), "Logs", "Setup");
+ LatestTracePath = Path.Combine(_setupLogDirectory, "easy-setup-latest.jsonl");
+ LatestSummaryPath = Path.Combine(_setupLogDirectory, "easy-setup-latest.txt");
+ }
+
+ public string? RunTracePath { get; private set; }
+ public string? LatestTracePath { get; }
+ public string? LatestSummaryPath { get; }
+
+ public static string ResolveLocalDataPath()
+ {
+ if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } dataOverride)
+ return dataOverride;
+
+ if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") is { Length: > 0 } localDataOverride)
+ return Path.Combine(localDataOverride, "OpenClawTray");
+
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OpenClawTray");
+ }
+
+ public static string LatestSummaryPathForCurrentUser() =>
+ Path.Combine(ResolveLocalDataPath(), "Logs", "Setup", "easy-setup-latest.txt");
+
+ public static string SetupStatePathForCurrentUser() =>
+ Path.Combine(ResolveLocalDataPath(), "setup-state.json");
+
+ public void RunStarted(LocalGatewaySetupState state, LocalGatewaySetupOptions options)
+ {
+ EnsureRunInitialized(state);
+ Write(new SetupDiagnosticRecord(
+ Event: "run_started",
+ Level: "info",
+ RunId: state.RunId,
+ InstallId: state.InstallId,
+ Status: state.Status.ToString(),
+ Message: "Local easy-button setup started.",
+ Details: new Dictionary
+ {
+ ["distro_name"] = options.DistroName,
+ ["gateway_url"] = SetupDiagnosticsRedactor.SanitizeText(LocalGatewayEndpointResolver.BuildLoopbackGatewayUrl(options)),
+ ["gateway_port"] = options.GatewayPort,
+ ["openclaw_install_version"] = options.OpenClawInstallVersion,
+ ["allow_existing_distro"] = options.AllowExistingDistro,
+ ["enable_windows_tray_node"] = options.EnableWindowsTrayNodeByDefault,
+ ["os_version"] = Environment.OSVersion.VersionString,
+ ["process_architecture"] = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString(),
+ ["os_architecture"] = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(),
+ ["dotnet_runtime"] = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription,
+ ["is_64_bit_os"] = Environment.Is64BitOperatingSystem,
+ ["is_elevated"] = IsElevated(),
+ ["setup_state_path"] = SetupStatePathForCurrentUser(),
+ ["tray_log_path"] = Logger.LogFilePath
+ }));
+ }
+
+ public void RunCompleted(LocalGatewaySetupState state, TimeSpan duration)
+ {
+ EnsureRunInitialized(state);
+ var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable
+ or LocalGatewaySetupStatus.FailedTerminal
+ or LocalGatewaySetupStatus.Blocked;
+ Write(new SetupDiagnosticRecord(
+ Event: failed ? "run_failed" : "run_completed",
+ Level: failed ? "error" : "info",
+ RunId: state.RunId,
+ InstallId: state.InstallId,
+ Phase: state.Phase.ToString(),
+ VisibleStage: GetVisibleStage(state.Phase),
+ Status: state.Status.ToString(),
+ Message: state.UserMessage,
+ FailureCode: state.FailureCode,
+ Retryable: state.Status == LocalGatewaySetupStatus.FailedRetryable,
+ DurationMs: duration.TotalMilliseconds,
+ Details: BuildStateDetails(state)));
+ WriteSummary(state, duration);
+ }
+
+ public void PhaseStarted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message)
+ {
+ EnsureRunInitialized(state);
+ Write(new SetupDiagnosticRecord(
+ Event: "phase_started",
+ Level: "info",
+ RunId: state.RunId,
+ InstallId: state.InstallId,
+ Phase: phase.ToString(),
+ VisibleStage: GetVisibleStage(phase),
+ Status: state.Status.ToString(),
+ Message: message));
+ }
+
+ public void PhaseCompleted(LocalGatewaySetupState state, LocalGatewaySetupPhase phase, string message, TimeSpan duration)
+ {
+ EnsureRunInitialized(state);
+ var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable
+ or LocalGatewaySetupStatus.FailedTerminal
+ or LocalGatewaySetupStatus.Blocked
+ or LocalGatewaySetupStatus.Cancelled;
+ Write(new SetupDiagnosticRecord(
+ Event: failed ? "phase_failed" : "phase_succeeded",
+ Level: failed ? "error" : "info",
+ RunId: state.RunId,
+ InstallId: state.InstallId,
+ Phase: phase.ToString(),
+ VisibleStage: GetVisibleStage(phase),
+ Status: state.Status.ToString(),
+ Message: failed ? state.UserMessage : message,
+ FailureCode: state.FailureCode,
+ Retryable: state.Status == LocalGatewaySetupStatus.FailedRetryable,
+ DurationMs: duration.TotalMilliseconds,
+ Details: failed ? BuildStateDetails(state) : null));
+
+ if (failed)
+ WriteSummary(state, duration);
+ }
+
+ public string CommandStarted(string fileName, IReadOnlyList arguments, TimeSpan timeout)
+ {
+ var commandId = Guid.NewGuid().ToString("N")[..12];
+ Write(new SetupDiagnosticRecord(
+ Event: "command_started",
+ Level: "debug",
+ RunId: _runId,
+ InstallId: _installId,
+ Message: fileName,
+ Details: new Dictionary
+ {
+ ["command_id"] = commandId,
+ ["file_name"] = fileName,
+ ["arguments"] = SetupDiagnosticsRedactor.RedactArguments(arguments),
+ ["timeout_ms"] = timeout.TotalMilliseconds
+ }));
+ return commandId;
+ }
+
+ public void CommandCompleted(string commandId, string fileName, IReadOnlyList arguments, TimeSpan duration, WslCommandResult result, bool timedOut)
+ {
+ var stdout = SetupDiagnosticsRedactor.SanitizeCommandOutput(result.StandardOutput, MaxCommandOutputChars, out var stdoutTruncated);
+ var stderr = SetupDiagnosticsRedactor.SanitizeCommandOutput(result.StandardError, MaxCommandOutputChars, out var stderrTruncated);
+ Write(new SetupDiagnosticRecord(
+ Event: result.Success && !timedOut ? "command_succeeded" : "command_failed",
+ Level: result.Success && !timedOut ? "debug" : "warn",
+ RunId: _runId,
+ InstallId: _installId,
+ Message: fileName,
+ DurationMs: duration.TotalMilliseconds,
+ Details: new Dictionary
+ {
+ ["command_id"] = commandId,
+ ["file_name"] = fileName,
+ ["arguments"] = SetupDiagnosticsRedactor.RedactArguments(arguments),
+ ["exit_code"] = result.ExitCode,
+ ["timed_out"] = timedOut,
+ ["stdout"] = stdout,
+ ["stdout_truncated"] = stdoutTruncated,
+ ["stderr"] = stderr,
+ ["stderr_truncated"] = stderrTruncated
+ }));
+ }
+
+ public void InstallerEvent(LocalGatewaySetupPhase phase, OpenClawLinuxInstallerEvent installerEvent)
+ {
+ Write(new SetupDiagnosticRecord(
+ Event: "installer_event",
+ Level: "info",
+ RunId: _runId,
+ InstallId: _installId,
+ Phase: phase.ToString(),
+ VisibleStage: GetVisibleStage(phase),
+ Message: SetupDiagnosticsRedactor.SanitizeText(installerEvent.Message ?? installerEvent.RawLine),
+ Details: new Dictionary
+ {
+ ["installer_event"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.Event),
+ ["installer_phase"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.Phase),
+ ["raw_line"] = SetupDiagnosticsRedactor.SanitizeText(installerEvent.RawLine)
+ }));
+ }
+
+ public void LifecycleStarted(string operation)
+ {
+ EnsureLifecycleInitialized(operation);
+ Write(new SetupDiagnosticRecord(
+ Event: "lifecycle_started",
+ Level: "info",
+ RunId: _runId,
+ InstallId: _installId,
+ Message: operation));
+ }
+
+ public void LifecycleStep(string operation, string step, bool success, string? errorCode = null, string? errorMessage = null)
+ {
+ Write(new SetupDiagnosticRecord(
+ Event: success ? "lifecycle_step_succeeded" : "lifecycle_step_failed",
+ Level: success ? "info" : "error",
+ RunId: _runId,
+ InstallId: _installId,
+ Message: step,
+ FailureCode: errorCode,
+ Details: new Dictionary
+ {
+ ["operation"] = operation,
+ ["step"] = step,
+ ["error_message"] = SetupDiagnosticsRedactor.SanitizeText(errorMessage)
+ }));
+ }
+
+ public void LifecycleCompleted(string operation, LocalGatewayLifecycleResult result, TimeSpan duration)
+ {
+ Write(new SetupDiagnosticRecord(
+ Event: result.Success ? "lifecycle_completed" : "lifecycle_failed",
+ Level: result.Success ? "info" : "error",
+ RunId: _runId,
+ InstallId: _installId,
+ Message: operation,
+ FailureCode: result.ErrorCode,
+ Retryable: !result.Success,
+ DurationMs: duration.TotalMilliseconds,
+ Details: new Dictionary
+ {
+ ["operation"] = operation,
+ ["error_message"] = SetupDiagnosticsRedactor.SanitizeText(result.ErrorMessage),
+ ["steps"] = result.Steps ?? Array.Empty()
+ }));
+ WriteLifecycleSummary(operation, result, duration);
+ }
+
+ public Task FlushAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.CompletedTask;
+ }
+
+ private void EnsureRunInitialized(LocalGatewaySetupState state)
+ {
+ lock (_lock)
+ {
+ if (_initialized
+ && string.Equals(_runId, state.RunId, StringComparison.Ordinal)
+ && string.Equals(_installId, state.InstallId, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(_setupLogDirectory);
+ _runId = state.RunId;
+ _installId = state.InstallId;
+ var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var shortRunId = string.IsNullOrWhiteSpace(state.RunId)
+ ? Guid.NewGuid().ToString("N")[..12]
+ : state.RunId[..Math.Min(12, state.RunId.Length)];
+ RunTracePath = Path.Combine(_setupLogDirectory, $"setup-{timestamp}-{shortRunId}.jsonl");
+ SafeDelete(LatestTracePath);
+ SafeDelete(LatestSummaryPath);
+ _records.Clear();
+ _initialized = true;
+ }
+ }
+
+ private void EnsureLifecycleInitialized(string operation)
+ {
+ lock (_lock)
+ {
+ Directory.CreateDirectory(_setupLogDirectory);
+ _runId = Guid.NewGuid().ToString("N");
+ _installId = null;
+ var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss");
+ var operationSlug = string.IsNullOrWhiteSpace(operation)
+ ? "lifecycle"
+ : string.Concat(operation.Where(char.IsLetterOrDigit)).ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(operationSlug))
+ operationSlug = "lifecycle";
+ RunTracePath = Path.Combine(_setupLogDirectory, $"setup-{timestamp}-{operationSlug}-{_runId[..12]}.jsonl");
+ SafeDelete(LatestTracePath);
+ SafeDelete(LatestSummaryPath);
+ _records.Clear();
+ _initialized = true;
+ }
+ }
+
+ private void Write(SetupDiagnosticRecord record)
+ {
+ lock (_lock)
+ {
+ if (RunTracePath is null || LatestTracePath is null)
+ return;
+
+ var sanitized = record.Sanitized();
+ _records.Add(sanitized);
+ if (_records.Count > MaxStoredRecords)
+ _records.RemoveAt(0);
+
+ var line = SetupDiagnosticsRedactor.SanitizeText(JsonSerializer.Serialize(sanitized, s_jsonOptions)) ?? "{}";
+ File.AppendAllText(RunTracePath, line + Environment.NewLine, Encoding.UTF8);
+ File.AppendAllText(LatestTracePath, line + Environment.NewLine, Encoding.UTF8);
+ }
+ }
+
+ private void WriteSummary(LocalGatewaySetupState state, TimeSpan duration)
+ {
+ lock (_lock)
+ {
+ if (LatestSummaryPath is null)
+ return;
+
+ Directory.CreateDirectory(_setupLogDirectory);
+ File.WriteAllText(LatestSummaryPath, BuildSummary(state, duration), Encoding.UTF8);
+ }
+ }
+
+ private void WriteLifecycleSummary(string operation, LocalGatewayLifecycleResult result, TimeSpan duration)
+ {
+ lock (_lock)
+ {
+ if (LatestSummaryPath is null)
+ return;
+
+ Directory.CreateDirectory(_setupLogDirectory);
+ File.WriteAllText(LatestSummaryPath, BuildLifecycleSummary(operation, result, duration), Encoding.UTF8);
+ }
+ }
+
+ private string BuildSummary(LocalGatewaySetupState state, TimeSpan duration)
+ {
+ var failed = state.Status is LocalGatewaySetupStatus.FailedRetryable
+ or LocalGatewaySetupStatus.FailedTerminal
+ or LocalGatewaySetupStatus.Blocked;
+ var sb = new StringBuilder();
+ sb.AppendLine("OpenClaw easy setup diagnostics");
+ sb.AppendLine($"Outcome: {(failed ? "FAILED" : state.Status.ToString().ToUpperInvariant())}");
+ if (failed)
+ {
+ sb.AppendLine($"Failed phase: {LastRunningPhase(state)}");
+ if (!string.IsNullOrWhiteSpace(state.FailureCode))
+ sb.AppendLine($"Failure code: {SetupDiagnosticsRedactor.SanitizeText(state.FailureCode)}");
+ if (!string.IsNullOrWhiteSpace(state.UserMessage))
+ sb.AppendLine($"Message: {SetupDiagnosticsRedactor.SanitizeText(state.UserMessage)}");
+ sb.AppendLine($"Retryable: {state.Status == LocalGatewaySetupStatus.FailedRetryable}");
+ }
+ sb.AppendLine($"Run ID: {state.RunId}");
+ sb.AppendLine($"Install ID: {state.InstallId}");
+ sb.AppendLine($"Updated UTC: {DateTimeOffset.UtcNow:O}");
+ sb.AppendLine($"Duration: {duration.TotalSeconds:F1}s");
+ sb.AppendLine($"Summary: {LatestSummaryPath}");
+ sb.AppendLine($"JSONL trace: {LatestTracePath}");
+ sb.AppendLine($"Per-run JSONL trace: {RunTracePath}");
+ sb.AppendLine($"Tray log: {Logger.LogFilePath}");
+ sb.AppendLine($"Setup state: {SetupStatePathForCurrentUser()}");
+ sb.AppendLine();
+ sb.AppendLine("Phase timeline:");
+ foreach (var record in _records.Where(r => r.Event is "phase_succeeded" or "phase_failed"))
+ {
+ var mark = record.Event == "phase_succeeded" ? "OK" : "FAILED";
+ var phase = record.Phase ?? "(unknown)";
+ var visible = string.IsNullOrWhiteSpace(record.VisibleStage) ? "" : $" [{record.VisibleStage}]";
+ var ms = record.DurationMs is null ? "" : $" {record.DurationMs.Value:F0}ms";
+ sb.AppendLine($"- {mark} {phase}{visible}{ms} - {SetupDiagnosticsRedactor.SanitizeText(record.Message)}");
+ }
+ if (failed)
+ {
+ sb.AppendLine();
+ sb.AppendLine("Next actions:");
+ foreach (var action in BuildNextActions(state))
+ sb.AppendLine($"- {action}");
+ }
+ return sb.ToString();
+ }
+
+ private string BuildLifecycleSummary(string operation, LocalGatewayLifecycleResult result, TimeSpan duration)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("OpenClaw easy setup diagnostics");
+ sb.AppendLine($"Outcome: {(result.Success ? "COMPLETE" : "FAILED")}");
+ sb.AppendLine($"Gateway lifecycle operation: {SetupDiagnosticsRedactor.SanitizeText(operation)}");
+ if (!result.Success)
+ {
+ if (!string.IsNullOrWhiteSpace(result.ErrorCode))
+ sb.AppendLine($"Failure code: {SetupDiagnosticsRedactor.SanitizeText(result.ErrorCode)}");
+ if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
+ sb.AppendLine($"Message: {SetupDiagnosticsRedactor.SanitizeText(result.ErrorMessage)}");
+ }
+ sb.AppendLine($"Run ID: {_runId}");
+ sb.AppendLine($"Updated UTC: {DateTimeOffset.UtcNow:O}");
+ sb.AppendLine($"Duration: {duration.TotalSeconds:F1}s");
+ sb.AppendLine($"Summary: {LatestSummaryPath}");
+ sb.AppendLine($"JSONL trace: {LatestTracePath}");
+ sb.AppendLine($"Per-run JSONL trace: {RunTracePath}");
+ sb.AppendLine($"Tray log: {Logger.LogFilePath}");
+ sb.AppendLine();
+ sb.AppendLine("Lifecycle timeline:");
+ foreach (var record in _records.Where(r => r.Event is "lifecycle_step_succeeded" or "lifecycle_step_failed"))
+ {
+ var mark = record.Event == "lifecycle_step_succeeded" ? "OK" : "FAILED";
+ sb.AppendLine($"- {mark} {SetupDiagnosticsRedactor.SanitizeText(record.Message)}");
+ }
+ if (!result.Success)
+ {
+ sb.AppendLine();
+ sb.AppendLine("Next actions:");
+ sb.AppendLine($"- Open setup JSONL trace: {LatestTracePath}");
+ sb.AppendLine($"- Open tray log: {Logger.LogFilePath}");
+ if (string.Join(" ", result.ErrorCode, result.ErrorMessage).Contains("wsl", StringComparison.OrdinalIgnoreCase)
+ || string.Join(" ", result.ErrorCode, result.ErrorMessage).Contains("gateway", StringComparison.OrdinalIgnoreCase))
+ {
+ sb.AppendLine("- If WSL diagnostics are needed, follow aka.ms/wsllogs.");
+ }
+ }
+ return sb.ToString();
+ }
+
+ private static Dictionary BuildStateDetails(LocalGatewaySetupState state)
+ {
+ return new Dictionary
+ {
+ ["issues"] = state.Issues.Select(issue => new Dictionary
+ {
+ ["code"] = SetupDiagnosticsRedactor.SanitizeText(issue.Code),
+ ["message"] = SetupDiagnosticsRedactor.SanitizeText(issue.Message),
+ ["severity"] = issue.Severity.ToString(),
+ ["detail"] = SetupDiagnosticsRedactor.SanitizeText(issue.Detail)
+ }).ToArray(),
+ ["next_actions"] = BuildNextActions(state)
+ };
+ }
+
+ private static string[] BuildNextActions(LocalGatewaySetupState state)
+ {
+ var actions = new List
+ {
+ $"Open setup summary: {LatestSummaryPathForCurrentUser()}",
+ $"Open setup JSONL trace: {Path.Combine(ResolveLocalDataPath(), "Logs", "Setup", "easy-setup-latest.jsonl")}",
+ $"Open tray log: {Logger.LogFilePath}"
+ };
+
+ var text = string.Join(" ", new[] { state.FailureCode, state.UserMessage }.Where(s => !string.IsNullOrWhiteSpace(s)));
+ if (text.Contains("wsl", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("gateway", StringComparison.OrdinalIgnoreCase)
+ || state.Issues.Any(issue => issue.Message.Contains("aka.ms/wsllogs", StringComparison.OrdinalIgnoreCase)))
+ {
+ actions.Add("If WSL diagnostics are needed, follow aka.ms/wsllogs.");
+ }
+
+ return actions.ToArray();
+ }
+
+ private static string? GetVisibleStage(LocalGatewaySetupPhase phase)
+ {
+ var index = LocalSetupProgressStageMap.IndexOfStageForPhase(phase);
+ return index >= 0 ? LocalSetupProgressStageMap.VisibleStages[index].LabelKey : null;
+ }
+
+ private static LocalGatewaySetupPhase LastRunningPhase(LocalGatewaySetupState state)
+ {
+ for (var i = state.History.Count - 1; i >= 0; i--)
+ {
+ var phase = state.History[i].Phase;
+ if (phase is not LocalGatewaySetupPhase.Failed
+ and not LocalGatewaySetupPhase.Cancelled
+ and not LocalGatewaySetupPhase.NotStarted)
+ {
+ return phase;
+ }
+ }
+
+ return state.Phase;
+ }
+
+ private static bool IsElevated()
+ {
+ if (!OperatingSystem.IsWindows())
+ return false;
+
+ try
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void SafeDelete(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return;
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+}
+
+internal sealed record SetupDiagnosticRecord(
+ [property: JsonPropertyName("event")] string Event,
+ [property: JsonPropertyName("level")] string Level,
+ [property: JsonPropertyName("run_id")] string? RunId,
+ [property: JsonPropertyName("install_id")] string? InstallId,
+ [property: JsonPropertyName("timestamp_utc")] DateTimeOffset? TimestampUtc = null,
+ [property: JsonPropertyName("phase")] string? Phase = null,
+ [property: JsonPropertyName("visible_stage")] string? VisibleStage = null,
+ [property: JsonPropertyName("status")] string? Status = null,
+ [property: JsonPropertyName("message")] string? Message = null,
+ [property: JsonPropertyName("failure_code")] string? FailureCode = null,
+ [property: JsonPropertyName("retryable")] bool? Retryable = null,
+ [property: JsonPropertyName("duration_ms")] double? DurationMs = null,
+ [property: JsonPropertyName("details")] IReadOnlyDictionary? Details = null)
+{
+ [JsonPropertyName("schema_version")]
+ public int SchemaVersion => LocalGatewaySetupDiagnosticsService.SchemaVersion;
+
+ public SetupDiagnosticRecord Sanitized() => this with
+ {
+ TimestampUtc = TimestampUtc ?? DateTimeOffset.UtcNow,
+ RunId = SetupDiagnosticsRedactor.SanitizeText(RunId),
+ InstallId = SetupDiagnosticsRedactor.SanitizeText(InstallId),
+ Phase = SetupDiagnosticsRedactor.SanitizeText(Phase),
+ VisibleStage = SetupDiagnosticsRedactor.SanitizeText(VisibleStage),
+ Status = SetupDiagnosticsRedactor.SanitizeText(Status),
+ Message = SetupDiagnosticsRedactor.SanitizeText(Message),
+ FailureCode = SetupDiagnosticsRedactor.SanitizeText(FailureCode),
+ Details = SetupDiagnosticsRedactor.SanitizeDictionary(Details)
+ };
+}
+
+internal static partial class SetupDiagnosticsRedactor
+{
+ private static readonly string[] SecretValueFlags =
+ [
+ "--token",
+ "--bootstrap-token",
+ "--operator-token",
+ "--device-token",
+ "--setup-code",
+ "--password",
+ "--pass",
+ "--key",
+ "--private-key",
+ "--auth"
+ ];
+
+ [GeneratedRegex(@"(?i)(https?|wss?)://([^/\s:@]+):([^@\s/]+)@")]
+ private static partial Regex UrlCredentialRegex();
+
+ [GeneratedRegex(@"-----BEGIN [A-Z ]*(?:PRIVATE|PUBLIC) KEY-----.*?-----END [A-Z ]*(?:PRIVATE|PUBLIC) KEY-----", RegexOptions.Singleline)]
+ private static partial Regex PrivateKeyRegex();
+
+ [GeneratedRegex(@"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")]
+ private static partial Regex JwtRegex();
+
+ [GeneratedRegex(@"(?i)(OPENCLAW_[A-Z0-9_]*(?:TOKEN|SECRET|KEY)|(?:setup[_-]?code|bootstrap[_-]?token|device[_-]?token|gateway[_-]?token|auth[_-]?token|private[_-]?key|password|secret))([^\r\n\S]*[:=][^\r\n\S]*)([^\s,;""'}]+)")]
+ private static partial Regex KeyValueSecretRegex();
+
+ public static string? SanitizeText(string? value)
+ {
+ if (value is null)
+ return null;
+
+ var sanitized = value.Replace("\0", string.Empty, StringComparison.Ordinal);
+ sanitized = PrivateKeyRegex().Replace(sanitized, "");
+ sanitized = JwtRegex().Replace(sanitized, "");
+ sanitized = UrlCredentialRegex().Replace(sanitized, "$1://@");
+ sanitized = KeyValueSecretRegex().Replace(sanitized, "$1$2");
+ sanitized = TokenSanitizer.Sanitize(SecretRedactor.Redact(sanitized));
+ return sanitized;
+ }
+
+ public static IReadOnlyList RedactArguments(IReadOnlyList arguments)
+ {
+ var redacted = new List(arguments.Count);
+ var redactNext = false;
+ foreach (var argument in arguments)
+ {
+ if (redactNext)
+ {
+ redacted.Add("");
+ redactNext = false;
+ continue;
+ }
+
+ var equalsIndex = argument.IndexOf('=');
+ var flagName = equalsIndex > 0 ? argument[..equalsIndex] : argument;
+ if (IsSecretFlag(flagName))
+ {
+ if (equalsIndex > 0)
+ redacted.Add(argument[..(equalsIndex + 1)] + "");
+ else
+ {
+ redacted.Add(argument);
+ redactNext = true;
+ }
+ continue;
+ }
+
+ redacted.Add(SanitizeText(argument) ?? string.Empty);
+ }
+
+ return redacted;
+ }
+
+ public static string? SanitizeCommandOutput(string? value, int maxChars, out bool truncated)
+ {
+ truncated = false;
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+
+ var retained = value;
+ if (retained.Length > maxChars)
+ {
+ retained = retained[^maxChars..];
+ truncated = true;
+ }
+
+ return SanitizeText(retained.Trim());
+ }
+
+ public static IReadOnlyDictionary? SanitizeDictionary(IReadOnlyDictionary? details)
+ {
+ if (details is null)
+ return null;
+
+ var sanitized = new Dictionary(StringComparer.Ordinal);
+ foreach (var pair in details)
+ sanitized[pair.Key] = SanitizeValue(pair.Value);
+ return sanitized;
+ }
+
+ private static object? SanitizeValue(object? value)
+ {
+ return value switch
+ {
+ null => null,
+ string s => SanitizeText(s),
+ IReadOnlyDictionary d => SanitizeDictionary(d),
+ IEnumerable strings => strings.Select(SanitizeText).ToArray(),
+ IEnumerable
@@ -96,11 +97,11 @@
-
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
index 2596dbb7f..357b1c759 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
@@ -16,7 +16,7 @@
AutomationProperties.AutomationId="BackToConnectionLink">
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 88cf0861a..32188d38b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1666,6 +1666,12 @@ On your gateway host (Mac/Linux), run:
Connect to gateway to start chatting
+
+ Waiting for chat to start…
+
+
+ The gateway is connected; the chat surface is still coming online.
+
⚙️ Config
@@ -3130,6 +3136,9 @@ On your gateway host (Mac/Linux), run:
Resync
+
+ Back to Connection
+
🔗 Pending Operator/Node Pairing
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index c713b55c7..207884d54 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -1617,6 +1617,12 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Connectez-vous à la passerelle pour commencer la discussion
+
+ En attente du démarrage du chat…
+
+
+ La passerelle est connectée ; l'interface de chat est encore en cours de démarrage.
+
⚙️ Configuration
@@ -3081,6 +3087,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Resync
+
+ Retour à la connexion
+
🔗 Pending Operator/Node Pairing
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index d3e544f30..65670f08f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -1618,6 +1618,12 @@ Voer op uw gateway-host (Mac/Linux) uit:
Maak verbinding met de gateway om te chatten
+
+ Wachten op start van de chat…
+
+
+ De gateway is verbonden; het chatoppervlak wordt nog geladen.
+
⚙️ Configuratie
@@ -3082,6 +3088,9 @@ Voer op uw gateway-host (Mac/Linux) uit:
Resync
+
+ Terug naar verbinding
+
🔗 Pending Operator/Node Pairing
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 7df9a2355..95636b56c 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -1617,6 +1617,12 @@
连接到网关以开始聊天
+
+ 等待聊天启动…
+
+
+ 网关已连接;聊天界面仍在上线中。
+
⚙️ 配置
@@ -3081,6 +3087,9 @@
Resync
+
+ 返回连接
+
🔗 Pending Operator/Node Pairing
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index a29696b44..2c5e84101 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -1617,6 +1617,12 @@
連線到閘道以開始聊天
+
+ 等待聊天啟動…
+
+
+ 閘道已連接;聊天介面仍在上線中。
+
⚙️ 設定
@@ -3081,6 +3087,9 @@
Resync
+
+ 返回連線
+
🔗 Pending Operator/Node Pairing
From 59ff8cef337d264bd477e78440daa9737c8f9bdc Mon Sep 17 00:00:00 2001
From: Ranjesh Jaganathan
Date: Thu, 21 May 2026 15:02:09 -0700
Subject: [PATCH 051/115] Remove Quick Send feature entirely
Surgically removes the Quick Send dialog, coordinator, hotkey (Ctrl+Alt+Shift+C),
deep link handler, command palette entry, tray menu item, and all associated
localization strings and tests.
Preserves voice hotkey (Ctrl+Alt+Shift+V) and settings hotkey (Ctrl+Alt+;).
Leaves 'quicksend' tray action as no-op break for backward compatibility.
Files deleted:
- QuickSendDialog.cs
- QuickSendCoordinator.cs
- QuickSendCoordinatorTests.cs
24 files changed, 1571 deletions, 20 additions.
All builds pass, all tests pass (1854 shared + 1173 tray).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/OpenClawPage.cs | 5 -
src/OpenClaw.Shared/OpenClawGatewayClient.cs | 78 ----
src/OpenClaw.Tray.WinUI/App.xaml.cs | 76 +---
.../Dialogs/QuickSendCoordinator.cs | 259 ------------
.../Dialogs/QuickSendDialog.cs | 399 ------------------
.../Helpers/FluentIconCatalog.cs | 1 -
.../Pages/SettingsPage.xaml | 4 +-
.../Services/DeepLinkHandler.cs | 6 -
.../Services/DeepLinkSecurityPolicy.cs | 2 -
.../Services/GlobalHotkeyService.cs | 37 +-
.../Services/IAppCommands.cs | 1 -
.../Services/TrayMenuStateBuilder.cs | 4 -
.../Strings/en-us/Resources.resw | 39 +-
.../Strings/fr-fr/Resources.resw | 32 +-
.../Strings/nl-nl/Resources.resw | 32 +-
.../Strings/zh-cn/Resources.resw | 32 +-
.../Strings/zh-tw/Resources.resw | 32 +-
.../Windows/HubWindow.xaml.cs | 4 -
.../OpenClawGatewayClientTests.cs | 174 --------
.../DeepLinkParserTests.cs | 14 -
.../DeepLinkSecurityPolicyTests.cs | 2 -
.../FluentIconCatalogTests.cs | 5 +-
.../OpenClaw.Tray.Tests.csproj | 1 -
.../QuickSendCoordinatorTests.cs | 352 ---------------
24 files changed, 20 insertions(+), 1571 deletions(-)
delete mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs
diff --git a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs
index 447dd5611..7419905da 100644
--- a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs
+++ b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs
@@ -49,11 +49,6 @@ public override IListItem[] GetItems()
Title = "💬 Web Chat",
Subtitle = "Open the OpenClaw chat window"
},
- new ListItem(new OpenUrlCommand("openclaw://send"))
- {
- Title = "📝 Quick Send",
- Subtitle = "Send a message to OpenClaw"
- },
new ListItem(new OpenUrlCommand("openclaw://setup"))
{
Title = "🧭 Setup Wizard",
diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
index 41e7f8c59..dfaa089da 100644
--- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs
+++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs
@@ -2252,84 +2252,6 @@ internal static string ResolveEffectiveSessionKey(
return null;
}
- public string BuildMissingScopeFixCommands(string missingScope)
- {
- var scope = string.IsNullOrWhiteSpace(missingScope) ? "operator.write" : missingScope.Trim();
- var grantedScopes = _grantedOperatorScopes.Length == 0
- ? "(none reported by gateway)"
- : string.Join(", ", _grantedOperatorScopes);
- var deviceId = string.IsNullOrWhiteSpace(_operatorDeviceId)
- ? "(not reported for this operator connection)"
- : _operatorDeviceId;
- var likelyNodeToken = _grantedOperatorScopes.Any(s => s.StartsWith("node.", StringComparison.OrdinalIgnoreCase));
-
- var sb = new StringBuilder();
- sb.AppendLine("Quick Send is connected, but your token is missing required permission.");
- sb.AppendLine($"Missing scope: {scope}");
- sb.AppendLine("Note: requested connect scopes are declarative; the gateway may grant fewer scopes based on token/policy/device state.");
- sb.AppendLine();
- sb.AppendLine("Do this in Windows Tray right now:");
- sb.AppendLine("1. Right-click the tray icon and open Settings.");
- sb.AppendLine("2. Replace Gateway Token with an OPERATOR token that includes operator.write.");
- sb.AppendLine("3. Click Save.");
- sb.AppendLine("4. Reconnect from the tray menu (or restart the tray app).");
- sb.AppendLine("5. Retry Quick Send.");
- sb.AppendLine();
- sb.AppendLine("Token requirements for Quick Send:");
- sb.AppendLine("- Role: operator");
- sb.AppendLine("- Required scope: operator.write");
- sb.AppendLine("- Recommended scopes: operator.admin, operator.read, operator.approvals, operator.pairing, operator.write");
-
- if (likelyNodeToken)
- {
- sb.AppendLine();
- sb.AppendLine("Detected node.* scopes. This usually means a node token was pasted into Gateway Token.");
- sb.AppendLine("Quick Send requires an operator token, not a node token.");
- }
-
- sb.AppendLine();
- sb.AppendLine("Connection details from this app (for debugging/support):");
- sb.AppendLine($"- role: operator");
- sb.AppendLine($"- client.id: {OperatorClientId}");
- sb.AppendLine($"- client.displayName: {OperatorClientDisplayName}");
- sb.AppendLine($"- operator device id: {deviceId}");
- sb.AppendLine($"- granted scopes: {grantedScopes}");
- sb.AppendLine();
- sb.AppendLine("If this still fails after updating the token, copy this block and share it with your gateway admin.");
- return sb.ToString().TrimEnd();
- }
-
- public string BuildPairingApprovalFixCommands()
- {
- var deviceId = !string.IsNullOrWhiteSpace(_operatorDeviceId)
- ? _operatorDeviceId
- : _deviceIdentity.DeviceId;
- var grantedScopes = _grantedOperatorScopes.Length == 0
- ? "(none reported by gateway yet)"
- : string.Join(", ", _grantedOperatorScopes);
-
- var sb = new StringBuilder();
- sb.AppendLine("Quick Send requires this device to be approved (paired) in the gateway.");
- sb.AppendLine("Gateway reported: pairing required");
- sb.AppendLine();
- sb.AppendLine("Do this now:");
- sb.AppendLine("1. Open the gateway admin UI.");
- sb.AppendLine("2. Go to pending pairing/device approvals.");
- sb.AppendLine("3. Approve this Windows tray device ID.");
- sb.AppendLine("4. Return to tray and reconnect (or restart tray app).");
- sb.AppendLine("5. Retry Quick Send.");
- sb.AppendLine();
- sb.AppendLine("Connection details from this app (for debugging/support):");
- sb.AppendLine("- role: operator");
- sb.AppendLine($"- client.id: {OperatorClientId}");
- sb.AppendLine($"- client.displayName: {OperatorClientDisplayName}");
- sb.AppendLine($"- operator device id: {deviceId}");
- sb.AppendLine($"- granted scopes: {grantedScopes}");
- sb.AppendLine();
- sb.AppendLine("If approval keeps failing, share this block with your gateway admin.");
- return sb.ToString().TrimEnd();
- }
-
private void HandleEvent(JsonElement root, int rawMessageLength)
{
if (!root.TryGetProperty("event", out var eventProp)) return;
diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs
index 9c86ac5a9..ee941ce9e 100644
--- a/src/OpenClaw.Tray.WinUI/App.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs
@@ -242,7 +242,6 @@ public IntPtr GetHubWindowHandle()
// Windows (created on demand)
private HubWindow? _hubWindow;
private TrayMenuWindow? _trayMenuWindow;
- private QuickSendDialog? _quickSendDialog;
private ChatWindow? _chatWindow;
private ConnectionStatusWindow? _connectionStatusWindow;
@@ -639,7 +638,6 @@ _dispatcherQueue is null
if (_settings.GlobalHotkeyEnabled)
{
_globalHotkey = new GlobalHotkeyService();
- _globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
_globalHotkey.VoiceHotkeyPressed += OnVoiceHotkeyPressed;
_globalHotkey.SettingsHotkeyPressed += OnSettingsHotkeyPressed;
_globalHotkey.Register();
@@ -922,7 +920,7 @@ private void OnTrayMenuItemClicked(object? sender, string action)
else
ShowHub();
break;
- case "quicksend": ShowQuickSend(); break;
+ case "quicksend": break; // Quick Send removed
case "history": ShowHub("channels"); break;
case "activity": ShowHub("channels"); break;
case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break;
@@ -2472,7 +2470,6 @@ internal void ShowHub(string? navigateTo = null, bool activate = true, string? o
_hubWindow = new HubWindow();
_hubWindow.AppModel = _appState;
_hubWindow.ApplyNavPaneState(_settings!);
- _hubWindow.QuickSendAction = () => ShowQuickSend();
_hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync();
_hubWindow.OpenConnectionStatusAction = ShowConnectionStatusWindow;
_hubWindow.OpenVoiceAction = () => ShowHub("voice"); // was: ShowVoiceOverlay()
@@ -2613,8 +2610,8 @@ private void OnSettingsSaved(object? sender, EventArgs e)
if (_settings!.GlobalHotkeyEnabled)
{
_globalHotkey ??= new GlobalHotkeyService();
- _globalHotkey.HotkeyPressed -= OnGlobalHotkeyPressed;
- _globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
+ _globalHotkey.VoiceHotkeyPressed -= OnVoiceHotkeyPressed;
+ _globalHotkey.VoiceHotkeyPressed += OnVoiceHotkeyPressed;
_globalHotkey.SettingsHotkeyPressed -= OnSettingsHotkeyPressed;
_globalHotkey.SettingsHotkeyPressed += OnSettingsHotkeyPressed;
_globalHotkey.Register();
@@ -2664,54 +2661,6 @@ private void ShowWebChat()
ShowHub("chat");
}
- private void ShowQuickSend(string? prefillMessage = null)
- {
- if (_connectionManager?.OperatorClient == null)
- {
- Logger.Warn("QuickSend blocked: gateway client not initialized");
- return;
- }
-
- try
- {
- // Keep a strong reference to the window; otherwise the dialog can be GC'd
- // and appear to not open (especially when triggered from a hotkey).
- if (_quickSendDialog != null)
- {
- // If caller wants a prefill, re-create to apply it.
- if (!string.IsNullOrEmpty(prefillMessage))
- {
- try { _quickSendDialog.Close(); } catch { }
- _quickSendDialog = null;
- }
- else
- {
- Logger.Info("QuickSend dialog already open; activating");
- _quickSendDialog.ShowAsync();
- return;
- }
- }
-
- Logger.Info("Showing QuickSend dialog");
- // Bug #3: pass a Func that resolves the live OperatorClient on
- // every Send so post-pair / restart / reinit swaps are observed.
- var dialog = new QuickSendDialog(() => _connectionManager?.OperatorClient as OpenClawGatewayClient, prefillMessage);
- dialog.Closed += (s, e) =>
- {
- if (ReferenceEquals(_quickSendDialog, dialog))
- {
- _quickSendDialog = null;
- }
- };
- _quickSendDialog = dialog;
- dialog.ShowAsync();
- }
- catch (Exception ex)
- {
- Logger.Error($"Failed to show QuickSend dialog: {ex.Message}");
- }
- }
-
private void ShowStatusDetail()
{
ShowHub("connection");
@@ -3026,7 +2975,6 @@ void IAppCommands.Disconnect()
}
void IAppCommands.ShowVoiceOverlay() => ShowHub("voice");
void IAppCommands.ShowChat() => ShowChatWindow();
- void IAppCommands.ShowQuickSend() => ShowQuickSend();
void IAppCommands.CheckForUpdates() => _ = CheckForUpdatesUserInitiatedAsync();
void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync();
void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow();
@@ -3119,21 +3067,6 @@ private static void OpenFolder(string? folderPath, string label)
}
}
- private void OnGlobalHotkeyPressed(object? sender, EventArgs e)
- {
- if (_dispatcherQueue == null)
- {
- Logger.Warn("Hotkey pressed but DispatcherQueue is null");
- return;
- }
-
- var enqueued = _dispatcherQueue.TryEnqueue(() => ShowQuickSend());
- if (!enqueued)
- {
- Logger.Warn("Hotkey pressed but failed to enqueue QuickSend on UI thread");
- }
- }
-
private void OnVoiceHotkeyPressed(object? sender, EventArgs e)
{
if (_dispatcherQueue == null) return;
@@ -3793,7 +3726,6 @@ private void HandleDeepLink(string uri)
OpenActivityStream = ShowActivityStream,
OpenNotificationHistory = ShowNotificationHistory,
OpenDashboard = OpenDashboard,
- OpenQuickSend = ShowQuickSend,
OpenHub = (page) => ShowHub(page),
OpenVoice = () => ShowHub("voice"), // was: ShowVoiceOverlay()
StopVoice = () => _ = StopVoiceAsync(),
@@ -3980,8 +3912,6 @@ private void ExitApplication()
SafeShutdownStep("chat window", () => { _chatWindow?.ForceClose(); _chatWindow = null; });
SafeShutdownStep("tray menu window", () => CloseWindow(_trayMenuWindow));
_trayMenuWindow = null;
- SafeShutdownStep("quick send dialog", () => CloseWindow(_quickSendDialog));
- _quickSendDialog = null;
SafeShutdownStep("keep alive window", () => CloseWindow(_keepAliveWindow));
_keepAliveWindow = null;
diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs
deleted file mode 100644
index 4b2e206fa..000000000
--- a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendCoordinator.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-using OpenClaw.Shared;
-using System;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace OpenClawTray.Dialogs;
-
-// Bug #3 (manual test 2026-05-05): QuickSendDialog used to capture the App's
-// gateway client at constructor time into a readonly field. After autopair (or
-// any other path that swapped App._gatewayClient — SSH tunnel restart, manual
-// ConnectionPage re-pair, onboarding completion), the dialog kept sending into
-// the stale instance which still reported NOT_PAIRED, triggering the
-// "copy pair command to clipboard" remediation toast against a perfectly
-// paired live client.
-//
-// This file extracts the per-Send logic into a pure, UI-free coordinator that:
-// 1. Resolves the live gateway client from a Func<> provider on every Send.
-// 2. Defines explicit behavior for null / disposed / swap-window cases.
-// 3. Returns a discriminated outcome the dialog renders.
-//
-// RubberDucky closure conditions #1 (scope), #2 (lifetime contract) and #3
-// (genuine-unpaired regression test) are all satisfied by tests over this
-// coordinator (see tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs).
-
-///
-/// Minimal gateway surface QuickSend needs. Wrapping the real
-/// behind this interface keeps
-/// testable without spinning up a real
-/// WebSocket client.
-///
-public interface IQuickSendGateway
-{
- bool IsConnectedToGateway { get; }
- Task ConnectAsync();
- Task SendChatMessageAsync(string message);
- string BuildPairingApprovalFixCommands();
- string BuildMissingScopeFixCommands(string missingScope);
-}
-
-///
-/// Adapter that exposes the live through
-/// for the production wiring.
-///
-public sealed class OpenClawGatewayClientAdapter : IQuickSendGateway
-{
- private readonly OpenClawGatewayClient _client;
-
- public OpenClawGatewayClientAdapter(OpenClawGatewayClient client)
- {
- _client = client ?? throw new ArgumentNullException(nameof(client));
- }
-
- public bool IsConnectedToGateway => _client.IsConnectedToGateway;
- public Task ConnectAsync() => _client.ConnectAsync();
- public Task SendChatMessageAsync(string message) => _client.SendChatMessageAsync(message);
- public string BuildPairingApprovalFixCommands() => _client.BuildPairingApprovalFixCommands();
- public string BuildMissingScopeFixCommands(string missingScope) => _client.BuildMissingScopeFixCommands(missingScope);
-}
-
-///
-/// Discriminated outcome of a single Send attempt. The dialog renders the
-/// outcome; the coordinator never touches UI.
-///
-public abstract record QuickSendOutcome
-{
- /// Message accepted by the gateway.
- public sealed record Sent : QuickSendOutcome;
-
- ///
- /// Gateway client provider returned null (or a previously-disposed
- /// instance was detected) — the App is mid-swap (init, restart, autopair
- /// reinit). DO NOT show the clipboard-pairing remediation; show a
- /// "still initializing" message and let the user retry.
- ///
- public sealed record GatewayInitializing(string Message) : QuickSendOutcome;
-
- ///
- /// Live current client genuinely reports NOT_PAIRED. Clipboard remediation
- /// MUST still fire — this is the path Mike explicitly does not want
- /// suppressed.
- ///
- public sealed record PairingRequired(string Commands) : QuickSendOutcome;
-
- /// Live current client is missing a required operator scope.
- public sealed record MissingScope(string Scope, string Commands) : QuickSendOutcome;
-
- /// Any other failure (timeout, transport, dispose race, etc.).
- public sealed record Failed(string ErrorMessage) : QuickSendOutcome;
-}
-
-///
-/// Pure (no UI, no static state) per-Send orchestrator. The dialog passes a
-/// that reads App._gatewayClient on every Send
-/// so a swap underneath the dialog is observed before remediation decisions
-/// are made.
-///
-public sealed class QuickSendCoordinator
-{
- ///
- /// Provider/lifetime contract — see Bug #3 plan §3 and RubberDucky
- /// closure condition #2:
- ///
- /// (a) Provider returns null => GatewayInitializing (no clipboard toast).
- /// Reason: App is between Dispose() and the next assignment of
- /// _gatewayClient (SSH tunnel restart, onboarding swap), or the field
- /// has not yet been initialized.
- /// (b) Provider returns a previously-disposed instance => SendChatMessageAsync
- /// throws "Gateway connection is not open" or ObjectDisposedException;
- /// coordinator catches and returns Failed (NOT clipboard).
- /// (c) Provider returns a live client that genuinely reports NOT_PAIRED =>
- /// PairingRequired (clipboard toast STILL fires — built from the
- /// resolved current client, never a captured stale one).
- ///
- private readonly Func _provider;
- private readonly int _connectTimeoutMs;
- private readonly int _providerRetryDelayMs;
- private readonly Func _delayAsync;
-
- public QuickSendCoordinator(
- Func provider,
- int connectTimeoutMs = 3000,
- int providerRetryDelayMs = 100,
- Func? delayAsync = null)
- {
- _provider = provider ?? throw new ArgumentNullException(nameof(provider));
- _connectTimeoutMs = connectTimeoutMs;
- _providerRetryDelayMs = providerRetryDelayMs;
- _delayAsync = delayAsync ?? Task.Delay;
- }
-
- public async Task SendAsync(string message, CancellationToken cancellationToken = default)
- {
- if (string.IsNullOrWhiteSpace(message))
- {
- return new QuickSendOutcome.Failed("Message is empty.");
- }
-
- // Resolve live client. If the App is mid-swap (e.g., between Dispose
- // and the next InitializeGatewayClient assignment), the provider
- // returns null briefly. Retry once after a short delay to absorb the
- // window without surfacing a spurious "initializing" message.
- var client = ResolveClient();
- if (client == null)
- {
- await _delayAsync(_providerRetryDelayMs).ConfigureAwait(false);
- client = ResolveClient();
- }
-
- if (client == null)
- {
- return new QuickSendOutcome.GatewayInitializing(
- "Gateway is still initializing. Please try again in a moment.");
- }
-
- try
- {
- if (!await EnsureConnectedAsync(client, cancellationToken).ConfigureAwait(false))
- {
- return new QuickSendOutcome.Failed("Gateway connection is not open");
- }
-
- await client.SendChatMessageAsync(message).ConfigureAwait(false);
- return new QuickSendOutcome.Sent();
- }
- catch (Exception ex)
- {
- return ClassifyFailure(client, ex);
- }
- }
-
- private IQuickSendGateway? ResolveClient()
- {
- try
- {
- return _provider();
- }
- catch
- {
- // Provider is `() => _gatewayClient` — the field read itself
- // can't throw, but defensive belt-and-braces against future
- // provider implementations.
- return null;
- }
- }
-
- private async Task EnsureConnectedAsync(IQuickSendGateway client, CancellationToken cancellationToken)
- {
- if (client.IsConnectedToGateway) return true;
-
- try
- {
- await client.ConnectAsync().ConfigureAwait(false);
- }
- catch
- {
- // Connect errors surface via the subsequent send.
- }
-
- var deadline = Environment.TickCount64 + _connectTimeoutMs;
- while (Environment.TickCount64 < deadline)
- {
- if (cancellationToken.IsCancellationRequested) return false;
- if (client.IsConnectedToGateway) return true;
- await _delayAsync(120).ConfigureAwait(false);
- }
-
- return client.IsConnectedToGateway;
- }
-
- private static QuickSendOutcome ClassifyFailure(IQuickSendGateway client, Exception ex)
- {
- // ObjectDisposedException happens when the resolved client was
- // disposed mid-send (case (b) of the lifetime contract). Surface as
- // a clean Failed — never as the clipboard pairing remediation.
- if (ex is ObjectDisposedException)
- {
- return new QuickSendOutcome.Failed(
- "Gateway client was reset mid-send. Please try again.");
- }
-
- var msg = ex.Message;
- if (IsPairingRequired(msg))
- {
- // Built from the live current client (resolved in this call), not
- // any captured stale snapshot — closes Bug #3 root cause.
- var commands = client.BuildPairingApprovalFixCommands();
- return new QuickSendOutcome.PairingRequired(commands);
- }
-
- if (TryExtractMissingScope(msg, out var scope))
- {
- var commands = client.BuildMissingScopeFixCommands(scope);
- return new QuickSendOutcome.MissingScope(scope, commands);
- }
-
- return new QuickSendOutcome.Failed(msg);
- }
-
- internal static bool IsPairingRequired(string? message)
- {
- if (string.IsNullOrWhiteSpace(message)) return false;
- return message.Contains("pairing required", StringComparison.OrdinalIgnoreCase)
- || message.Contains("not paired", StringComparison.OrdinalIgnoreCase)
- || message.Contains("NOT_PAIRED", StringComparison.OrdinalIgnoreCase);
- }
-
- internal static bool TryExtractMissingScope(string? message, out string scope)
- {
- scope = string.Empty;
- if (string.IsNullOrWhiteSpace(message)) return false;
-
- var match = Regex.Match(message, @"missing\s+scope\s*:\s*([A-Za-z0-9._-]+)", RegexOptions.IgnoreCase);
- if (!match.Success) return false;
-
- scope = match.Groups[1].Value;
- return !string.IsNullOrWhiteSpace(scope);
- }
-}
diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
deleted file mode 100644
index c2f0435ef..000000000
--- a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs
+++ /dev/null
@@ -1,399 +0,0 @@
-using Microsoft.Toolkit.Uwp.Notifications;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Input;
-using Microsoft.UI.Xaml.Media;
-using OpenClaw.Shared;
-using OpenClawTray.Helpers;
-using OpenClawTray.Services;
-using System;
-using System.Runtime.InteropServices;
-using System.Threading.Tasks;
-using WinUIEx;
-
-namespace OpenClawTray.Dialogs;
-
-///
-/// Quick send dialog for sending messages to OpenClaw.
-///
-public sealed class QuickSendDialog : WindowEx
-{
- // Bug #3 (manual test 2026-05-05): resolve the live App._gatewayClient
- // on every Send via this provider instead of capturing a single instance
- // at construction time. This survives autopair / SSH-tunnel-restart /
- // manual-pair / onboarding-completion swaps under the dialog.
- private readonly Func _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);
-
- [DllImport("user32.dll")]
- private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
-
- [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 const int TitleBarHeight = 48;
- private const int SW_SHOWNORMAL = 1;
- private const uint SWP_NOMOVE = 0x0002;
- private const uint SWP_NOSIZE = 0x0001;
- private const uint SWP_SHOWWINDOW = 0x0040;
-
- public QuickSendDialog(Func clientProvider, string? prefillMessage = null)
- {
- _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");
- ExtendsContentIntoTitleBar = true;
- this.SetWindowSize(420, 260 + TitleBarHeight);
- this.CenterOnScreen();
- this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
-
- // Apply Acrylic via controller to keep IsInputActive=true.
- // This avoids focus/activation oddities on Windows 10 for hotkey-launched windows.
- BackdropHelper.TrySetAcrylicBackdrop((Microsoft.UI.Xaml.Window)this);
-
- // Hotkey-launched windows can fail to foreground on Windows 10 due to
- // foreground activation restrictions. Keep the existing topmost promotion.
- this.IsAlwaysOnTop = true;
-
- // Build UI programmatically (simple dialog)
- var root = new Grid
- {
- RowSpacing = 12
- };
- root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- 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 });
-
- var header = new TextBlock
- {
- Text = LocalizationHelper.GetString("QuickSend_Header"),
- Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"]
- };
- Grid.SetRow(header, 0);
- root.Children.Add(header);
-
- _messageTextBox = new TextBox
- {
- PlaceholderText = LocalizationHelper.GetString("QuickSend_Placeholder"),
- AcceptsReturn = false,
- Text = prefillMessage ?? ""
- };
- _messageTextBox.KeyDown += OnKeyDown;
- Grid.SetRow(_messageTextBox, 1);
- root.Children.Add(_messageTextBox);
-
- _errorDetailsTextBox = new TextBox
- {
- Visibility = Visibility.Collapsed,
- IsReadOnly = true,
- IsTabStop = true,
- AcceptsReturn = true,
- TextWrapping = TextWrapping.Wrap,
- MinHeight = 80,
- MaxHeight = 240,
- VerticalAlignment = VerticalAlignment.Stretch
- };
- ScrollViewer.SetVerticalScrollBarVisibility(_errorDetailsTextBox, ScrollBarVisibility.Auto);
- Grid.SetRow(_errorDetailsTextBox, 2);
- root.Children.Add(_errorDetailsTextBox);
-
- var buttonPanel = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Spacing = 8,
- HorizontalAlignment = HorizontalAlignment.Right
- };
-
- var cancelButton = new Button { Content = LocalizationHelper.GetString("QuickSend_CancelButton") };
- cancelButton.Click += (s, e) => Close();
- buttonPanel.Children.Add(cancelButton);
-
- _sendButton = new Button
- {
- Content = LocalizationHelper.GetString("QuickSend_SendButton"),
- Style = (Style)Application.Current.Resources["AccentButtonStyle"]
- };
- _sendButton.Click += OnSendClick;
- buttonPanel.Children.Add(_sendButton);
-
- Grid.SetRow(buttonPanel, 3);
- root.Children.Add(buttonPanel);
-
- var body = new Border
- {
- Padding = new Thickness(24),
- Child = root
- };
-
- var outerGrid = new Grid();
- outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(TitleBarHeight) });
- outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
-
- var titleBar = new Grid { Padding = new Thickness(16, 0, WindowControlsReservedWidth, 0) };
- var titleStack = new StackPanel { Orientation = Orientation.Horizontal };
- titleStack.Children.Add(new TextBlock
- {
- Text = TitleIcon,
- FontSize = 20,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 10, 0)
- });
- titleStack.Children.Add(new TextBlock
- {
- Text = LocalizationHelper.GetString("WindowTitle_QuickSend"),
- FontSize = 13,
- Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
- VerticalAlignment = VerticalAlignment.Center
- });
- titleBar.Children.Add(titleStack);
- Grid.SetRow(titleBar, 0);
- outerGrid.Children.Add(titleBar);
-
- Grid.SetRow(body, 1);
- outerGrid.Children.Add(body);
-
- Content = outerGrid;
- SetTitleBar(titleBar);
-
- // Focus the text box when shown without closing on transient deactivation.
- Activated += (s, e) =>
- {
- if (e.WindowActivationState != WindowActivationState.Deactivated)
- {
- TryBringToFront();
- RequestInputFocus();
- }
- };
-
- Closed += (s, e) =>
- {
- _isClosed = true;
- Logger.Info("[QuickSend] Dialog closed");
- };
-
- Logger.Info($"[QuickSend] Dialog opened (prefill={!string.IsNullOrEmpty(prefillMessage)})");
- }
-
- private void TryBringToFront()
- {
- try
- {
- if (_isClosed)
- return;
-
- var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
- if (hwnd == IntPtr.Zero) return;
-
- // Make sure it's actually shown and promoted.
- ShowWindow(hwnd, SW_SHOWNORMAL);
- SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW);
- SetForegroundWindow(hwnd);
- }
- catch (Exception ex)
- {
- Logger.Warn($"QuickSend bring-to-front failed: {ex.Message}");
- }
- }
-
- private async void OnKeyDown(object sender, KeyRoutedEventArgs e)
- {
- if (e.Key == global::Windows.System.VirtualKey.Enter && !_isSending)
- {
- e.Handled = true;
- await SendMessageAsync();
- }
- else if (e.Key == global::Windows.System.VirtualKey.Escape)
- {
- Close();
- }
- }
-
- private async void OnSendClick(object sender, RoutedEventArgs e)
- {
- await SendMessageAsync();
- }
-
- private async Task SendMessageAsync()
- {
- var message = _messageTextBox.Text?.Trim();
- if (string.IsNullOrEmpty(message)) return;
-
- _errorDetailsTextBox.Visibility = Visibility.Collapsed;
- _errorDetailsTextBox.Text = string.Empty;
- this.SetWindowSize(420, 260 + TitleBarHeight);
-
- _isSending = true;
- _sendButton.IsEnabled = false;
- _messageTextBox.IsEnabled = false;
- ShowDetails(LocalizationHelper.GetString("QuickSend_Sending"));
-
- QuickSendOutcome outcome;
- try
- {
- outcome = await _coordinator.SendAsync(message);
- }
- catch (Exception ex)
- {
- // Coordinator catches/classifies all expected failures; this is
- // a defensive guard against unexpected programmer errors.
- Logger.Error($"Quick send coordinator threw: {ex.Message}");
- outcome = new QuickSendOutcome.Failed(ex.Message);
- }
-
- 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{pr.Commands}");
- break;
-
- 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 '{ms.Scope}'. Identity + remediation guidance copied to clipboard.")
- .Show();
- Logger.Warn($"[QuickSend] Missing scope '{ms.Scope}'. Commands copied to clipboard.\n{ms.Commands}");
- break;
-
- 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)
- {
- _errorDetailsTextBox.Header = LocalizationHelper.GetString("QuickSend_Failed");
- _errorDetailsTextBox.MinHeight = 140;
- _errorDetailsTextBox.Text = details;
- _errorDetailsTextBox.Visibility = Visibility.Visible;
- this.SetWindowSize(520, 400 + TitleBarHeight);
-
- // Move focus to the details box so users can immediately select/copy text.
- _errorDetailsTextBox.Focus(FocusState.Programmatic);
- }
-
- private void ShowDetails(string details)
- {
- _errorDetailsTextBox.Header = null;
- _errorDetailsTextBox.MinHeight = 80;
- _errorDetailsTextBox.Text = details;
- _errorDetailsTextBox.Visibility = Visibility.Visible;
- this.SetWindowSize(500, 320 + TitleBarHeight);
- }
-
- private static void CopyTextToClipboard(string text)
- {
- ClipboardHelper.CopyText(text);
- }
-
- private void QueueFocusMessageInput()
- {
- if (_isClosed)
- return;
-
- DispatcherQueue?.TryEnqueue(FocusMessageInput);
- }
-
- private void RequestInputFocus()
- {
- QueueFocusMessageInput();
- if (!_focusRetryRunning)
- {
- _focusRetryRunning = true;
- _ = RetryFocusMessageInputAsync();
- }
- }
-
- private async Task RetryFocusMessageInputAsync()
- {
- try
- {
- var delaysMs = new[] { 60, 160, 320 };
- foreach (var delay in delaysMs)
- {
- await Task.Delay(delay);
- if (_isClosed)
- return;
-
- TryBringToFront();
- QueueFocusMessageInput();
- }
- }
- finally
- {
- _focusRetryRunning = false;
- }
- }
-
- public void FocusMessageInput()
- {
- _messageTextBox.Focus(FocusState.Programmatic);
- _messageTextBox.SelectionStart = _messageTextBox.Text?.Length ?? 0;
- }
-
- public new void ShowAsync()
- {
- Activate();
- RequestInputFocus();
- }
-}
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index 5c8137d2f..e1bcdf2c0 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -47,7 +47,6 @@ public static class FluentIconCatalog
public const string CanvasAct = "\uE790"; // Color (palette) - matches Canvas permission glyph
public const string VoiceAct = "\uE720"; // Microphone
public const string Settings = "\uE713"; // Settings
- public const string QuickSend = "\uE724"; // Send (Mail variant) — closest universal Send glyph
public const string Setup = "\uE825"; // Bank — Reconfigure / Setup wizard launcher
public const string About = "\uE946"; // Info
public const string Exit = "\uE711"; // Cancel (X) — used for "Close" menu item
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
index 9fa67af3f..cd5cb45b0 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
@@ -58,9 +58,9 @@
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs
index f711a536d..537e5653c 100644
--- a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs
@@ -225,11 +225,6 @@ public static void Handle(string uri, DeepLinkActions actions)
actions.OpenDashboard?.Invoke(dashboardPath);
break;
- case "send":
- var sendMessage = result.Parameters.GetValueOrDefault("message");
- actions.OpenQuickSend?.Invoke(sendMessage);
- break;
-
case "agent":
var agentMessage = result.Parameters.GetValueOrDefault("message");
if (!string.IsNullOrEmpty(agentMessage) && actions.SendMessage != null)
@@ -304,7 +299,6 @@ public class DeepLinkActions
public Action? OpenActivityStream { get; set; }
public Action? OpenNotificationHistory { get; set; }
public Action? OpenDashboard { get; set; }
- public Action? OpenQuickSend { get; set; }
public Action? OpenHub { get; set; }
public Func? SendMessage { get; set; }
public Action? OpenVoice { get; set; }
diff --git a/src/OpenClaw.Tray.WinUI/Services/DeepLinkSecurityPolicy.cs b/src/OpenClaw.Tray.WinUI/Services/DeepLinkSecurityPolicy.cs
index 95f77ffea..1592f251a 100644
--- a/src/OpenClaw.Tray.WinUI/Services/DeepLinkSecurityPolicy.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/DeepLinkSecurityPolicy.cs
@@ -16,7 +16,6 @@ internal static class DeepLinkSecurityPolicy
private static readonly HashSet StateChangingPaths = new(StringComparer.OrdinalIgnoreCase)
{
- "send",
"agent",
"voice",
"voice-start",
@@ -78,7 +77,6 @@ internal static string GetActionDisplayName(DeepLinkResult result)
var path = result.Path.Trim().Trim('/').ToLowerInvariant();
return path switch
{
- "send" => "open the quick-send window with a prefilled message",
"agent" => "send a message to the agent",
"voice" or "voice-start" => "start voice input",
"voice-stop" => "stop voice input",
diff --git a/src/OpenClaw.Tray.WinUI/Services/GlobalHotkeyService.cs b/src/OpenClaw.Tray.WinUI/Services/GlobalHotkeyService.cs
index 91cf7b3d4..2a495d251 100644
--- a/src/OpenClaw.Tray.WinUI/Services/GlobalHotkeyService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/GlobalHotkeyService.cs
@@ -7,18 +7,16 @@ namespace OpenClawTray.Services;
///
/// Registers and handles global hotkeys using P/Invoke.
-/// Default: Ctrl+Alt+Shift+C for Quick Send.
+/// Default: Ctrl+Alt+Shift+V for Voice, Ctrl+Alt+; for Settings.
///
public class GlobalHotkeyService : IDisposable
{
- private const int HOTKEY_ID = 9001;
private const int HOTKEY_ID_VOICE = 9002;
private const int HOTKEY_ID_SETTINGS = 9003;
private const uint MOD_CONTROL = 0x0002;
private const uint MOD_ALT = 0x0001;
private const uint MOD_SHIFT = 0x0004;
private const uint MOD_WIN = 0x0008;
- private const uint VK_C = 0x43;
private const uint VK_V = 0x56;
private const uint VK_OEM_1 = 0xBA; // ';:' on US keyboards
private const int WM_HOTKEY = 0x0312;
@@ -118,7 +116,6 @@ private struct POINT
private readonly ManualResetEventSlim _windowReady = new(false);
private readonly ManualResetEventSlim _opCompleted = new(false);
- public event EventHandler? HotkeyPressed;
public event EventHandler? VoiceHotkeyPressed;
public event EventHandler? SettingsHotkeyPressed;
@@ -232,25 +229,12 @@ private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
if (msg == WM_APP_REGISTER)
{
// Register from the message-loop thread that owns hWnd.
- _registered = RegisterHotKey(hWnd, HOTKEY_ID,
+ // Voice hotkey: Ctrl+Alt+Shift+V
+ _registered = RegisterHotKey(hWnd, HOTKEY_ID_VOICE,
MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT,
- VK_C);
+ VK_V);
if (_registered)
- {
- Logger.Info("Global hotkey registered: Ctrl+Alt+Shift+C");
- }
- else
- {
- var err = Marshal.GetLastWin32Error();
- var errMsg = new Win32Exception(err).Message;
- Logger.Warn($"Failed to register global hotkey (Win32Error={err}: {errMsg})");
- }
-
- // Also register voice hotkey: Ctrl+Alt+Shift+V
- if (RegisterHotKey(hWnd, HOTKEY_ID_VOICE,
- MOD_CONTROL | MOD_ALT | MOD_SHIFT | MOD_NOREPEAT,
- VK_V))
{
Logger.Info("Voice hotkey registered: Ctrl+Alt+Shift+V");
}
@@ -282,7 +266,6 @@ private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
if (_registered)
{
- UnregisterHotKey(hWnd, HOTKEY_ID);
UnregisterHotKey(hWnd, HOTKEY_ID_VOICE);
UnregisterHotKey(hWnd, HOTKEY_ID_SETTINGS);
_registered = false;
@@ -300,12 +283,7 @@ private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
return IntPtr.Zero;
}
- if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID)
- {
- Logger.Info("Hotkey pressed: Ctrl+Alt+Shift+C");
- OnHotkeyPressed();
- }
- else if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID_VOICE)
+ if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID_VOICE)
{
Logger.Info("Voice hotkey pressed: Ctrl+Alt+Shift+V");
VoiceHotkeyPressed?.Invoke(this, EventArgs.Empty);
@@ -346,11 +324,6 @@ public void Unregister()
}
}
- internal void OnHotkeyPressed()
- {
- HotkeyPressed?.Invoke(this, EventArgs.Empty);
- }
-
public void Dispose()
{
if (_disposed) return;
diff --git a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
index 54c0f7ab2..69a20985b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs
@@ -14,7 +14,6 @@ internal interface IAppCommands
void Disconnect();
void ShowVoiceOverlay();
void ShowChat();
- void ShowQuickSend();
void CheckForUpdates();
void ShowOnboarding();
void ShowConnectionStatus();
diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
index 7b0ac13f7..bcfef98ac 100644
--- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs
@@ -311,10 +311,6 @@ internal void Build(TrayMenuWindow menu)
menu.AddMenuItem("Canvas", FluentIconCatalog.Build(FluentIconCatalog.CanvasAct), "canvas");
// Voice overlay disabled — inline chat voice mode is used instead.
// menu.AddMenuItem("Voice", FluentIconCatalog.Build(FluentIconCatalog.VoiceAct), "voice");
- menu.AddMenuItem(
- LocalizationHelper.GetString("Menu_QuickSend"),
- FluentIconCatalog.Build(FluentIconCatalog.QuickSend),
- "quicksend");
// Setup Guide / Reconfigure entry — label flips based on whether prior
// configuration exists; routes to the existing "setup" action handler.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 1b52ef66d..ed26351df 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -49,7 +49,7 @@
Start automatically with Windows
- Global hotkey (Ctrl+Alt+Shift+C → Quick Send)
+ Global hotkeys (voice, settings)
Show notifications
@@ -312,9 +312,6 @@
Notification History — OpenClaw Tray
-
- Quick Send — OpenClaw
-
OpenClaw Chat
@@ -334,31 +331,6 @@
❌ Connection failed
-
-
- 📤 Quick Send
-
-
- Type your message...
-
-
- Send
-
-
- Cancel
-
-
- Sending...
-
-
- ❌ Failed
-
-
- Message Sent
-
-
- Your message was sent to OpenClaw.
-
Welcome to OpenClaw!
@@ -433,9 +405,6 @@
Open Web Chat
-
- Quick Send...
-
Activity Stream...
@@ -2096,7 +2065,7 @@ On your gateway host (Mac/Linux), run:
Start automatically with Windows
- Global hotkey (Ctrl+Alt+Shift+C → Quick Send)
+ Global hotkeys (voice, settings)
Notifications
@@ -3374,7 +3343,7 @@ On your gateway host (Mac/Linux), run:
Start with Windows
- Quick Send hotkey
+ Global hotkeys
Use gateway's web chat interface
@@ -3548,7 +3517,7 @@ On your gateway host (Mac/Linux), run:
Launch OpenClaw automatically when you sign in.
- Ctrl + Alt + Shift + C opens the Quick Send dialog from anywhere.
+ Enable global hotkeys (Ctrl+Alt+Shift+V for voice, Ctrl+Alt+; for settings).
When on, Chat hosts the gateway's web chat instead of the native Windows experience.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 279490d04..4e466e442 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -287,9 +287,6 @@
Historique de notifications — OpenClaw Tray
-
- Envoi rapide — OpenClaw
-
Discussion OpenClaw
@@ -308,30 +305,6 @@
❌ Problème de connexion
-
- 📤 Envoi rapide
-
-
- Saisissez votre message...
-
-
- Envoyer
-
-
- Annuler
-
-
- Envoi en cours...
-
-
- ❌ Échec
-
-
- Message envoyé
-
-
- Votre message a été envoyé à OpenClaw.
-
Bienvenue sur OpenClaw!
@@ -403,9 +376,6 @@
Ouvrir le chat
-
- Envoi rapide...
-
Flux d'activité...
@@ -3499,7 +3469,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Launch OpenClaw automatically when you sign in.
- Ctrl + Alt + Shift + C opens the Quick Send dialog from anywhere.
+ Enable global hotkeys (Ctrl+Alt+Shift+V for voice, Ctrl+Alt+; for settings).
When on, Chat hosts the gateway's web chat instead of the native Windows experience.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 0d0109f63..e1dabd9ba 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -288,9 +288,6 @@
Meldingsgeschiedenis — OpenClaw Tray
-
- Snel verzenden — OpenClaw
-
OpenClaw-chatgesprek
@@ -309,30 +306,6 @@
❌ Verbinding mislukt
-
- 📤 Snel verzenden
-
-
- Typ je bericht...
-
-
- Verzenden
-
-
- Annuleren
-
-
- Verzenden...
-
-
- ❌ Mislukt
-
-
- Bericht verzonden
-
-
- Je bericht is verzonden naar OpenClaw.
-
Welkom bij OpenClaw!
@@ -404,9 +377,6 @@
Webchat openen
-
- Snel verzenden...
-
Activiteitenstroom...
@@ -3500,7 +3470,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Launch OpenClaw automatically when you sign in.
- Ctrl + Alt + Shift + C opens the Quick Send dialog from anywhere.
+ Enable global hotkeys (Ctrl+Alt+Shift+V for voice, Ctrl+Alt+; for settings).
When on, Chat hosts the gateway's web chat instead of the native Windows experience.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index b9fbb4395..316b73d8a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -287,9 +287,6 @@
通知历史 — OpenClaw 托盘
-
- 快速发送 — OpenClaw
-
OpenClaw 聊天
@@ -308,30 +305,6 @@
❌ 连接失败
-
- 📤 快速发送
-
-
- 输入您的消息...
-
-
- 发送
-
-
- 取消
-
-
- 发送中...
-
-
- ❌ 发送失败
-
-
- 消息已发送
-
-
- 您的消息已发送至 OpenClaw。
-
欢迎使用 OpenClaw!
@@ -403,9 +376,6 @@
打开网页聊天
-
- 快速发送...
-
活动流...
@@ -3499,7 +3469,7 @@
Launch OpenClaw automatically when you sign in.
- Ctrl + Alt + Shift + C opens the Quick Send dialog from anywhere.
+ Enable global hotkeys (Ctrl+Alt+Shift+V for voice, Ctrl+Alt+; for settings).
When on, Chat hosts the gateway's web chat instead of the native Windows experience.
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 3e4e9973a..385e96555 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -287,9 +287,6 @@
通知歷史 — OpenClaw 設定
-
- 快速發送 — OpenClaw
-
OpenClaw 聊天
@@ -308,30 +305,6 @@
❌ 連線失敗
-
- 📤 快速發送
-
-
- 輸入您的訊息...
-
-
- 發送
-
-
- 取消
-
-
- 發送中...
-
-
- ❌ 發送失敗
-
-
- 訊息已發送
-
-
- 您的訊息已發送至 OpenClaw。
-
歡迎使用 OpenClaw!
@@ -403,9 +376,6 @@
打開網頁聊天
-
- 快速發送...
-
串流活動...
@@ -3499,7 +3469,7 @@
Launch OpenClaw automatically when you sign in.
- Ctrl + Alt + Shift + C opens the Quick Send dialog from anywhere.
+ Enable global hotkeys (Ctrl+Alt+Shift+V for voice, Ctrl+Alt+; for settings).
When on, Chat hosts the gateway's web chat instead of the native Windows experience.
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 49d282825..fe1b92b74 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -749,7 +749,6 @@ internal List BuildCommandList()
// Actions
new() { Icon = "💬", Title = "Open Chat Window", Subtitle = "Open standalone chat", Tag = "chat" },
new() { Icon = "🌐", Title = "Open Dashboard", Subtitle = "Open web dashboard", Execute = () => ((IAppCommands)Application.Current).OpenDashboard(null) },
- new() { Icon = "📤", Title = "Quick Send", Subtitle = "Send a quick message", Execute = () => QuickSendAction?.Invoke() },
};
// Toggle commands
@@ -821,9 +820,6 @@ private void ExecuteCommand(CommandItem cmd)
}
}
- /// Action to open the QuickSend dialog, set by App.xaml.cs.
- public Action? QuickSendAction { get; set; }
-
#region High Contrast icon fallback
// Maps NavigationViewItem.Tag -> Segoe Fluent Icons glyph used as fallback
diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
index 93f9c54a2..89f325939 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
+++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
@@ -277,12 +277,6 @@ private T GetPrivateField(string fieldName)
public void SetOperatorDeviceId(string? id) => SetPrivateField("_operatorDeviceId", id);
- public string CallBuildMissingScopeFixCommands(string missingScope) =>
- _client.BuildMissingScopeFixCommands(missingScope);
-
- public string CallBuildPairingApprovalFixCommands() =>
- _client.BuildPairingApprovalFixCommands();
-
public string[] GetRequestedOperatorScopes()
{
var role = GetConnectRole();
@@ -1798,174 +1792,6 @@ public void ParseChannelHealth_ProbeNotOk_DoesNotSetReady()
Assert.Equal("ready", channels[0].Status);
}
- // ── BuildMissingScopeFixCommands tests ─────────────────────────────────────
-
- [Fact]
- public void BuildMissingScopeFixCommands_NullOrEmptyScope_DefaultsToOperatorWrite()
- {
- var helper = new GatewayClientTestHelper();
-
- var output = helper.CallBuildMissingScopeFixCommands("");
-
- Assert.Contains("Missing scope: operator.write", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WhitespaceScope_DefaultsToOperatorWrite()
- {
- var helper = new GatewayClientTestHelper();
-
- var output = helper.CallBuildMissingScopeFixCommands(" ");
-
- Assert.Contains("Missing scope: operator.write", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WithSpecificScope_IncludesItInOutput()
- {
- var helper = new GatewayClientTestHelper();
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.approvals");
-
- Assert.Contains("Missing scope: operator.approvals", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_EmptyGrantedScopes_ShowsNoneReportedPlaceholder()
- {
- var helper = new GatewayClientTestHelper();
- // _grantedOperatorScopes is empty by default
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("(none reported by gateway)", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WithGrantedScopes_ListsScopesInOutput()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetGrantedScopes(["operator.read", "operator.admin"]);
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("operator.read, operator.admin", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WithOperatorDeviceId_IncludesItInOutput()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetOperatorDeviceId("test-device-id-abc123");
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("test-device-id-abc123", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_NoOperatorDeviceId_ShowsNotReportedPlaceholder()
- {
- var helper = new GatewayClientTestHelper();
- // _operatorDeviceId is null by default
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("(not reported for this operator connection)", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WithNodeScopes_ShowsNodeTokenWarning()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetGrantedScopes(["node.read", "node.write"]);
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("Detected node.* scopes", output);
- Assert.Contains("node token", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_WithOnlyOperatorScopes_NoNodeTokenWarning()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetGrantedScopes(["operator.read", "operator.write"]);
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.DoesNotContain("node token", output);
- }
-
- [Fact]
- public void BuildMissingScopeFixCommands_NodeScopeIsCaseInsensitive()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetGrantedScopes(["NODE.read"]);
-
- var output = helper.CallBuildMissingScopeFixCommands("operator.write");
-
- Assert.Contains("Detected node.* scopes", output);
- }
-
- // ── BuildPairingApprovalFixCommands tests ──────────────────────────────────
-
- [Fact]
- public void BuildPairingApprovalFixCommands_WithOperatorDeviceId_UsesItInOutput()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetOperatorDeviceId("operator-device-abc");
-
- var output = helper.CallBuildPairingApprovalFixCommands();
-
- Assert.Contains("operator-device-abc", output);
- }
-
- [Fact]
- public void BuildPairingApprovalFixCommands_NoOperatorDeviceId_FallsBackToDeviceIdentity()
- {
- var helper = new GatewayClientTestHelper();
- // _operatorDeviceId is null by default
-
- var fallbackId = helper.GetFallbackDeviceId();
- var output = helper.CallBuildPairingApprovalFixCommands();
-
- Assert.Contains(fallbackId, output);
- }
-
- [Fact]
- public void BuildPairingApprovalFixCommands_EmptyGrantedScopes_ShowsNoneYetPlaceholder()
- {
- var helper = new GatewayClientTestHelper();
- // _grantedOperatorScopes is empty by default
-
- var output = helper.CallBuildPairingApprovalFixCommands();
-
- Assert.Contains("(none reported by gateway yet)", output);
- }
-
- [Fact]
- public void BuildPairingApprovalFixCommands_WithGrantedScopes_ListsThemInOutput()
- {
- var helper = new GatewayClientTestHelper();
- helper.SetGrantedScopes(["operator.read", "operator.pairing"]);
-
- var output = helper.CallBuildPairingApprovalFixCommands();
-
- Assert.Contains("operator.read, operator.pairing", output);
- }
-
- [Fact]
- public void BuildPairingApprovalFixCommands_ContainsApprovalInstructions()
- {
- var helper = new GatewayClientTestHelper();
-
- var output = helper.CallBuildPairingApprovalFixCommands();
-
- Assert.Contains("pairing required", output);
- Assert.Contains("Approve this Windows tray device ID", output);
- }
-
// --- HandleRequestError: pairing required ---
[Fact]
diff --git a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs
index 887757961..216657335 100644
--- a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs
+++ b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs
@@ -334,20 +334,6 @@ public void Handle_Activity_RedirectsToChannelsByDefault()
Assert.Equal("channels", hubTag);
}
- [Fact]
- public void Handle_Send_PassesPrefillMessage()
- {
- string? message = null;
- var actions = new DeepLinkActions
- {
- OpenQuickSend = value => message = value
- };
-
- DeepLinkHandler.Handle("openclaw://send?message=hello%20world", actions);
-
- Assert.Equal("hello world", message);
- }
-
[Fact]
public async Task Handle_Agent_SendsMessage()
{
diff --git a/tests/OpenClaw.Tray.Tests/DeepLinkSecurityPolicyTests.cs b/tests/OpenClaw.Tray.Tests/DeepLinkSecurityPolicyTests.cs
index fe92811b5..4e23aab1d 100644
--- a/tests/OpenClaw.Tray.Tests/DeepLinkSecurityPolicyTests.cs
+++ b/tests/OpenClaw.Tray.Tests/DeepLinkSecurityPolicyTests.cs
@@ -6,8 +6,6 @@ namespace OpenClaw.Tray.Tests;
public class DeepLinkSecurityPolicyTests
{
[Theory]
- [InlineData("send")]
- [InlineData("send/anything")]
[InlineData("agent")]
[InlineData("voice")]
[InlineData("voice-start")]
diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
index 5fad700f4..06d15b9a6 100644
--- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
@@ -19,7 +19,7 @@ public sealed class FluentIconCatalogTests
"StatusOk", "StatusWarn", "StatusErr",
"Sessions", "Approvals", "Devices", "Hostname", "Permissions",
"Browser", "Camera", "Canvas", "Screen", "Location", "Voice", "Speech", "System", "Terminal", "Operator",
- "Dashboard", "OpenInBrowser", "Chat", "CanvasAct", "VoiceAct", "Settings", "QuickSend",
+ "Dashboard", "OpenInBrowser", "Chat", "CanvasAct", "VoiceAct", "Settings",
"Setup", "About", "Exit",
"Add", "Back", "Sync", "Lock", "Plug", "MoreOverflow",
"People", "Money", "ServerEnvironment", "CapabilityOff", "Channels",
@@ -222,7 +222,7 @@ public void BuildTrayMenuPopup_DoesNotEmitInlinePermissionGrid()
///
/// Regression guard: every static action emitted by the tray menu's
- /// top-level entries (Gateway header, Permissions, Setup, QuickSend, etc.)
+ /// top-level entries (Gateway header, Permissions, Setup, etc.)
/// must have an explicit case in OnTrayMenuItemClicked. The default
/// fall-through to ShowHub(action) is convenient but easy to break
/// silently — these specific actions are user-visible entry points and
@@ -239,7 +239,6 @@ public void BuildTrayMenuPopup_TopLevelActions_AllHaveExplicitHandlers()
"reconnect", // brand-header button (when disconnected)
"permissions", // permissions row
"setup", // setup/reconfigure row
- "quicksend", // quicksend row
"companion", // footer
"about", // footer
"exit", // footer
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 629e5eaea..062f3877d 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -74,7 +74,6 @@
-
diff --git a/tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs b/tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs
deleted file mode 100644
index f9a765feb..000000000
--- a/tests/OpenClaw.Tray.Tests/QuickSendCoordinatorTests.cs
+++ /dev/null
@@ -1,352 +0,0 @@
-using OpenClawTray.Dialogs;
-using System;
-using System.Threading.Tasks;
-using Xunit;
-
-namespace OpenClaw.Tray.Tests;
-
-///
-/// Bug #3 (manual test 2026-05-05): QuickSend dialog used to capture the App's
-/// gateway client at constructor time and continue sending against it after
-/// the App swapped in a freshly-paired instance — producing a spurious
-/// "copy pair command to clipboard" toast against a perfectly paired live
-/// client. These tests cover the per-Send resolver behavior and the
-/// null/disposed lifetime contract (RubberDucky closure conditions #2 + #3).
-///
-public class QuickSendCoordinatorTests
-{
- private sealed class FakeGateway : IQuickSendGateway
- {
- public string Name { get; }
- public bool IsConnectedToGateway { get; set; } = true;
- public int ConnectCount;
- public int SendCount;
- public string? LastSent;
- public Exception? SendThrows;
-
- public string PairingCommands { get; set; } = "PAIR-COMMANDS-DEFAULT";
- public string MissingScopeCommandsTemplate { get; set; } = "SCOPE-COMMANDS-FOR:{0}";
-
- public FakeGateway(string name = "fake") { Name = name; }
-
- public Task ConnectAsync()
- {
- ConnectCount++;
- IsConnectedToGateway = true;
- return Task.CompletedTask;
- }
-
- public Task SendChatMessageAsync(string message)
- {
- SendCount++;
- LastSent = message;
- if (SendThrows != null) throw SendThrows;
- return Task.CompletedTask;
- }
-
- public string BuildPairingApprovalFixCommands() => $"{PairingCommands}|from={Name}";
- public string BuildMissingScopeFixCommands(string missingScope) =>
- string.Format(MissingScopeCommandsTemplate, missingScope) + $"|from={Name}";
- }
-
- private static QuickSendCoordinator NewCoordinator(Func provider)
- // Tight timings keep tests fast; behavior identical to production defaults.
- => new(provider, connectTimeoutMs: 200, providerRetryDelayMs: 5,
- delayAsync: _ => Task.CompletedTask);
-
- // -- Closure condition #1 stale-snapshot scenarios --------------------
-
- [Fact]
- public async Task Send_AfterClientReinitialized_UsesFreshClient()
- {
- // Open dialog while App._gatewayClient = clientA (e.g., still unpaired
- // bootstrap-token instance). Autopair completes → App reassigns to
- // clientB (paired). Per-send resolver must observe clientB.
- var clientA = new FakeGateway("A");
- var clientB = new FakeGateway("B");
- IQuickSendGateway? live = clientA;
- var coord = NewCoordinator(() => live);
-
- live = clientB; // App swapped underneath the dialog
- var outcome = await coord.SendAsync("hello");
-
- Assert.IsType(outcome);
- Assert.Equal(0, clientA.SendCount);
- Assert.Equal(1, clientB.SendCount);
- Assert.Equal("hello", clientB.LastSent);
- }
-
- [Fact]
- public async Task ReusedDialog_AfterClientSwap_UsesNewClient()
- {
- // Mirrors App.ShowQuickSend's _quickSendDialog reactivation path:
- // the dialog instance lives across many Sends and across A→B swap.
- var clientA = new FakeGateway("A");
- var clientB = new FakeGateway("B");
- IQuickSendGateway? live = clientA;
- var coord = NewCoordinator(() => live);
-
- var firstOutcome = await coord.SendAsync("ping-1");
- Assert.IsType(firstOutcome);
- Assert.Equal(1, clientA.SendCount);
-
- live = clientB;
- var secondOutcome = await coord.SendAsync("ping-2");
- Assert.IsType(secondOutcome);
- Assert.Equal(1, clientA.SendCount); // not re-used
- Assert.Equal(1, clientB.SendCount);
- Assert.Equal("ping-2", clientB.LastSent);
- }
-
- // -- Closure condition #2 lifetime contract: null + disposed ---------
-
- [Fact]
- public async Task Send_WhenProviderReturnsNull_ShowsInitializing_NoClipboard()
- {
- // Provider returns null both on first try and after the short retry
- // delay (true mid-init, not just a swap blip).
- var coord = NewCoordinator(() => null);
-
- var outcome = await coord.SendAsync("hello");
-
- var init = Assert.IsType(outcome);
- Assert.Contains("initializing", init.Message, StringComparison.OrdinalIgnoreCase);
- }
-
- [Fact]
- public async Task Send_WhenProviderReturnsNullThenClient_RetriesAndSends()
- {
- // Closes the swap-window race: dispose-then-init briefly leaves the
- // field null. The coordinator retries the provider once after a
- // short delay before declaring "initializing".
- var clientB = new FakeGateway("B");
- var calls = 0;
- IQuickSendGateway? Provider()
- {
- calls++;
- return calls == 1 ? null : clientB;
- }
-
- var coord = NewCoordinator(Provider);
- var outcome = await coord.SendAsync("hello");
-
- Assert.IsType(outcome);
- Assert.Equal(1, clientB.SendCount);
- Assert.Equal(2, calls);
- }
-
- [Fact]
- public async Task Send_WhenResolvedClientThrowsObjectDisposed_ReturnsFailed_NoClipboard()
- {
- // Provider returns A; A is disposed mid-send and throws
- // ObjectDisposedException from SendChatMessageAsync.
- var clientA = new FakeGateway("A")
- {
- SendThrows = new ObjectDisposedException("WebSocket")
- };
- var coord = NewCoordinator(() => clientA);
-
- var outcome = await coord.SendAsync("hello");
-
- var failed = Assert.IsType(outcome);
- // Specifically NOT a clipboard-pairing remediation outcome.
- Assert.IsNotType(outcome);
- Assert.Contains("reset mid-send", failed.ErrorMessage, StringComparison.OrdinalIgnoreCase);
- }
-
- [Fact]
- public async Task Send_WhenProviderItselfThrows_ShowsInitializing()
- {
- // Defensive: belt-and-braces against future provider impls. Treat as
- // mid-swap, not as a hard failure.
- var coord = NewCoordinator(() => throw new InvalidOperationException("boom"));
-
- var outcome = await coord.SendAsync("hello");
-
- Assert.IsType(outcome);
- }
-
- // -- Closure condition #3 genuine-unpaired regression guard ----------
-
- [Fact]
- public async Task Send_WhenLiveClientGenuinelyUnpaired_StillFiresClipboardRemediation()
- {
- // The current live client (resolved by the provider on this Send)
- // genuinely reports NOT_PAIRED. Mike explicitly does NOT want this
- // suppressed: clipboard remediation must still fire, and the
- // commands must come from THIS resolved client (not a stale one).
- var live = new FakeGateway("LIVE-but-unpaired")
- {
- PairingCommands = "REAL-PAIR-CMDS",
- SendThrows = new InvalidOperationException("NOT_PAIRED: device not approved"),
- };
- var coord = NewCoordinator(() => live);
-
- var outcome = await coord.SendAsync("hello");
-
- var pairing = Assert.IsType(outcome);
- Assert.Contains("REAL-PAIR-CMDS", pairing.Commands);
- Assert.Contains("from=LIVE-but-unpaired", pairing.Commands);
- }
-
- [Fact]
- public async Task Send_PairingRemediationIsBuiltFromLiveClient_NotStaleSnapshot()
- {
- // Belt-and-braces variant of the regression guard: even when the App
- // swaps A→B mid-flight (B is genuinely unpaired), the remediation
- // commands must come from B (live), not A (stale snapshot).
- var staleA = new FakeGateway("STALE-A") { PairingCommands = "STALE-CMDS" };
- var liveB = new FakeGateway("LIVE-B")
- {
- PairingCommands = "LIVE-CMDS",
- SendThrows = new InvalidOperationException("not paired"),
- };
- IQuickSendGateway? live = staleA;
- var coord = NewCoordinator(() => live);
-
- live = liveB;
- var outcome = await coord.SendAsync("hello");
-
- var pairing = Assert.IsType(outcome);
- Assert.Contains("LIVE-CMDS", pairing.Commands);
- Assert.DoesNotContain("STALE-CMDS", pairing.Commands);
- Assert.Contains("from=LIVE-B", pairing.Commands);
- }
-
- // -- Tunnel restart + manual ConnectionPage reinit (dialog-lifetime) -
-
- [Fact]
- public async Task Send_AfterSshTunnelRestart_UsesNewClient()
- {
- // SSH tunnel restart in App.RestartSshTunnel disposes _gatewayClient,
- // sets it null, then re-runs InitializeGatewayClient. Provider sees
- // the new instance on the next Send.
- var oldClient = new FakeGateway("OLD");
- var newClient = new FakeGateway("NEW");
- IQuickSendGateway? live = oldClient;
- var coord = NewCoordinator(() => live);
-
- await coord.SendAsync("before-restart");
- Assert.Equal(1, oldClient.SendCount);
-
- // Simulate restart: dispose-and-null, then reinit with new instance.
- live = null;
- live = newClient;
-
- var outcome = await coord.SendAsync("after-restart");
- Assert.IsType(outcome);
- Assert.Equal(1, newClient.SendCount);
- Assert.Equal(1, oldClient.SendCount); // unchanged
- }
-
- [Fact]
- public async Task Send_AfterManualConnectionPageReinit_UsesNewClient()
- {
- // ConnectionPage.TestConnection → app.ReinitializeGatewayClient()
- // swaps the App field. QuickSend opened later (or kept open) must
- // observe the new instance.
- var preReinit = new FakeGateway("PRE");
- var postReinit = new FakeGateway("POST");
- IQuickSendGateway? live = preReinit;
- var coord = NewCoordinator(() => live);
-
- live = postReinit;
- var outcome = await coord.SendAsync("hello");
-
- Assert.IsType(outcome);
- Assert.Equal(1, postReinit.SendCount);
- Assert.Equal(0, preReinit.SendCount);
- }
-
- // -- Autopair end-to-end (integration validation, RubberDucky #3) -----
-
- [Fact]
- public async Task Autopair_End_To_End_Reinit_Then_QuickSend_Sends_Successfully()
- {
- // Simulates the full front-door autopair sequence at the resolver
- // contract layer (the only layer that determines staleness):
- //
- // 1. App boots with bootstrap-token client A (will report NOT_PAIRED).
- // 2. User opens QuickSend; dialog captures Func<>=()=>field, NOT
- // a snapshot of A.
- // 3. Local autopair completes; OnboardingCompleted callback
- // disposes A, sets field=null, calls InitializeGatewayClient
- // which assigns a freshly-paired client B to field.
- // 4. User clicks Send. Resolver returns B; Send succeeds; NO
- // clipboard pairing-remediation toast fires.
- //
- // The integration this validates is the same one Mike's manual e2e
- // exercises after the fix lands.
- var bootstrapClientA = new FakeGateway("bootstrap-A")
- {
- SendThrows = new InvalidOperationException("NOT_PAIRED"),
- };
- var pairedClientB = new FakeGateway("paired-B");
-
- IQuickSendGateway? appField = bootstrapClientA;
- var coord = NewCoordinator(() => appField);
-
- // Phase 1: dialog opens; without the fix, Send here would clipboard-toast.
- // Phase 2: autopair completes — App swaps field A→B (with brief null).
- appField = null;
- appField = pairedClientB;
-
- // Phase 3: user clicks Send.
- var outcome = await coord.SendAsync("first message after autopair");
-
- Assert.IsType(outcome);
- Assert.Equal(0, bootstrapClientA.SendCount);
- Assert.Equal(1, pairedClientB.SendCount);
- }
-
- // -- Misc behavioral coverage ----------------------------------------
-
- [Fact]
- public async Task Send_EmptyMessage_ReturnsFailed()
- {
- var coord = NewCoordinator(() => new FakeGateway());
- var outcome = await coord.SendAsync(" ");
- Assert.IsType(outcome);
- }
-
- [Fact]
- public async Task Send_MissingScope_ReturnsMissingScopeOutcome_FromLiveClient()
- {
- var live = new FakeGateway("scoped")
- {
- MissingScopeCommandsTemplate = "RUN:fix scope {0}",
- SendThrows = new InvalidOperationException("missing scope: operator.write"),
- };
- var coord = NewCoordinator(() => live);
-
- var outcome = await coord.SendAsync("hello");
-
- var ms = Assert.IsType(outcome);
- Assert.Equal("operator.write", ms.Scope);
- Assert.Contains("RUN:fix scope operator.write", ms.Commands);
- Assert.Contains("from=scoped", ms.Commands);
- }
-
- [Fact]
- public async Task Send_WhenNotConnected_AttemptsConnect()
- {
- var live = new FakeGateway("disconnected") { IsConnectedToGateway = false };
- var coord = NewCoordinator(() => live);
-
- var outcome = await coord.SendAsync("hello");
-
- Assert.IsType(outcome);
- Assert.Equal(1, live.ConnectCount);
- Assert.Equal(1, live.SendCount);
- }
-
- [Fact]
- public void IsPairingRequired_MatchesAllKnownVariants()
- {
- Assert.True(QuickSendCoordinator.IsPairingRequired("NOT_PAIRED"));
- Assert.True(QuickSendCoordinator.IsPairingRequired("device is not paired"));
- Assert.True(QuickSendCoordinator.IsPairingRequired("pairing required"));
- Assert.False(QuickSendCoordinator.IsPairingRequired("transport closed"));
- Assert.False(QuickSendCoordinator.IsPairingRequired(null));
- }
-}
From 4231feab9c0e9619892d6dc6cdb35823dea93a48 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Thu, 21 May 2026 15:21:26 -0700
Subject: [PATCH 052/115] fix(chat): dedupe duplicate assistant bubbles and
align continuation bubbles
Two related fixes for the screenshot bug where the same assistant text rendered twice in a row and continuation bubbles were indented left of the first bubble.
Rendering (OpenClawChatTimeline.RenderAssistantEntry):
- Reserve a 36x36 spacer for continuation assistant entries so the bubble's left edge stays aligned with the first entry in the burst (matches the user-burst path above and the tool-burst path below).
- bubbleRow margin and footer leftInset now depend only on showAssistAvatar, so the slot is consistently inset regardless of whether the avatar is actually drawn.
Reducer (ChatTimelineReducer.UpsertAssistant):
- Added an identical-text dedupe safety net: if the most recent Assistant entry within the same turn (i.e. before any User boundary) has byte-equal text to the incoming message, collapse them. This catches duplicate ChatMessageEvent emissions from the gateway regardless of the ReconcilePrevious flag.
- On merge, restore ActiveAssistantId to the merged entry so subsequent deltas/messages do not split into a new bubble.
Tests:
- DuplicateFinalAssistant_IdenticalText_DedupesWithoutReconcileFlag
- SubsequentAssistant_DifferentText_AfterTurnEnd_CreatesNewEntry
Validation: build.ps1, Shared.Tests (1869 passed / 28 skipped), Tray.Tests (1193 passed).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Chat/ChatTimelineReducer.cs | 34 ++++++++++++----
.../Chat/OpenClawChatTimeline.cs | 18 +++++----
.../ChatTimelineReducerTests.cs | 40 +++++++++++++++++++
3 files changed, 76 insertions(+), 16 deletions(-)
diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs
index 1df3a1166..f007028b9 100644
--- a/src/OpenClaw.Chat/ChatTimelineReducer.cs
+++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs
@@ -189,27 +189,45 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b
}
}
- if (replace && reconcilePrevious && state.Entries.Count > 0)
+ // A final ChatMessageEvent that arrives without an ActiveAssistantId
+ // must, in certain cases, reconcile into the most recent Assistant
+ // entry instead of creating a duplicate bubble:
+ //
+ // • `reconcilePrevious` flag: explicit opt-in from the provider,
+ // used when the gateway emits the final message AFTER tool entries
+ // have been appended (text → tool → tool output → final text). The
+ // flag lets the reducer collapse the streaming preview into the
+ // final text even though the immediate last entry is a ToolCall.
+ //
+ // • Identical-text safety net: if the most recent Assistant entry
+ // (within the same turn — i.e. before any User boundary) has
+ // byte-equal text to the incoming message, collapse them
+ // regardless of any flag. This catches duplicate ChatMessageEvent
+ // emissions from the gateway (see the duplicate-bubble screenshot
+ // bug where the same final text was rendered twice in a row).
+ if (replace && state.Entries.Count > 0)
{
// Scan backward for the most recent Assistant entry — not just
- // the very last one. The gateway can emit a final message
- // event AFTER tool entries have been appended (text → tool →
- // tool output → final text), in which case the immediate last
- // entry is a ToolCall. Without this scan, the final message
- // would create a brand-new assistant entry that duplicates
- // the streaming text the user already saw before the tool ran.
+ // the very last one (see reasons above).
for (var li = state.Entries.Count - 1; li >= 0; li--)
{
var candidate = state.Entries[li];
if (candidate.Kind == ChatTimelineItemKind.Assistant)
{
+ var shouldMerge =
+ reconcilePrevious
+ || string.Equals(candidate.Text, text, StringComparison.Ordinal);
+ if (!shouldMerge)
+ break;
+
return state with
{
Entries = state.Entries.SetItem(li, candidate with
{
Text = text,
IsStreaming = streaming
- })
+ }),
+ ActiveAssistantId = candidate.Id
};
}
// Stop scanning once we hit a User entry — that's a turn
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 3a086bf1f..2560280d1 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1061,12 +1061,14 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
return Empty();
// Avatar shown only on the FIRST entry of a contiguous agent-side
- // run. Continuation entries get no spacer — they align flush with
- // the tool burst cards above (which also sit at the left inset),
- // so the agent column reads as a single vertical edge.
- Element leftSlot = !showAssistAvatar || !showAvatar
+ // run. Continuation entries reserve a 36×36 spacer so the bubble's
+ // left edge stays aligned with the first entry (matches the user
+ // burst path above and the tool burst path below).
+ Element leftSlot = !showAssistAvatar
? Empty()
- : AssistantAvatar().VAlign(VerticalAlignment.Top);
+ : (showAvatar
+ ? AssistantAvatar().VAlign(VerticalAlignment.Top)
+ : Border(Empty()).Size(36, 36));
// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
@@ -1103,7 +1105,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var bubbleRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0),
card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
@@ -1117,7 +1119,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
entryMeta?.InputTokens, entryMeta?.OutputTokens,
entryMeta?.ResponseTokens, entryMeta?.ContextPercent,
chatStampFg, entry.Id, entry.Text ?? "");
- var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
+ var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0;
leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
}
@@ -1913,7 +1915,7 @@ string Truncate(string s, int max)
var burstRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0),
listCard.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
index 6860a911c..df36994a2 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
@@ -68,6 +68,46 @@ public void DuplicateFinalAssistant_DoesNotCreateSecondEntry()
Assert.Equal("final", updated.Entries[0].Text);
}
+ [Fact]
+ public void DuplicateFinalAssistant_IdenticalText_DedupesWithoutReconcileFlag()
+ {
+ // Reproduces the duplicate-bubble screenshot bug: gateway re-emits
+ // the exact same final message after a turn end without setting the
+ // ReconcilePrevious flag. The reducer must collapse identical-text
+ // duplicates as a safety net so the UI doesn't render the same
+ // assistant text twice in a row.
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatMessageEvent("I don't see a pending approval."));
+ state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
+ Assert.False(state.TurnActive);
+
+ var updated = ChatTimelineReducer.Apply(
+ state,
+ new ChatMessageEvent("I don't see a pending approval."));
+
+ Assert.Single(updated.Entries);
+ Assert.Equal("I don't see a pending approval.", updated.Entries[0].Text);
+ }
+
+ [Fact]
+ public void SubsequentAssistant_DifferentText_AfterTurnEnd_CreatesNewEntry()
+ {
+ // Guard against over-aggressive dedupe: a genuinely new assistant
+ // message in a later turn (different text, no reconcile flag, turn
+ // already ended) must NOT be merged into the previous entry.
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatMessageEvent("first"));
+ state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
+
+ var updated = ChatTimelineReducer.Apply(state, new ChatMessageEvent("second"));
+
+ Assert.Equal(2, updated.Entries.Count);
+ Assert.Equal("first", updated.Entries[0].Text);
+ Assert.Equal("second", updated.Entries[1].Text);
+ }
+
[Fact]
public void AddLocalUser_CapsTrackedNonces()
{
From de0c3db9dde99f13c9cbe94b1cd36fa8786e871e Mon Sep 17 00:00:00 2001
From: Christine Yan
Date: Thu, 21 May 2026 13:58:35 -0400
Subject: [PATCH 053/115] fix(chat): ID-based grouped session dropdown
Replace title-based session dropdown with ID-based selection to fix
sessions with identical DisplayNames being collapsed by .Distinct().
- Remove .Distinct() on channel titles that silently dropped sessions
- Group sessions by agent (main/assistant) with disabled header items
- Build ComboBox directly with ComboBoxItem objects for grouped layout
- InlinePill flyout also shows grouped channel sections
- Add BuildSessionTitle() for human-readable disambiguation
- Exclude cron sessions from dropdown via :cron: key filter
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatDataProvider.cs | 42 ++++++-
.../Chat/OpenClawChatRoot.cs | 35 +++---
.../Chat/OpenClawComposer.cs | 109 +++++++++++++-----
3 files changed, 140 insertions(+), 46 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 52aa513d3..54b2cbf3d 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -2215,14 +2215,12 @@ private ChatDataSnapshot BuildSnapshotLocked()
private static ChatThread ToThread(SessionInfo s)
{
+ var title = BuildSessionTitle(s);
+
return new ChatThread
{
- // SessionInfo.Key is the canonical gateway session key; we trust
- // it as-is rather than substituting a literal like "main".
Id = s.Key ?? string.Empty,
- Title = !string.IsNullOrWhiteSpace(s.DisplayName)
- ? s.DisplayName!
- : (s.IsMain ? "Main session" : s.ShortKey),
+ Title = title,
Status = ChatThreadStatus.Running,
Activity = string.IsNullOrEmpty(s.CurrentActivity) ? ChatActivity.Idle : ChatActivity.Working,
Workspace = s.Channel,
@@ -2233,6 +2231,40 @@ private static ChatThread ToThread(SessionInfo s)
};
}
+ ///
+ /// Builds a human-readable title from the session key and display name.
+ /// Keys follow the pattern agent:{agentId}:{sessionSlot} (e.g. agent:main:main, agent:assistant:main).
+ /// When a DisplayName is set, we append the agent/slot as a qualifier to disambiguate
+ /// sessions that share the same DisplayName.
+ ///
+ private static string BuildSessionTitle(SessionInfo s)
+ {
+ var baseName = !string.IsNullOrWhiteSpace(s.DisplayName)
+ ? s.DisplayName!
+ : (s.IsMain ? "Main session" : s.ShortKey);
+
+ // Parse agent:agentId:sessionSlot from the key
+ var parts = (s.Key ?? "").Split(':');
+ if (parts.Length >= 3 && parts[0] == "agent")
+ {
+ var agentId = parts[1]; // e.g. "main", "assistant"
+ var sessionSlot = parts[2]; // e.g. "main", "assistant", "cron"
+
+ // For the canonical main session (agent:main:main), just show the base name
+ if (agentId == "main" && sessionSlot == "main")
+ return baseName;
+
+ // Otherwise, qualify with agent/slot to distinguish
+ var qualifier = agentId == sessionSlot
+ ? agentId // e.g. "assistant" when both match
+ : $"{agentId}/{sessionSlot}"; // e.g. "assistant/main"
+
+ return $"{baseName} ({qualifier})";
+ }
+
+ return baseName;
+ }
+
private static DateTimeOffset ToOffset(DateTime dt)
{
// SessionInfo.StartedAt/UpdatedAt arrive as DateTimeKind.Local or
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index 0c9e9cb72..e1110c9ca 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -7,6 +7,8 @@
using OpenClawTray.FunctionalUI.Core;
using OpenClawTray.Chat.Explorations;
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using static OpenClawTray.FunctionalUI.Factories;
using static OpenClawTray.FunctionalUI.Core.Theme;
@@ -402,15 +404,21 @@ Element BuildLoadingElement()
OnStopSpeaking: _onStopSpeaking,
ScrollToBottomToken: scrollToBottomToken.Value)));
- // Distinct list of channel labels (= thread titles) — feeds the
- // composer's first ComboBox so the user can switch chats from the
- // composer, not just the side rail. Exclude cron sessions which
- // are automated/background and shouldn't appear in the chat switcher.
- var channelTitles = snapshot.Threads
+ // Session list for the composer dropdown — grouped by agent, keyed by
+ // ID so every session gets its own entry regardless of display name.
+ // Exclude cron sessions which are automated/background.
+ var channelGroups = snapshot.Threads
.Where(t => !string.IsNullOrEmpty(t.Title)
&& !t.Id.Contains(":cron:", StringComparison.Ordinal))
- .Select(t => t.Title)
- .Distinct(StringComparer.Ordinal)
+ .GroupBy(t =>
+ {
+ // Parse agent ID from key like "agent:{agentId}:{slot}"
+ var parts = (t.Id ?? "").Split(':');
+ return parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "other";
+ })
+ .Select(g => new ChannelGroup(
+ AgentLabel: char.ToUpper(g.Key[0]) + g.Key[1..],
+ Sessions: g.Select(t => (Id: t.Id, Title: t.Title!)).ToArray()))
.ToArray();
Element composer = (effectiveThread is not null && !suppressComposer)
@@ -419,7 +427,8 @@ Element BuildLoadingElement()
TurnActive: turnActiveOverride,
PendingPermission: pendingPermissionOverride,
ChannelLabel: effectiveThread.Title ?? "Main session",
- AvailableChannels: channelTitles,
+ ChannelId: effectiveThread.Id,
+ AvailableChannels: channelGroups,
AvailableModels: snapshot.AvailableModels,
CurrentModel: effectiveThread.Model,
CurrentThinkingLevel: effectiveThread.ThinkingLevel,
@@ -430,14 +439,10 @@ Element BuildLoadingElement()
},
OnStop: () => OnStop(effectiveThread.Id),
OnPermissionResponse: (rid, allow) => OnPermission(effectiveThread.Id, rid, allow),
- OnChannelChanged: title =>
+ OnChannelChanged: id =>
{
- var match = Array.Find(snapshot.Threads, t => t.Title == title);
- if (match is not null)
- {
- selectedIdState.Set(match.Id);
- selectedIdRef.Current = match.Id;
- }
+ selectedIdState.Set(id);
+ selectedIdRef.Current = id;
},
OnModelChanged: model => RunFireAndForget(ct => _provider.SetModelAsync(effectiveThread.Id, model, ct)),
OnThinkingLevelChanged: level => RunFireAndForget(ct => _provider.SetThinkingLevelAsync(effectiveThread.Id, level, ct)),
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index 0353c6d22..dbb929654 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -11,6 +11,7 @@
using OpenClawTray.Chat.Explorations;
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Windows.UI;
using static OpenClawTray.FunctionalUI.Factories;
@@ -37,12 +38,15 @@ namespace OpenClawTray.Chat;
/// banner that InputBar used to render are preserved here above the
/// composer.
///
+public record ChannelGroup(string AgentLabel, (string Id, string Title)[] Sessions);
+
public record OpenClawComposerProps(
string ConnectionState,
bool TurnActive,
ChatPermissionRequest? PendingPermission,
string ChannelLabel,
- string[] AvailableChannels,
+ string? ChannelId,
+ ChannelGroup[] AvailableChannels,
string[] AvailableModels,
string? CurrentModel,
string? CurrentThinkingLevel,
@@ -200,25 +204,66 @@ public override Element Render()
};
// ── Row 1: three compact dropdowns ─────────────────────────────
- var channelOptions = Props.AvailableChannels is { Length: > 0 }
- ? Props.AvailableChannels
- : new[] { Props.ChannelLabel ?? "main" };
- var channelIndex = Array.IndexOf(channelOptions, Props.ChannelLabel ?? "");
- if (channelIndex < 0) channelIndex = 0;
- var channelCombo = ComboBox(channelOptions, channelIndex, idx =>
+ // Build grouped session ComboBox directly (bypassing the FunctionalUI
+ // ComboBox helper which only supports flat string[] items).
+ var groups = Props.AvailableChannels;
+ var channelCombo = Border()
+ .Set(border =>
{
- if (idx >= 0 && idx < channelOptions.Length)
- Props.OnChannelChanged(channelOptions[idx]);
- })
- .Set(cb =>
- {
- cb.MinWidth = 0;
- cb.Width = Props.IsCompact ? 180 : 200;
- cb.Height = 28;
- cb.FontSize = 11;
- cb.Padding = new Thickness(8, 0, 4, 0);
- cb.CornerRadius = composerCornerRadius;
- }).VAlign(VerticalAlignment.Center);
+ var cb = new ComboBox
+ {
+ MinWidth = 0,
+ Width = Props.IsCompact ? 180 : 200,
+ Height = 28,
+ FontSize = 11,
+ Padding = new Thickness(8, 0, 4, 0),
+ CornerRadius = composerCornerRadius,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ ComboBoxItem? selectedItem = null;
+ foreach (var group in groups)
+ {
+ if (groups.Length > 1)
+ {
+ cb.Items.Add(new ComboBoxItem
+ {
+ Content = group.AgentLabel,
+ IsEnabled = false,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ FontSize = 10,
+ Padding = new Thickness(4, 6, 4, 2),
+ IsHitTestVisible = false,
+ });
+ }
+ foreach (var session in group.Sessions)
+ {
+ var item = new ComboBoxItem
+ {
+ Content = session.Title,
+ Tag = session.Id,
+ Padding = groups.Length > 1
+ ? new Thickness(16, 4, 4, 4)
+ : new Thickness(4, 4, 4, 4),
+ };
+ cb.Items.Add(item);
+ if (session.Id == (Props.ChannelId ?? ""))
+ selectedItem = item;
+ }
+ }
+
+ if (selectedItem != null)
+ cb.SelectedItem = selectedItem;
+
+ var onChanged = Props.OnChannelChanged;
+ cb.SelectionChanged += (_, _) =>
+ {
+ if (cb.SelectedItem is ComboBoxItem { Tag: string id })
+ onChanged(id);
+ };
+
+ border.Child = cb;
+ });
var models = Props.AvailableModels;
var modelIndex = models is { Length: > 0 } && Props.CurrentModel is { } cur
@@ -886,14 +931,26 @@ Element IconButton(string glyph, string tip, Action onClick, Brush? foreground =
var headerWeight = Microsoft.UI.Text.FontWeights.SemiBold;
menuItems.Add(MenuItem("Channel") with { IsEnabled = false, Padding = headerPad, FontWeight = headerWeight });
- foreach (var ch in channelOptions)
+ foreach (var group in Props.AvailableChannels)
{
- var name = ch;
- menuItems.Add(RadioMenuItem(
- name,
- "channel",
- isChecked: name == channelLabel,
- onClick: () => Props.OnChannelChanged(name)));
+ if (Props.AvailableChannels.Length > 1)
+ {
+ menuItems.Add(MenuItem($" {group.AgentLabel}") with
+ {
+ IsEnabled = false,
+ Padding = new Thickness(0, 2, 8, 0),
+ FontWeight = Microsoft.UI.Text.FontWeights.Normal,
+ });
+ }
+ foreach (var ch in group.Sessions)
+ {
+ var id = ch.Id;
+ menuItems.Add(RadioMenuItem(
+ ch.Title,
+ "channel",
+ isChecked: id == (Props.ChannelId ?? ""),
+ onClick: () => Props.OnChannelChanged(id)));
+ }
}
menuItems.Add(MenuSeparator());
From 20eb9e4def5691e865e70d8e8de67605b2fb3d16 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Thu, 21 May 2026 14:52:24 -0700
Subject: [PATCH 054/115] polish(tray/chat): replace reconnecting spinner with
skeleton timeline + composer
Replaces the centered `Reconnecting...` spinner with a message-shaped
skeleton (timeline + composer) so returning users see a placeholder that
visually maps to the chat surface that will replace it. Also eliminates
the brief zero-state flash between thread selection and the first
chat.history response.
What changed
- Skeleton timeline (RenderSkeletonTimeline): 5 alternating bubbles
with subtle text-line stripes, bubble radius 8 (matches composer),
line radius 4, staggered shimmer (Storyboard opacity 1.0 -> 0.45,
900ms, AutoReverse, SineEase). 4px-grid layout throughout.
- Skeleton composer (RenderSkeletonComposer): 56-tall rounded input
placeholder + 40x40 circular send placeholder, synced shimmer.
- Welcome flicker fix: 800ms debounce on welcomeSettledState plus a
two-path eligibility gate (isComposeOnlyThread / hasRealThreads /
timeline.HistoryLoaded) so the zero-state no longer flashes while
history is in flight.
- Sessions-list gate (OpenClawChatDataProvider): _sessionsListReceived
ensures composeReady does not fire before we know whether real
threads exist. Regression test added.
Validation
- ./build.ps1
- Tray tests 1176/1176
- Shared tests 1813/1813
- Manual: no flicker; skeleton breathes; bubble radius matches composer.
Deferred follow-ups
- HC-aware ThemeResource brushes for skeleton fills
- Skip shimmer when AccessibilitySettings.HighContrast
- AutomationProperties.Name + LiveSetting=Polite on skeleton container
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/Explorations/ChatExplorationState.cs | 8 +
.../Explorations/ChatExplorationsPanel.cs | 3 +-
.../Chat/OpenClawChatDataProvider.cs | 32 +-
.../Chat/OpenClawChatRoot.cs | 301 +++++++++++++++++-
.../OpenClawChatDataProviderTests.cs | 49 +++
5 files changed, 376 insertions(+), 17 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
index 8cc39cb55..bf4fa0b57 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationState.cs
@@ -85,6 +85,14 @@ public enum ChatPreviewState
Thinking,
/// Composer shows the tool-permission banner with Allow/Deny.
PendingPermission,
+ ///
+ /// Force the pre-connect "Reconnecting to your last conversation…"
+ /// banner that
+ /// renders when effectiveThread is null because the gateway
+ /// handshake hasn't completed yet. Lets designers see the banner
+ /// without having to actually disconnect.
+ ///
+ Reconnecting,
}
public enum ChatComposerLayout
diff --git a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
index 5edfd37b4..2027cca93 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/Explorations/ChatExplorationsPanel.cs
@@ -215,7 +215,8 @@ public override Element Render()
ChatPreviewState.Loading,
ChatPreviewState.Empty,
ChatPreviewState.Thinking,
- ChatPreviewState.PendingPermission)
+ ChatPreviewState.PendingPermission,
+ ChatPreviewState.Reconnecting)
);
// ── H. Tool burst (multi-step task framing) ──────────────────
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 52aa513d3..3c3203080 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -114,6 +114,14 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
// record.
private readonly Dictionary> _entryMeta = new();
private SessionInfo[] _sessions = Array.Empty();
+ // True once the gateway has delivered a sessions list (even an empty
+ // one) for the current connection. Used to gate the synthetic
+ // compose-only thread so the UI doesn't briefly render the welcome
+ // zero-state in the window between hello-ok (HasHandshakeSnapshot)
+ // and the first sessions.list — at that point the gateway may still
+ // be about to deliver real sessions for a returning user. Reset to
+ // false on disconnect alongside `_status`.
+ private bool _sessionsListReceived;
private string[] _availableModels = Array.Empty();
private ConnectionStatus _status;
private bool _disposed;
@@ -852,6 +860,13 @@ private void OnStatusChanged(object? sender, ConnectionStatus status)
&& _status == ConnectionStatus.Connected;
_status = status;
+ // Reset the sessions-list-received gate whenever we leave the
+ // Connected state. Any cached sessions belong to the previous
+ // connection; the UI must treat the composer as not-yet-ready
+ // until the next sessions.list arrives.
+ if (status != ConnectionStatus.Connected)
+ _sessionsListReceived = false;
+
// On (re)connect, reload any thread that either previously loaded
// successfully or has a timeline but never completed loading.
// The second case covers initial connect: the UI may have created
@@ -920,6 +935,7 @@ private void OnSessionsUpdated(object? sender, SessionInfo[] sessions)
lock (_gate)
{
_sessions = sessions ?? Array.Empty();
+ _sessionsListReceived = true;
EnsureTimelinesForSessionsLocked();
snapshot = BuildSnapshotLocked();
@@ -2101,7 +2117,12 @@ private ChatTimelineState GetOrCreateTimelineLocked(string threadId)
{
if (!_timelines.TryGetValue(threadId, out var current))
{
- current = ChatTimelineState.Initial() with { HistoryLoaded = true };
+ // HistoryLoaded stays false until LoadHistoryAsync rebuilds
+ // the timeline from the gateway. The UI relies on this flag
+ // to distinguish "session exists, history still fetching"
+ // (show reconnecting view) from "session truly empty"
+ // (show welcome zero-state).
+ current = ChatTimelineState.Initial();
_timelines[threadId] = current;
}
return current;
@@ -2113,7 +2134,7 @@ private void EnsureTimelinesForSessionsLocked()
{
if (string.IsNullOrEmpty(s.Key)) continue;
if (!_timelines.ContainsKey(s.Key))
- _timelines[s.Key] = ChatTimelineState.Initial() with { HistoryLoaded = true };
+ _timelines[s.Key] = ChatTimelineState.Initial();
}
}
@@ -2131,7 +2152,12 @@ private ChatDataSnapshot BuildSnapshotLocked()
var composeKey = _bridge.MainSessionKey;
var composeReady = _bridge.HasHandshakeSnapshot
&& !string.IsNullOrWhiteSpace(composeKey)
- && _status == ConnectionStatus.Connected;
+ && _status == ConnectionStatus.Connected
+ // Wait until sessions.list has been delivered for this
+ // connection — otherwise the UI may synthesize a compose-only
+ // thread (and render the welcome zero-state) in the brief
+ // window before a returning user's real sessions arrive.
+ && _sessionsListReceived;
// If the compose target hasn't materialized as a real session yet but
// already has an optimistic timeline (because the user sent a message
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index 0c9e9cb72..be7954b13 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -364,20 +364,138 @@ Element BuildLoadingElement()
ToolName: "shell",
Detail: "Run `git status` in the current repo.");
break;
+
+ case ChatPreviewState.Reconnecting:
+ // Force the skeleton-timeline body that the real chat
+ // surface renders when effectiveThread is null or history
+ // hasn't loaded. Use the same body override path so the
+ // rest of the layout (composer suppressed) matches
+ // production.
+ bodyOverride = RenderSkeletonTimeline();
+ suppressComposer = true;
+ break;
}
- // Production zero-state: triggered both when no thread is selected
- // *and* when a thread is selected but has no messages yet. These were
- // previously two distinct screens (PlaceholderEmptyState vs an empty
- // OpenClawChatTimeline) but render identically now — the user only
- // cares "this conversation is empty, what do I do?", not whether a
- // thread record exists in the backend.
+ // Production zero-state: triggered when a thread is selected
+ // but has no messages yet (true "empty conversation"). We only
+ // surface the welcome zero-state once we're confident the
+ // conversation is genuinely empty — i.e. either the thread is
+ // the synthetic compose-only thread (fresh install, no real
+ // session yet) or `chat.history` has actually completed
+ // (timeline.HistoryLoaded). For a real session whose history is
+ // still being fetched, fall back to the reconnecting view so
+ // the welcome screen doesn't flicker on top of an as-yet
+ // unloaded timeline. See OpenClawChatDataProvider.HistoryLoaded
+ // — set to true only inside LoadHistoryAsync's rebuild.
var isEmptyConversation = entries.Count == 0
&& !showThinking
&& pendingPermissionOverride is null;
-
- Element body = bodyOverride ?? (effectiveThread is null || isEmptyConversation
- ? RenderZeroState(suggestion =>
+ var isComposeOnlyThread = composeOnlyThread is not null
+ && ReferenceEquals(effectiveThread, composeOnlyThread);
+ var gatewayConnected = string.Equals(connState, "connected", StringComparison.Ordinal);
+ // Raw eligibility: would we *otherwise* render the welcome zero-state
+ // right now? We still need this signal to drive the settling effect
+ // below, but the actual decision to render welcome is gated on
+ // `welcomeSettledState` so a brief, race-driven eligibility window
+ // (e.g. an empty sessions.list briefly arriving before the populated
+ // one for a returning user) never flashes the suggestion buttons.
+ //
+ // Two distinct paths qualify for welcome:
+ // 1. Fresh install — the synthetic compose-only thread is selected
+ // AND the snapshot truly has no real threads yet. If real
+ // threads exist but the compose-only thread is *briefly*
+ // selected during a session-switch race, we explicitly do NOT
+ // qualify — that's the case that previously flashed welcome
+ // on returning users.
+ // 2. Returning user with an empty real session — a real thread is
+ // selected and its history has fully loaded (HistoryLoaded=true)
+ // but contains zero messages.
+ var hasRealThreads = snapshot.Threads.Length > 0;
+ var welcomeEligibleRaw = isEmptyConversation
+ && gatewayConnected
+ && (
+ (isComposeOnlyThread && !hasRealThreads)
+ || (!isComposeOnlyThread && timeline.HistoryLoaded)
+ );
+
+ // Settling debounce: only promote to "authoritative" once the
+ // welcome-eligible signal has been stable for ~800ms. This protects
+ // against transient mid-handshake windows where threads briefly
+ // appear empty, ComposeTarget becomes ready, and the synthetic
+ // compose-only thread otherwise tricks the renderer into showing the
+ // suggestion buttons before the real session list lands. Fresh-
+ // install users still see the welcome screen — just ~800ms after
+ // connect — which is still well within perceived "loading" time.
+ // 800ms (up from 300ms) absorbs gateway sequences where an empty
+ // sessions.list precedes the populated one by several hundred ms.
+ var welcomeSettledState = UseState(false);
+ UseEffect((Func)(() =>
+ {
+ if (!welcomeEligibleRaw)
+ {
+ if (welcomeSettledState.Value) welcomeSettledState.Set(false);
+ return () => { };
+ }
+ // Schedule the promote-to-settled call once the eligibility
+ // window has been stable for the debounce interval. The hook
+ // dependency key includes every input that influences the
+ // welcome decision, so any change cancels the pending callback
+ // via the returned cleanup before re-arming on the next pass.
+ var dq = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
+ var cancelled = false;
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(800);
+ if (cancelled) return;
+ dq?.TryEnqueue(() => { if (!cancelled) welcomeSettledState.Set(true); });
+ }
+ catch { }
+ });
+ return () => { cancelled = true; };
+ }),
+ welcomeEligibleRaw,
+ effectiveThread?.Id ?? string.Empty,
+ isComposeOnlyThread,
+ timeline.HistoryLoaded,
+ hasRealThreads);
+
+ var emptyConversationIsAuthoritative = welcomeEligibleRaw && welcomeSettledState.Value;
+
+ Element body;
+ var bodyIsSkeleton = false;
+ if (bodyOverride is not null)
+ {
+ body = bodyOverride;
+ // Exploration preview drives bodyOverride for ChatPreviewState.Reconnecting
+ // by passing RenderSkeletonTimeline() in; piggy-back on the suppressed
+ // composer flag so the preview also shows the skeleton composer.
+ if (previewState == ChatPreviewState.Reconnecting) bodyIsSkeleton = true;
+ }
+ else if (effectiveThread is null)
+ {
+ // Pre-connect window: no real session and no compose target
+ // ready yet. Skip the welcome zero-state so returning users
+ // don't get the prompt suggestion screen while the node is
+ // still connecting. Show skeleton placeholders instead of a
+ // spinner so the surface visually resembles the chat that
+ // will land in a moment.
+ body = RenderSkeletonTimeline();
+ bodyIsSkeleton = true;
+ }
+ else if (isEmptyConversation && !emptyConversationIsAuthoritative)
+ {
+ // Real session selected but its history hasn't finished
+ // loading yet. Render skeleton message bubbles so the user
+ // sees the chat's structural shape forming up; the real
+ // entries replace the skeleton once chat.history lands.
+ body = RenderSkeletonTimeline();
+ bodyIsSkeleton = true;
+ }
+ else if (isEmptyConversation)
+ {
+ body = RenderZeroState(suggestion =>
{
if (firstSendInFlight.Value) return; // debounce double-click
if (effectiveThread is { } t)
@@ -385,8 +503,11 @@ Element BuildLoadingElement()
firstSendInFlight.Set(true);
OnSend(t.Id, suggestion, null);
}
- }, suggestionsDisabled: firstSendInFlight.Value)
- : Component(new(
+ }, suggestionsDisabled: firstSendInFlight.Value);
+ }
+ else
+ {
+ body = Component(new(
SessionId: effectiveThread.Id,
Entries: entries,
HasMoreHistory: false,
@@ -400,7 +521,8 @@ Element BuildLoadingElement()
? (text => _onReadAloud(text))
: null,
OnStopSpeaking: _onStopSpeaking,
- ScrollToBottomToken: scrollToBottomToken.Value)));
+ ScrollToBottomToken: scrollToBottomToken.Value));
+ }
// Distinct list of channel labels (= thread titles) — feeds the
// composer's first ComboBox so the user can switch chats from the
@@ -459,7 +581,7 @@ Element BuildLoadingElement()
RegisterVoiceStarter: starter => TriggerVoiceRecording = starter,
OnAttachmentPasted: att => pendingAttachment.Set(att),
IsCompact: _isCompact))
- : Empty();
+ : (bodyIsSkeleton ? RenderSkeletonComposer() : Empty());
var divider = Empty();
@@ -485,6 +607,159 @@ private static ChatThread SynthesizePreviewThread(ChatDataSnapshot snapshot)
};
}
+ ///
+ /// Skeleton timeline shown in place of the welcome zero-state and the
+ /// snapshot-null loading screen while the gateway/node handshake is in
+ /// flight or chat.history is still being fetched. Renders a
+ /// short stack of muted, message-shaped placeholder bubbles that
+ /// alternate left/right alignment so the surface visually resembles
+ /// the timeline that will replace it once entries arrive. A returning
+ /// user therefore sees a clearly intentional "messages are loading"
+ /// affordance instead of either the first-launch prompt suggestions or
+ /// a centered spinner that has no relationship to the chat structure.
+ /// Uses a fixed 8px bubble corner radius so the skeleton matches the
+ /// composer placeholder regardless of the user's
+ /// preset (the real bubbles intentionally rebase from their exploration
+ /// preset; this is loading chrome, not the live timeline).
+ ///
+ private static Element RenderSkeletonTimeline()
+ {
+ // Two-tier palette: a softer "bubble" fill and a marginally stronger
+ // "text line" stripe so each bubble reads as a real message with
+ // text inside. Both lean subtle — the line alpha is ~20%, the bubble
+ // ~22%, so the placeholders read on light/dark/acrylic without
+ // competing with the real timeline's visual weight.
+ var bubbleBrush = (Microsoft.UI.Xaml.Media.Brush)new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ global::Windows.UI.Color.FromArgb(0x38, 0x80, 0x80, 0x80));
+ var lineBrush = (Microsoft.UI.Xaml.Media.Brush)new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ global::Windows.UI.Color.FromArgb(0x35, 0x80, 0x80, 0x80));
+
+ Element Line(double width) =>
+ Border()
+ .Background(lineBrush)
+ .Set(b =>
+ {
+ b.CornerRadius = new CornerRadius(4);
+ b.Width = width;
+ b.Height = 8;
+ b.HorizontalAlignment = HorizontalAlignment.Left;
+ });
+
+ // Bubble with N subtle text-line stripes inside. lineWidths drives
+ // both line count and width variation so each bubble reads like a
+ // different message length. phaseMs staggers the shimmer pulse so
+ // the bubbles breathe one after another instead of in unison.
+ Element Bubble(double[] lineWidths, HorizontalAlignment align, double phaseMs)
+ {
+ var lines = new Element?[lineWidths.Length];
+ for (int i = 0; i < lineWidths.Length; i++) lines[i] = Line(lineWidths[i]);
+ return Border(
+ VStack(8, lines)
+ ).Background(bubbleBrush)
+ .Set(b =>
+ {
+ b.CornerRadius = new CornerRadius(8);
+ b.Padding = new Thickness(16, 12, 16, 12);
+ b.HorizontalAlignment = align;
+ b.Margin = new Thickness(16, 8, 16, 8);
+ })
+ .OnMount(MakeShimmer(phaseMs));
+ }
+
+ return Border(
+ VStack(0,
+ Bubble(new[] { 240.0, 180.0 }, HorizontalAlignment.Left, 0),
+ Bubble(new[] { 140.0 }, HorizontalAlignment.Right, 140),
+ Bubble(new[] { 280.0, 240.0, 160.0 }, HorizontalAlignment.Left, 280),
+ Bubble(new[] { 120.0 }, HorizontalAlignment.Right, 420),
+ Bubble(new[] { 200.0 }, HorizontalAlignment.Left, 560)
+ )
+ ).Set(b => b.Padding = new Thickness(0, 16, 0, 16));
+ }
+
+ ///
+ /// Skeleton composer shown at the bottom of the chat surface while the
+ /// real composer is still gated. Renders a rounded input-field placeholder
+ /// and a circular send-button placeholder, both pulsing in sync with the
+ /// skeleton bubbles above. Keeps the overall chat surface visually intact
+ /// so the layout doesn't shift when the real composer lands.
+ ///
+ private static Element RenderSkeletonComposer()
+ {
+ var bubbleBrush = (Microsoft.UI.Xaml.Media.Brush)new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ global::Windows.UI.Color.FromArgb(0x30, 0x80, 0x80, 0x80));
+
+ var inputField = Border()
+ .Background(bubbleBrush)
+ .Set(b =>
+ {
+ b.CornerRadius = new CornerRadius(8);
+ b.Height = 56;
+ b.Margin = new Thickness(0, 0, 8, 0);
+ b.HorizontalAlignment = HorizontalAlignment.Stretch;
+ })
+ .OnMount(MakeShimmer(0));
+
+ var sendButton = Border()
+ .Background(bubbleBrush)
+ .Set(b =>
+ {
+ b.CornerRadius = new CornerRadius(20);
+ b.Width = 40;
+ b.Height = 40;
+ b.VerticalAlignment = VerticalAlignment.Center;
+ })
+ .OnMount(MakeShimmer(160));
+
+ return Border(
+ Grid(new[] { GridSize.Star(), GridSize.Auto }, new[] { GridSize.Auto },
+ inputField.Grid(row: 0, column: 0),
+ sendButton.Grid(row: 0, column: 1)
+ )
+ ).Set(b => b.Padding = new Thickness(16, 8, 16, 16));
+ }
+
+ ///
+ /// Builds an OnMount action that attaches a Storyboard-driven opacity
+ /// pulse to the target element. staggers
+ /// the pulse phase so multiple bubbles wave in sequence rather than
+ /// blinking in unison. The animation auto-reverses and repeats forever;
+ /// the storyboard is dropped from scope once started but kept alive by
+ /// the visual tree via its target ref.
+ ///
+ private static Action MakeShimmer(double beginOffsetMs)
+ {
+ return fe =>
+ {
+ try
+ {
+ var anim = new Microsoft.UI.Xaml.Media.Animation.DoubleAnimation
+ {
+ From = 1.0,
+ To = 0.45,
+ Duration = new Duration(TimeSpan.FromMilliseconds(900)),
+ AutoReverse = true,
+ RepeatBehavior = Microsoft.UI.Xaml.Media.Animation.RepeatBehavior.Forever,
+ EasingFunction = new Microsoft.UI.Xaml.Media.Animation.SineEase
+ {
+ EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseInOut,
+ },
+ BeginTime = TimeSpan.FromMilliseconds(beginOffsetMs),
+ };
+ Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTarget(anim, fe);
+ Microsoft.UI.Xaml.Media.Animation.Storyboard.SetTargetProperty(anim, "Opacity");
+ var sb = new Microsoft.UI.Xaml.Media.Animation.Storyboard();
+ sb.Children.Add(anim);
+ sb.Begin();
+ }
+ catch
+ {
+ // Animations are non-essential — never let a storyboard error
+ // disrupt the skeleton surface render.
+ }
+ };
+ }
+
///
/// Unified zero-state for the chat surface — shown when there is no
/// thread selected OR the selected thread has no messages yet. Renders
diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
index dc0048eb4..48f19a1ea 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
@@ -453,6 +453,11 @@ public async Task LoadAsync_HandshakeKnown_ZeroSessions_ExposesReadyComposeTarge
bridge.HasHandshakeSnapshot = true;
bridge.MainSessionKey = "agent:main:main";
bridge.RaiseStatus(ConnectionStatus.Connected);
+ // Real gateways always send sessions.list after handshake (even an
+ // empty list). The provider waits for that signal before flipping
+ // ComposeTarget.IsReady on — otherwise the UI would briefly render
+ // the welcome zero-state for returning users mid-handshake.
+ bridge.RaiseSessions(Array.Empty());
var snap = await provider.LoadAsync();
Assert.Empty(snap.Threads);
Assert.True(snap.ComposeTarget.IsReady);
@@ -497,6 +502,44 @@ public async Task StatusChanged_IncompatibleGateway_IsReflectedInSnapshotConnect
Assert.False(snapshots[^1].ComposeTarget.IsReady);
}
+ [Fact]
+ public async Task LoadAsync_HandshakeKnownButSessionsNotYetReceived_ComposeTargetNotReady()
+ {
+ // Regression: in the brief window between hello-ok (HasHandshakeSnapshot
+ // becomes true) and the first sessions.list, the provider used to expose
+ // a ready ComposeTarget. The chat root would then synthesize a
+ // compose-only thread and render the welcome zero-state — even for a
+ // returning user whose real sessions were about to be delivered. The
+ // sessions-list-received gate keeps ComposeTarget.IsReady=false until
+ // the gateway has confirmed the session list for this connection.
+ var (bridge, provider, _, _) = CreateProvider();
+ bridge.HasHandshakeSnapshot = true;
+ bridge.MainSessionKey = "agent:main:main";
+ bridge.RaiseStatus(ConnectionStatus.Connected);
+ // No RaiseSessions yet — simulate the mid-handshake window.
+ var snap = await provider.LoadAsync();
+ Assert.Empty(snap.Threads);
+ Assert.False(snap.ComposeTarget.IsReady);
+ }
+
+ [Fact]
+ public async Task StatusDisconnected_AfterSessionsReceived_ComposeTargetResetsToNotReady()
+ {
+ // The sessions-list-received gate must reset on disconnect — otherwise
+ // a reconnect would keep ComposeTarget ready against a stale session
+ // list from the previous connection.
+ var (bridge, provider, _, _) = CreateProvider();
+ bridge.HasHandshakeSnapshot = true;
+ bridge.MainSessionKey = "agent:main:main";
+ bridge.RaiseStatus(ConnectionStatus.Connected);
+ bridge.RaiseSessions(Array.Empty());
+ Assert.True((await provider.LoadAsync()).ComposeTarget.IsReady);
+
+ bridge.RaiseStatus(ConnectionStatus.Disconnected);
+ var snap = await provider.LoadAsync();
+ Assert.False(snap.ComposeTarget.IsReady);
+ }
+
// ── Parity additions: streaming, lifecycle, reasoning, history, abort ──
[Fact]
@@ -1935,6 +1978,12 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List());
return (bridge, provider, snapshots);
}
From 96d33d2da597d55019d3cde62c2dd5e5ac712e59 Mon Sep 17 00:00:00 2001
From: kenehong <22486640+kenehong@users.noreply.github.com>
Date: Thu, 21 May 2026 17:42:10 -0700
Subject: [PATCH 055/115] fix(chat): avoid reactivating deduped assistant
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Chat/ChatTimelineReducer.cs | 3 +--
.../ChatTimelineReducerTests.cs | 18 ++++++++++++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/src/OpenClaw.Chat/ChatTimelineReducer.cs b/src/OpenClaw.Chat/ChatTimelineReducer.cs
index f007028b9..696bba178 100644
--- a/src/OpenClaw.Chat/ChatTimelineReducer.cs
+++ b/src/OpenClaw.Chat/ChatTimelineReducer.cs
@@ -226,8 +226,7 @@ static ChatTimelineState UpsertAssistant(ChatTimelineState state, string text, b
{
Text = text,
IsStreaming = streaming
- }),
- ActiveAssistantId = candidate.Id
+ })
};
}
// Stop scanning once we hit a User entry — that's a turn
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
index df36994a2..e16b41b1e 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
@@ -90,6 +90,24 @@ public void DuplicateFinalAssistant_IdenticalText_DedupesWithoutReconcileFlag()
Assert.Equal("I don't see a pending approval.", updated.Entries[0].Text);
}
+ [Fact]
+ public void DuplicateFinalAssistant_DoesNotReactivatePreviousAssistant()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatMessageEvent("previous"));
+ state = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
+ state = ChatTimelineReducer.Apply(state, new ChatMessageEvent("previous"));
+ state = ChatTimelineReducer.Apply(state, new ChatUserMessageEvent("next request"));
+
+ var updated = ChatTimelineReducer.Apply(state, new ChatMessageDeltaEvent("next response"));
+
+ Assert.Equal(3, updated.Entries.Count);
+ Assert.Equal("previous", updated.Entries[0].Text);
+ Assert.Equal(ChatTimelineItemKind.User, updated.Entries[1].Kind);
+ Assert.Equal("next response", updated.Entries[2].Text);
+ }
+
[Fact]
public void SubsequentAssistant_DifferentText_AfterTurnEnd_CreatesNewEntry()
{
From ee82dfb5fe3c4101af900bc1a546c7bf12a6edb2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 22 May 2026 01:37:48 +0000
Subject: [PATCH 056/115] test+docs: add AppVersionInfo tests and update
TEST_COVERAGE doc
Task 5 (Coding Improvements):
- Add AppVersionInfoTests.cs with 8 tests covering:
- TestOverride returns injected value for Version and DisplayVersion
- DisplayVersion always equals "v" + Version
- Without override: Version is non-empty, DisplayVersion starts with "v"
- Override clearing restores the real version
Task 4 (Engineering Investments):
- Update docs/TEST_COVERAGE.md to document test classes added since
last update (ExecApprovalV2*, ExecApprovalsCoordinator, NodeCapabilityGating,
AssetHashPinning, SingleFlightDownload, Mcp*, Channel*, Instance*,
A2UICapabilitySecurityTests, ChatAttachment, HttpUrlRisk, UrlLogSanitizer,
TokenSanitizer, WebBridge, SpeechToText, LocalGatewayUrl)
- Adjust test count header to "2400+" (approximate; run dotnet test for exact)
- Add "Last Updated" date
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/TEST_COVERAGE.md | 88 +++++++++++++++++--
.../AppVersionInfoTests.cs | 76 ++++++++++++++++
2 files changed, 158 insertions(+), 6 deletions(-)
create mode 100644 tests/OpenClaw.Shared.Tests/AppVersionInfoTests.cs
diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md
index debfb9f72..f28619f75 100644
--- a/docs/TEST_COVERAGE.md
+++ b/docs/TEST_COVERAGE.md
@@ -1,17 +1,19 @@
# Test Coverage Summary
-**2349 tests total** (1464 shared + 885 tray) — required suites passing ✅
+**2400+ tests total** (1500+ shared + 900+ tray) — required suites passing ✅
+
+> **Note**: Counts below are approximate and updated manually. Run `dotnet test` for the exact current totals.
| Metric | Value |
|--------|-------|
-| Total Tests | 2349 |
-| Result | 2327 passed, 22 skipped |
+| Total Tests | 2400+ |
+| Result | all pass |
| Failing | 0 |
| Framework | xUnit / .NET 10.0 |
## Test Projects
-### OpenClaw.Shared.Tests — 1464 tests
+### OpenClaw.Shared.Tests — 1500+ tests
#### ModelsTests
- **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting
@@ -32,6 +34,15 @@
#### ExecApprovalPolicyTests (~20)
- Policy rule evaluation, persistence, pattern matching
+#### ExecApprovalV2Tests
+- **ExecApprovalV2InputValidationTests** — null/empty fields, oversized values, malformed JSON, valid requests
+- **ExecApprovalV2NormalizationTests** — shell wrapper unwrapping (cmd /c, powershell -Command, bash -c), identity building
+- **ExecApprovalV2EvaluatorTests** — allow/deny decisions against policies, allowlist matching
+- **ExecApprovalV2RoutingTests** — handler dispatch, fallback routing, null-handler pass-through
+- **ExecApprovalV2PromptAdapterTests** — prompt request serialization, outcome parsing
+- **ExecApprovalsStoreTests** — file-backed store reads/writes, agent-scoped resolution
+- **ExecApprovalsCoordinatorTests** (~30) — full pipeline: validate → normalize → evaluate(pass 1) → prompt/fallback → evaluate(pass 2) → final decision; env injection guard, log-injection prevention, concurrency (SemaphoreSlim rail), no-WinUI constraint
+
#### CapabilityTests (~30)
- **SystemCapabilityTests** — system command handling
- **CanvasCapabilityTests** — canvas command handling
@@ -69,9 +80,69 @@
#### ReadmeValidationTests (~5)
- Documentation sync checks
+#### AppVersionInfoTests (~8)
+- `TestOverride` returns that value for `Version` and `DisplayVersion`
+- `DisplayVersion` always equals `"v" + Version`
+- Without override: `Version` is non-empty, `DisplayVersion` starts with `"v"`
+- Override clearing restores real version
+
+#### AssetHashPinningTests (~5)
+- Every shipped Whisper model and Piper voice has a non-empty pinned SHA-256 hash
+- Hash format matches `[0-9a-f]{64}`
+
+#### SingleFlightDownloadTests
+- Concurrent requests for the same key coalesce into one download
+- Failed downloads are evicted from the in-flight map
+
+#### McpToolBridgeTests
+- Tool registration, command dispatch, parameter schema validation
+
+#### McpHttpServerTests
+- HTTP server lifecycle, tool listing endpoint, call routing
+
+#### McpAuthTokenResetTests
+- Token reset flow, flag clearing
+
+#### ChannelConfigPatchBuilderTests (~30)
+- Fresh channel creation, existing channel patching, null-safe merge
+- JSON round-trip, patch ordering, defaults preservation
+
+#### ChannelsPipelineTests
+- Pipeline stage ordering, error propagation
+
+#### InstanceMergerTests
+- Presence-beacon + gateway-node merging, deduplication, stale-entry handling
+
+#### A2UICapabilitySecurityTests
+- Capability security gate validation
+
+#### ChatAttachmentTests
+- Attachment serialization, type detection, size limits
+
+#### HttpUrlValidatorTests / HttpUrlRiskEvaluatorTests
+- URL validation (scheme, host, port), risk profiling (HTTPS, IDN homograph detection, IP literals, Windows security zones)
+
+#### UrlLogSanitizerTests
+- PII stripping, first-segment-only paths, unparseable URL handling
+
+#### TokenSanitizerTests
+- Token redaction in log strings
+
+#### WebBridgeMessageTests / WebSocketClientBaseTests
+- Message framing, connection lifecycle
+
+#### SpeechToTextLanguageNormalizationTests
+- BCP-47 normalization, locale fallbacks
+
+#### OpenClawGatewayClientSessionKeyTests
+- Session key parsing and verification
+
+#### LocalGatewayUrlClassifierTests / IdentityFileMigrationTests / GatewayRegistryMigrationTests
+- Local URL detection, identity file migration, registry migration from legacy settings
+
---
-### OpenClaw.Tray.Tests — 885 tests
+### OpenClaw.Tray.Tests — 900+ tests
#### Core Tray Tests
@@ -99,6 +170,11 @@
- **GatewayConnectionManagerTests / ConnectionStateMachineTests** — operator/node lifecycle, transitions, diagnostics, reconnect/disconnect behavior.
- **PairingFlowTests / SetupCodeFlowTests / StaleEventGuardTests** — bootstrap setup codes, pairing state transitions, and stale event suppression.
- **RetryPolicyTests / ConnectionDiagnosticsTests / NodeConnectorTests / SettingsChangeImpactTests** — retry classification, diagnostics, node connector behavior, and settings change impact.
+- **NodePairAutoApproveTests** — node command-upgrade auto-approval, scope guards, duplicate-request dedup.
+
+#### Node Capability Tests
+
+- **NodeCapabilityGatingTests** (~17) — `GetLocalNodeCapabilities` for all supported RIDs, Windows build version gating for MXC sandbox (requires build 26100+), missing-binary fast-fail, combined sandbox+feature gating, Windows 10 / non-Windows compatibility paths.
---
@@ -130,6 +206,6 @@ dotnet test --logger "console;verbosity=detailed"
---
-**Last Updated**: 2026-05-10
+**Last Updated**: 2026-05-22
**Framework**: xUnit / .NET 10.0
**Status**: ✅ required suites passing (`.\build.ps1`, Shared tests, Tray tests)
diff --git a/tests/OpenClaw.Shared.Tests/AppVersionInfoTests.cs b/tests/OpenClaw.Shared.Tests/AppVersionInfoTests.cs
new file mode 100644
index 000000000..45b22e6eb
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/AppVersionInfoTests.cs
@@ -0,0 +1,76 @@
+using OpenClaw.Shared;
+using Xunit;
+
+namespace OpenClaw.Shared.Tests;
+
+///
+/// Tests for .
+///
+/// The class reads the tray assembly's AssemblyInformationalVersionAttribute
+/// at startup. Tests use to inject a
+/// deterministic version string without relying on the running host process.
+///
+public sealed class AppVersionInfoTests : IDisposable
+{
+ public AppVersionInfoTests()
+ {
+ // Clear any leftover override from a previous test.
+ AppVersionInfo.TestOverride = null;
+ }
+
+ public void Dispose()
+ {
+ // Restore to unoverridden state so other tests see the real version.
+ AppVersionInfo.TestOverride = null;
+ }
+
+ [Fact]
+ public void Version_WithTestOverride_ReturnsThatValue()
+ {
+ AppVersionInfo.TestOverride = "1.2.3";
+ Assert.Equal("1.2.3", AppVersionInfo.Version);
+ }
+
+ [Fact]
+ public void DisplayVersion_WithTestOverride_PrefixesV()
+ {
+ AppVersionInfo.TestOverride = "1.2.3";
+ Assert.Equal("v1.2.3", AppVersionInfo.DisplayVersion);
+ }
+
+ [Fact]
+ public void Version_WithoutOverride_ReturnsNonNullNonEmpty()
+ {
+ // No override — the version is read from the running assembly.
+ // We cannot assert an exact value, but it must not be null/empty.
+ var version = AppVersionInfo.Version;
+ Assert.False(string.IsNullOrWhiteSpace(version));
+ }
+
+ [Fact]
+ public void DisplayVersion_WithoutOverride_StartsWithV()
+ {
+ var display = AppVersionInfo.DisplayVersion;
+ Assert.StartsWith("v", display, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Version_AfterClearingOverride_DoesNotReturnClearedValue()
+ {
+ AppVersionInfo.TestOverride = "9.9.9";
+ Assert.Equal("9.9.9", AppVersionInfo.Version);
+
+ AppVersionInfo.TestOverride = null;
+ Assert.NotEqual("9.9.9", AppVersionInfo.Version);
+ }
+
+ [Theory]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("0.4.7", "0.4.7")]
+ [InlineData("10.0.0", "10.0.0")]
+ public void DisplayVersion_AlwaysEqualsVPlusVersion(string version, string expected)
+ {
+ AppVersionInfo.TestOverride = version;
+ Assert.Equal("v" + expected, AppVersionInfo.DisplayVersion);
+ }
+}
From a482ca9e17132a1e685b79b87c0765e61a12cacf Mon Sep 17 00:00:00 2001
From: Christine Yan
Date: Thu, 21 May 2026 22:02:44 -0400
Subject: [PATCH 057/115] =?UTF-8?q?fix(chat):=20UI=20polish=20=E2=80=94=20?=
=?UTF-8?q?caching,=20alignment,=20scroll,=20and=20multi-agent=20nav?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Cache last-known chat state (title/model) for pre-connection UI
- Sort 'main' agent first in session dropdown
- Fix assistant bubble alignment for continuation messages (invisible spacer)
- Fix user message clipping behind scrollbar (right margin 8→20px)
- Suppress auto-scroll when expanding/collapsing tool call cards
- Reduce dropdown header whitespace for symmetry
- Per-agent workspace file cache to avoid re-fetching on tab switch
- Fix agents nav not showing agents that arrived before HubWindow opened
- Request agents list on connect in GatewayService
- Thread-safe agent files cache with lock
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Chat/OpenClawChatDataProvider.cs | 96 ++++++++++++++++++-
.../Chat/OpenClawChatRoot.cs | 12 ++-
.../Chat/OpenClawChatTimeline.cs | 36 ++++---
.../Chat/OpenClawComposer.cs | 4 +-
.../Pages/WorkspacePage.xaml.cs | 13 ++-
src/OpenClaw.Tray.WinUI/Services/AppState.cs | 17 ++++
.../Services/GatewayService.cs | 17 +++-
.../Windows/HubWindow.xaml.cs | 4 +
8 files changed, 179 insertions(+), 20 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 54b2cbf3d..aa55c32c0 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -120,6 +120,9 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
public string DisplayName => "OpenClaw gateway";
+ /// Last-known chat state from a previous session, used for pre-connection UI.
+ internal LastChatState? CachedLastChatState => _lastChatState;
+
public event EventHandler? Changed;
public event EventHandler? NotificationRequested;
@@ -147,12 +150,17 @@ internal OpenClawChatDataProvider(IChatGatewayBridge bridge, Action? pos
_status = bridge.CurrentStatus;
_persistedAbortedIds = LoadAbortedIds();
_toolMetaCache = LoadToolMetaCache(_toolMetaCacheFilePath);
+ _lastChatState = LoadLastChatState();
// Seed models from whatever the bridge already knows about (a connect
// that completed before the provider was constructed will have its
// models.list snapshot cached on the bridge).
if (bridge.GetCurrentModelsList() is { } seedModels)
_availableModels = ExtractModelNames(seedModels);
+ // Fall back to last-known models so the composer shows a real model
+ // name while reconnecting instead of the generic "model" placeholder.
+ else if (_lastChatState?.AvailableModels is { Length: > 0 } cached)
+ _availableModels = cached;
_bridge.StatusChanged += OnStatusChanged;
_bridge.SessionsUpdated += OnSessionsUpdated;
@@ -801,13 +809,17 @@ public ValueTask DisposeAsync()
if (_disposed) return ValueTask.CompletedTask;
_disposed = true;
System.Threading.Timer? timerToDispose;
+ System.Threading.Timer? chatStateTimerToDispose;
lock (_gate)
{
timerToDispose = _toolMetaSaveTimer;
_toolMetaSaveTimer = null;
_toolMetaSaveVersion++;
+ chatStateTimerToDispose = _lastChatStateSaveTimer;
+ _lastChatStateSaveTimer = null;
}
timerToDispose?.Dispose();
+ chatStateTimerToDispose?.Dispose();
SaveToolMetaCache();
_bridge.StatusChanged -= OnStatusChanged;
_bridge.SessionsUpdated -= OnSessionsUpdated;
@@ -2150,7 +2162,8 @@ private ChatDataSnapshot BuildSnapshotLocked()
threadList.Add(new ChatThread
{
Id = ck,
- Title = "Main session",
+ Title = _lastChatState?.ThreadTitle ?? "Main session",
+ Model = _lastChatState?.Model,
Status = ChatThreadStatus.Running,
Activity = ChatActivity.Idle,
});
@@ -2285,9 +2298,86 @@ private void Publish(ChatDataSnapshot snapshot)
if (_post is null)
{
Changed?.Invoke(this, args);
- return;
}
- _post(() => Changed?.Invoke(this, args));
+ else
+ {
+ _post(() => Changed?.Invoke(this, args));
+ }
+
+ // Debounce-save last-known UI state so the next launch can show
+ // meaningful labels while reconnecting instead of "Main session"/"model".
+ if (snapshot.Threads.Length > 0 || snapshot.AvailableModels.Length > 0)
+ DebounceSaveLastChatState(snapshot);
+ }
+
+ // ── Last-chat-state cache ──────────────────────────────────────────
+ // Persists the last-known thread title, model, and available models so
+ // the UI can show them while reconnecting instead of generic placeholders.
+
+ private static readonly string LastChatStateFilePath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OpenClawTray", "last-chat-state.json");
+
+ private System.Threading.Timer? _lastChatStateSaveTimer;
+
+ internal sealed class LastChatState
+ {
+ public string? DefaultThreadId { get; set; }
+ public string? ThreadTitle { get; set; }
+ public string? Model { get; set; }
+ public string[]? AvailableModels { get; set; }
+ }
+
+ private LastChatState? _lastChatState;
+
+ internal static LastChatState? LoadLastChatState()
+ {
+ try
+ {
+ if (!File.Exists(LastChatStateFilePath)) return null;
+ var json = File.ReadAllText(LastChatStateFilePath);
+ return System.Text.Json.JsonSerializer.Deserialize(json);
+ }
+ catch { return null; }
+ }
+
+ private void DebounceSaveLastChatState(ChatDataSnapshot snapshot)
+ {
+ // Find the default thread to capture its title/model
+ var defaultThread = snapshot.DefaultThreadId is { } dtId
+ ? Array.Find(snapshot.Threads, t => t.Id == dtId)
+ : snapshot.Threads.Length > 0 ? snapshot.Threads[0] : null;
+
+ if (defaultThread is null && snapshot.AvailableModels.Length == 0) return;
+
+ var state = new LastChatState
+ {
+ DefaultThreadId = snapshot.DefaultThreadId,
+ ThreadTitle = defaultThread?.Title,
+ Model = defaultThread?.Model,
+ AvailableModels = snapshot.AvailableModels,
+ };
+
+ lock (_gate)
+ {
+ _lastChatState = state;
+ _lastChatStateSaveTimer?.Dispose();
+ _lastChatStateSaveTimer = new System.Threading.Timer(_ => SaveLastChatState(state), null, 2000, Timeout.Infinite);
+ }
+ }
+
+ private static void SaveLastChatState(LastChatState state)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(LastChatStateFilePath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ var json = System.Text.Json.JsonSerializer.Serialize(state);
+ var tmp = LastChatStateFilePath + ".tmp";
+ File.WriteAllText(tmp, json);
+ File.Move(tmp, LastChatStateFilePath, overwrite: true);
+ }
+ catch { }
}
private void RaiseNotification(ChatProviderNotification notification)
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
index e1110c9ca..9714e31a0 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatRoot.cs
@@ -243,10 +243,15 @@ Element BuildLoadingElement()
&& snapshot.ComposeTarget.IsReady
&& snapshot.ComposeTarget.SessionKey is { } composeKey)
{
+ // Use last-known state from the data provider so the composer shows
+ // the previous session title/model while reconnecting instead of
+ // generic "Main session"/"model" placeholders.
+ var lastState = (_provider as OpenClawChatDataProvider)?.CachedLastChatState;
composeOnlyThread = new ChatThread
{
Id = composeKey,
- Title = "Main session",
+ Title = lastState?.ThreadTitle ?? "Main session",
+ Model = lastState?.Model,
Status = ChatThreadStatus.Running,
Activity = ChatActivity.Idle,
};
@@ -416,8 +421,11 @@ Element BuildLoadingElement()
var parts = (t.Id ?? "").Split(':');
return parts.Length >= 3 && parts[0] == "agent" ? parts[1] : "other";
})
+ // "main" first (sort key 0), then alphabetical
+ .OrderBy(g => g.Key.Equals("main", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
.Select(g => new ChannelGroup(
- AgentLabel: char.ToUpper(g.Key[0]) + g.Key[1..],
+ AgentLabel: g.Key.Length > 0 ? char.ToUpper(g.Key[0]) + g.Key[1..] : "Unknown",
Sessions: g.Select(t => (Id: t.Id, Title: t.Title!)).ToArray()))
.ToArray();
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
index 3a086bf1f..1f8750244 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs
@@ -1041,7 +1041,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst
VStack(2, bubbleRow, footer)
.HAlign(HorizontalAlignment.Stretch)
).Background(new SolidColorBrush(Colors.Transparent))
- .Margin(gutter, topMargin, 16, bottomMargin),
+ .Margin(gutter, topMargin, 20, bottomMargin),
entry.Id);
}
@@ -1061,12 +1061,15 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
return Empty();
// Avatar shown only on the FIRST entry of a contiguous agent-side
- // run. Continuation entries get no spacer — they align flush with
- // the tool burst cards above (which also sit at the left inset),
- // so the agent column reads as a single vertical edge.
- Element leftSlot = !showAssistAvatar || !showAvatar
- ? Empty()
- : AssistantAvatar().VAlign(VerticalAlignment.Top);
+ // run. Continuation entries get an invisible spacer the same width
+ // as the avatar so all bubbles stay left-aligned in a uniform column.
+ Element leftSlot;
+ if (!showAssistAvatar)
+ leftSlot = Empty();
+ else if (showAvatar)
+ leftSlot = AssistantAvatar().VAlign(VerticalAlignment.Top);
+ else
+ leftSlot = Border(Empty()).Set(b => { b.Width = 36; b.Height = 0; });
// Assistant bubble — subtle gray with primary text. Radius/Padding
// come from ChatExplorationState (BubbleCornerRadius + PaddingDensity).
@@ -1103,10 +1106,9 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
var bubbleRow = Grid(
[GridSize.Auto, GridSize.Star()],
[GridSize.Auto],
- leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar && showAvatar ? bubbleSideMargin : 0, 0),
+ leftSlot.Grid(row: 0, column: 0).Margin(0, 0, showAssistAvatar ? bubbleSideMargin : 0, 0),
card.HAlign(HorizontalAlignment.Left).Grid(row: 0, column: 1)
).HAlign(HorizontalAlignment.Stretch);
-
Element footer = Empty();
if (endsBurst && showTimestamps)
{
@@ -1117,7 +1119,7 @@ Element RenderAssistantEntry(ChatTimelineItem entry, bool startsBurst, bool ends
entryMeta?.InputTokens, entryMeta?.OutputTokens,
entryMeta?.ResponseTokens, entryMeta?.ContextPercent,
chatStampFg, entry.Id, entry.Text ?? "");
- var leftInset = (showAssistAvatar && showAvatar) ? (36 + bubbleSideMargin) : 0;
+ var leftInset = showAssistAvatar ? (36 + bubbleSideMargin) : 0;
leftInset += (int)bubblePadding.Left;
footer = footer.Margin(leftInset, 2, 0, 0);
}
@@ -1367,6 +1369,7 @@ Element PhantomChevron() => Caption("▸")
{
var next = new HashSet(expandedToolChips.Value);
if (!next.Add(token)) next.Remove(token);
+ suppressAutoFollowRef.Current = true;
expandedToolChips.Set(next);
};
@@ -1619,7 +1622,12 @@ Element AnchorLeft(Element card) => Grid(
Action toggleSummary = () =>
{
var next = new HashSet(expandedToolChips.Value);
- if (!next.Add(summaryToken)) next.Remove(summaryToken);
+ var expanding = next.Add(summaryToken);
+ if (!expanding) next.Remove(summaryToken);
+ // Suppress auto-follow so the scroll position stays put
+ // while the card unfurls — the SizeChanged handler would
+ // otherwise chase the new bottom.
+ suppressAutoFollowRef.Current = true;
expandedToolChips.Set(next);
};
@@ -1806,6 +1814,7 @@ string Truncate(string s, int max)
{
var next = new HashSet(expandedToolChips.Value);
if (!next.Add(taskListToken)) next.Remove(taskListToken);
+ suppressAutoFollowRef.Current = true;
expandedToolChips.Set(next);
};
@@ -2306,6 +2315,11 @@ bool BurstIsNestable(System.Collections.Generic.List b)
{
QueueScrollToBottom(sv, prevSessionIdRef.Current, disableAnimation: true);
}
+ else if (suppressAutoFollowRef.Current)
+ {
+ // Reset after one suppressed layout pass (e.g. tool expand/collapse).
+ suppressAutoFollowRef.Current = false;
+ }
};
}
}).Grid(row: 2, column: 0),
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
index dbb929654..5be851dd8 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawComposer.cs
@@ -232,7 +232,7 @@ public override Element Render()
IsEnabled = false,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
FontSize = 10,
- Padding = new Thickness(4, 6, 4, 2),
+ Padding = new Thickness(4, 2, 4, 2),
IsHitTestVisible = false,
});
}
@@ -244,7 +244,7 @@ public override Element Render()
Tag = session.Id,
Padding = groups.Length > 1
? new Thickness(16, 4, 4, 4)
- : new Thickness(4, 4, 4, 4),
+ : new Thickness(8, 4, 4, 4),
};
cb.Items.Add(item);
if (session.Id == (Props.ChannelId ?? ""))
diff --git a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
index 8c539d08d..3ac590049 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
@@ -33,7 +33,14 @@ public void Initialize()
{
_appState = CurrentApp.AppState;
_appState.PropertyChanged += OnAppStateChanged;
- // Only request fresh data when no matching cache exists.
+
+ // Check per-agent cache first, then fall back to single-slot cache
+ if (_appState.TryGetCachedAgentFilesList(AgentId, out var cachedData))
+ {
+ UpdateAgentFilesList(cachedData);
+ return;
+ }
+
var hasMatchingCache = _appState?.AgentFilesList.HasValue == true &&
string.Equals(_appState?.AgentFilesListAgentId, AgentId, StringComparison.OrdinalIgnoreCase);
var status = CurrentApp.AppState?.Status ?? OpenClaw.Shared.ConnectionStatus.Disconnected;
@@ -45,6 +52,10 @@ public void Initialize()
ClearTabs();
_ = CurrentApp.GatewayClient.RequestAgentFilesListAsync(AgentId);
}
+ else if (hasMatchingCache)
+ {
+ UpdateAgentFilesList(_appState!.AgentFilesList!.Value);
+ }
else if (CurrentApp.GatewayClient == null || status != OpenClaw.Shared.ConnectionStatus.Connected)
{
FallbackInfoBar.IsOpen = true;
diff --git a/src/OpenClaw.Tray.WinUI/Services/AppState.cs b/src/OpenClaw.Tray.WinUI/Services/AppState.cs
index abd5d98e0..c16068ea8 100644
--- a/src/OpenClaw.Tray.WinUI/Services/AppState.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/AppState.cs
@@ -131,6 +131,22 @@ private bool SetField(ref T field, T value, [CallerMemberName] string? name =
private string? _agentFilesListAgentId;
public string? AgentFilesListAgentId { get => _agentFilesListAgentId; set => SetField(ref _agentFilesListAgentId, value); }
+ // Per-agent workspace file cache so switching agents doesn't re-fetch.
+ private readonly Dictionary _agentFilesCache = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object _agentFilesCacheLock = new();
+
+ /// Cache workspace file list for a specific agent.
+ public void CacheAgentFilesList(string agentId, JsonElement data)
+ {
+ lock (_agentFilesCacheLock) _agentFilesCache[agentId] = data;
+ }
+
+ /// Try to get cached workspace file list for an agent.
+ public bool TryGetCachedAgentFilesList(string agentId, out JsonElement data)
+ {
+ lock (_agentFilesCacheLock) return _agentFilesCache.TryGetValue(agentId, out data);
+ }
+
private JsonElement? _cronList;
public JsonElement? CronList { get => _cronList; set => SetField(ref _cronList, value); }
@@ -260,6 +276,7 @@ public void ClearCachedData()
SkillsAgentId = null;
AgentFilesList = null;
AgentFilesListAgentId = null;
+ lock (_agentFilesCacheLock) _agentFilesCache.Clear();
AgentFileContent = null;
CronList = null;
CronStatus = null;
diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs
index 3230c7de0..e9d6af0e4 100644
--- a/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/GatewayService.cs
@@ -174,6 +174,12 @@ private void OnConnectionStatusChanged(object? sender, ConnectionStatus status)
status = status.ToString()
});
+ // Request agents list on connect so the nav pane can populate.
+ if (status == ConnectionStatus.Connected && sender is IOperatorGatewayClient client)
+ {
+ _ = client.RequestAgentsListAsync();
+ }
+
EnqueueModelUpdate(() =>
{
if (status == ConnectionStatus.Connected)
@@ -464,7 +470,16 @@ private void OnAgentFilesListUpdated(object? sender, JsonElement data)
{
if (sender != _currentClient) return;
var cloned = data.Clone();
- EnqueueModelUpdate(() => _state.AgentFilesList = cloned);
+ var agentId = cloned.TryGetProperty("agentId", out var aidEl) ? aidEl.GetString() : null;
+ EnqueueModelUpdate(() =>
+ {
+ _state.AgentFilesList = cloned;
+ if (!string.IsNullOrEmpty(agentId))
+ {
+ _state.AgentFilesListAgentId = agentId;
+ _state.CacheAgentFilesList(agentId!, cloned);
+ }
+ });
}
private void OnAgentFileContentUpdated(object? sender, JsonElement data)
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 49d282825..7e22dd14c 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -102,6 +102,10 @@ internal void BindToAppState()
AppModel.PropertyChanged += OnAppModelChanged;
UpdateTitleBarStatus(AppModel.Status);
ScheduleGatewayNavVisibilityForStatus(AppModel.Status, debounceDisconnected: false);
+
+ // Apply agents list that may have arrived before this window opened.
+ if (AppModel.AgentsList.HasValue)
+ RebuildAgentNavItems(AppModel.AgentsList.Value);
}
}
From 127c332b6eb4f92eb7a8f8d843a7456c432205a6 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Thu, 21 May 2026 19:59:59 -0700
Subject: [PATCH 058/115] docs: refresh audit findings (#499)
Update stale documentation discovered during the repository documentation audit, including current test coverage, A2UI modal behavior, device identity paths, and third-party icon attribution.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/CODE_REVIEW.md | 54 ++++--
docs/CONNECTION_ARCHITECTURE.md | 2 +-
docs/SETUP.md | 2 +-
docs/TEST_COVERAGE.md | 165 +++++++-----------
docs/a2ui/README.md | 8 +-
docs/a2ui/grading.md | 6 +-
.../Rendering/Renderers/ContainerRenderers.cs | 3 +-
.../THIRD_PARTY_NOTICES.md | 2 +-
src/skills/windows-a2ui/SKILL.md | 4 +-
9 files changed, 112 insertions(+), 134 deletions(-)
diff --git a/docs/CODE_REVIEW.md b/docs/CODE_REVIEW.md
index 3182ab187..610d3fbaa 100644
--- a/docs/CODE_REVIEW.md
+++ b/docs/CODE_REVIEW.md
@@ -1,4 +1,12 @@
-# Code Review - OpenClaw Windows Hub
+# Historical Code Review - OpenClaw Windows Hub
+
+> Current audit status (2026-05-21): this review is retained as a point-in-time
+> snapshot from early 2026. Several findings have since been addressed or moved
+> into dedicated architecture docs, including the connection state machine,
+> credential storage, and expanded test coverage. Use
+> [`TEST_COVERAGE.md`](./TEST_COVERAGE.md) and
+> [`CONNECTION_ARCHITECTURE.md`](./CONNECTION_ARCHITECTURE.md) for current
+> status before acting on the historical recommendations below.
## Overview
This document provides a comprehensive code review of the OpenClaw Windows Hub repository, focusing on correctness, security, and best practices.
@@ -63,6 +71,10 @@ else if (sessions.ValueKind == JsonValueKind.Object) { /* ... */ }
**Location**: `OpenClawGatewayClient.ReconnectWithBackoffAsync()` (lines 164-185)
+**Current status**: Superseded by the dedicated connection architecture in
+`OpenClaw.Connection`, including `ConnectionStateMachine` and
+`GatewayConnectionManager`.
+
**Issue**: Multiple paths can trigger reconnection simultaneously:
- Manual reconnect in `CheckHealthAsync()` (line 92)
- Auto-reconnect in `ListenForMessagesAsync()` (line 278)
@@ -207,10 +219,14 @@ public async Task SendChatMessageAsync(string message)
### ⚠️ Security Recommendations
-1. **Token Storage** (Medium Priority)
- - Currently stores auth token in `settings.json` as plain text
- - **Recommendation**: Use Windows Data Protection API (DPAPI) to encrypt tokens
- - Example: `ProtectedData.Protect()` from `System.Security.Cryptography`
+1. **Credential Storage** (Medium Priority)
+ - Historical finding: older settings stored gateway credentials directly in
+ `settings.json`.
+ - Current status: gateway credential ownership moved to the gateway registry
+ and device identity files; SettingsManager uses Windows DPAPI for stored
+ API keys where applicable.
+ - **Recommendation**: keep new credential paths documented and covered by
+ migration/security tests.
2. **WebSocket Message Validation** (Low-Medium Priority)
- No explicit size limits on incoming messages
@@ -224,10 +240,11 @@ public async Task SendChatMessageAsync(string message)
## Testing Coverage
-### ✅ Tests Added (571 tests)
+### Tests Added
-1. **OpenClaw.Shared.Tests** (478 tests) - Models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting
-2. **OpenClaw.Tray.Tests** (93 tests) - Menu display, menu positioning, settings round-trip, deep link parsing
+The original review was written when the suite was much smaller. The current
+test project inventory and latest required-suite runtime counts live in
+[`TEST_COVERAGE.md`](./TEST_COVERAGE.md).
### 📋 Recommended Additional Tests
@@ -322,14 +339,16 @@ if (Key.Contains('/') || Key.Contains('\\'))
## Recommendations Summary
### High Priority
-1. ✅ Add unit tests - **COMPLETED (88 tests)**
-2. Consider encrypting auth token in settings.json (use DPAPI)
-3. Add integration tests for WebSocket communication
+1. Add unit tests - **completed and expanded; see `TEST_COVERAGE.md`**
+2. Review remaining credential-at-rest gaps against the current gateway
+ registry/device identity model
+3. Add integration tests for WebSocket communication where live-gateway coverage
+ is still required
### Medium Priority
4. Improve error handling consistency
5. Add schema versioning to protocol
-6. Implement connection state machine
+6. Connection state machine - **completed in `OpenClaw.Connection`**
7. Add message size limits
### Low Priority
@@ -342,17 +361,18 @@ if (Key.Contains('/') || Key.Contains('\\'))
The OpenClaw Windows Hub codebase demonstrates good software engineering practices with proper async/await usage, event-driven architecture, and resource management. The main areas for improvement are:
-1. **Testing**: Now addressed with 88 unit tests covering core functionality
+1. **Testing**: Now addressed by the expanded xUnit suite documented in `TEST_COVERAGE.md`
2. **Error Handling**: Could be more consistent
-3. **Security**: Token encryption would enhance security
+3. **Security**: Continue reviewing credential-at-rest coverage as storage paths evolve
4. **Robustness**: JSON parsing could be more resilient
-All critical functionality has been validated through the new unit test suite. The code is production-ready with the caveat that the identified medium-priority issues should be addressed for long-term maintainability.
+Critical functionality has broad automated coverage, but this historical review
+should not be treated as the live source of truth for current issue status.
---
-**Review Date**: 2026-01-29 (updated 2026-03-18)
+**Review Date**: 2026-01-29 (historical review; documentation audit 2026-05-21)
**Reviewer**: GitHub Copilot Coding Agent
-**Test Coverage**: 571 tests, all passing
+**Test Coverage**: See [`TEST_COVERAGE.md`](./TEST_COVERAGE.md) for current counts
**Overall Grade**: B+ (Good, with room for improvement)
diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md
index e00e26c04..c89bd8627 100644
--- a/docs/CONNECTION_ARCHITECTURE.md
+++ b/docs/CONNECTION_ARCHITECTURE.md
@@ -212,7 +212,7 @@ The connect handshake uses Ed25519 signatures with v3→v2 fallback:
## Tests
-Connection tests live in `tests/OpenClaw.Connection.Tests/` (215 tests):
+Connection tests live in `tests/OpenClaw.Connection.Tests/`:
- `ConnectionStateMachineTests` — FSM transitions, derived overall state
- `CredentialResolverTests` — credential precedence for operator and node
diff --git a/docs/SETUP.md b/docs/SETUP.md
index 556b4e2d4..c3cf2df26 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -190,4 +190,4 @@ OpenClaw Tray checks for updates automatically and shows a notification when a n
Go to **Settings → Apps → Installed apps**, find **OpenClaw Tray**, and click **Uninstall**. Alternatively, use **Add or Remove Programs** in the Control Panel.
-Your settings file at `%APPDATA%\OpenClawTray\settings.json` and device key at `%LOCALAPPDATA%\OpenClawTray\device-key-ed25519.json` are not removed automatically — delete them manually if you want a clean uninstall.
+Your settings file at `%APPDATA%\OpenClawTray\settings.json` and device identity files under `%APPDATA%\OpenClawTray\` (including per-gateway keys at `%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json`) are not removed automatically — delete them manually if you want a clean uninstall.
diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md
index debfb9f72..65b7a4aed 100644
--- a/docs/TEST_COVERAGE.md
+++ b/docs/TEST_COVERAGE.md
@@ -1,135 +1,94 @@
# Test Coverage Summary
-**2349 tests total** (1464 shared + 885 tray) — required suites passing ✅
+**Last audited**: 2026-05-21
+**Framework**: xUnit / .NET 10.0
+**Required validation status**: passing (`.\build.ps1`, Shared tests, Tray tests)
-| Metric | Value |
-|--------|-------|
-| Total Tests | 2349 |
-| Result | 2327 passed, 22 skipped |
-| Failing | 0 |
-| Framework | xUnit / .NET 10.0 |
+## Required validation suites
-## Test Projects
+These are the suites every agent must run after code changes, as documented in
+`AGENTS.md`.
-### OpenClaw.Shared.Tests — 1464 tests
+| Suite | Latest runtime result |
+|---|---:|
+| `OpenClaw.Shared.Tests` | 1,897 total: 1,869 passed, 28 skipped |
+| `OpenClaw.Tray.Tests` | 1,191 total: 1,191 passed, 0 skipped |
-#### ModelsTests
-- **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting
-- **ChannelHealthTests** (~25) — status display for ON/OFF/ERR/LINKED/READY states, case-insensitive matching
-- **SessionInfoTests** (~25) — display text, main/sub session prefixes, ShortKey extraction, context summaries
-- **SessionInfoContextSummaryTests** (~8) — token window formatting, millions/thousands display
-- **SessionInfoRichDisplayTextTests** (~8) — rich display labels, display name fallback
-- **SessionInfoAgeTextTests** (~6) — relative time formatting (minutes, hours ago)
-- **GatewayUsageInfoTests** (~12) — token counts (999, 15.0K, 2.5M), cost display, empty state
-- **GatewayNodeInfoTests** (~10) — display name, node info formatting
+Runtime totals come from `dotnet test` on 2026-05-21. They are higher than
+method counts because some `[Theory]` tests expand into multiple cases.
-#### OpenClawGatewayClientTests (~50)
-- Notification classification (health, urgent, calendar, build, email alerts)
-- Tool-to-activity mapping (exec, read, write, edit, search, browser, message)
-- Path shortening and label truncation
-- `ResetUnsupportedMethodFlags` — clearing unsupported flag state
+## Test project inventory
-#### ExecApprovalPolicyTests (~20)
-- Policy rule evaluation, persistence, pattern matching
+| Project | Primary scope | Test methods |
+|---|---|---:|
+| `OpenClaw.Connection.Tests` | Gateway registry, credential resolution, connection manager/state machine, setup codes, pairing, diagnostics | 189 |
+| `OpenClaw.Shared.Tests` | Shared models, gateway client, capabilities, MCP, exec approval, A2UI security, URL handling, notification categorization | 1,356 |
+| `OpenClaw.Tray.Tests` | Tray state/UI helpers, settings isolation, onboarding, connection page behavior, localization, local gateway setup/uninstall | 798 |
+| `OpenClaw.Tray.UITests` | Native WinUI/A2UI control and rendering coverage | 50 |
+| `OpenClaw.WinNode.Cli.Tests` | Windows node CLI argument parsing, command behavior, JSON output, uninstall flow | 79 |
+| `OpenClawTray.FunctionalUI.Tests` | Functional UI smoke coverage | 8 |
+| `OpenClawTray.OnboardingV2.Tests` | Onboarding V2 page flow and state coverage | 9 |
+| `OpenClaw.Tray.IntegrationTests` | Integration-test project scaffold; no `[Fact]`/`[Theory]` methods currently | 0 |
-#### CapabilityTests (~30)
-- **SystemCapabilityTests** — system command handling
-- **CanvasCapabilityTests** — canvas command handling
-- **ScreenCapabilityTests** — screen command handling
-- **CameraCapabilityTests** — camera command handling
+The method inventory is a source scan of `[Fact]` and `[Theory]` attributes. Use
+`dotnet test` for authoritative runtime totals.
-#### NodeCapabilitiesTests (~15)
-- Base class parsing, `ExecuteAsync` return values, payload handling
+## Coverage highlights
-#### DeviceIdentityTests (~15)
-- Payload format validation, pairing status events
+### OpenClaw.Shared.Tests
-#### NotificationCategorizerTests (~30)
-- Keyword matching, channel-to-type mapping (health, calendar, stock, build, email, urgent)
-- Priority rules, default categorization
+- **Model and display formatting** — activity glyphs, session labels, gateway usage/node display, channel status, and rich text helpers.
+- **Gateway and WebSocket behavior** — gateway client parsing, session keys, WebSocket base handling, URL normalization, local gateway classification, and token sanitization.
+- **Capabilities and MCP** — app/canvas/screen/camera/system capabilities, MCP auth token reset, MCP HTTP server, MCP tool bridge, MXC availability, MXC policy building, and command runners.
+- **Exec approval** — legacy policy coverage plus V2 evaluator, input validation, normalization, prompt adapter, routing, coordinator, store, environment sanitizing, and shell-wrapper parsing.
+- **A2UI and web bridge** — A2UI capability security, asset hash pinning, web bridge message handling, and channel payload/status tests.
+- **Security and localization-adjacent helpers** — HTTP URL validation/risk evaluation, device identity, identity migration, notification categorization, speech language normalization, and non-fatal action handling.
-#### GatewayUrlHelperTests (~25)
-- URL normalization (http→ws, https→wss)
-- Embedded credential stripping
-- Port preservation, path handling
+### OpenClaw.Tray.Tests
-#### SystemRunTests (~20)
-- Command execution, timeout handling, environment variables
+- **Tray UI and state** — app state, menu display/position/sizing, tray tooltip formatting, activity streams, async list loading, diagnostics contracts, markup regressions, and chat timeline/markdown handling.
+- **Connection and pairing** — connection manager node connector tests, connection page approval/channel metrics/row state, operator and Windows tray node pairing approval, gateway action transport, and quick-send coordination.
+- **Settings and startup** — settings round-trip/isolation, consent and settings save, auto-start defaults, startup setup state, existing config guard policy, and local setup progress stage mapping.
+- **Onboarding and local gateway setup** — onboarding completion/chat bootstrapper/existing config guard, wizard flow/selection/error/step parsing, setup code decoding, local gateway setup diagnostics, uninstall, WSL keep-alive, and auto-pair flags.
+- **Localization and resources** — localization key parity, capability page localization, fluent icon catalog coverage, and installer assertion tests.
-#### ShellQuotingTests (~20)
-- Shell metachar detection (`&`, `|`, `;`, `$`, `` ` ``, `*`, `?`, `<`, `>`, etc.)
-- Quoting for safe shell invocation
+### Additional projects
-#### WindowsNodeClientTests (~10)
-- URL handling, endpoint construction
+- **OpenClaw.Connection.Tests** keeps connection architecture tests separate from tray UI concerns.
+- **OpenClaw.Tray.UITests** covers A2UI/native WinUI rendering behavior that is awkward to validate through pure unit tests.
+- **OpenClaw.WinNode.Cli.Tests** covers the standalone Windows node CLI contract.
+- **OpenClawTray.OnboardingV2.Tests** and **OpenClawTray.FunctionalUI.Tests** cover newer UI surfaces outside the main tray test project.
-#### NodeInvokeResponseTests (~5)
-- Default values, property setting
-
-#### ReadmeValidationTests (~5)
-- Documentation sync checks
-
----
-
-### OpenClaw.Tray.Tests — 885 tests
-
-#### Core Tray Tests
-
-- **MenuDisplayHelperTests** (~40) — `GetStatusIcon` emoji mapping for Connected/Disconnected/Connecting/Error states, `GetChannelStatusIcon` status icons for running/idle/pending/error/disconnected + case-insensitive variants, `GetNextToggleValue` ON↔OFF toggling, unknown/empty status fallback
-- **MenuPositionerTests** (~15) — Screen edge clamping (top-left, bottom-right), taskbar-at-right scenario, menu positioning relative to cursor
-- **SettingsRoundTripTests** (~15) — Serialization/deserialization round trips, default values on missing keys, backward compatibility with older settings formats
-- **DeepLinkParserTests** (~23) — `ParseDeepLink` protocol validation, null/empty handling, subpath parsing, trailing slash stripping, query parameter extraction, URL-encoded message handling
-
-#### Onboarding Tests
-
-- **GatewayWizardStateTests** — Gateway wizard state persistence defaults and disposal
-- **GatewayChatHelperTests** (11) — URL scheme conversion, token encoding, localhost checks, session keys
-- **LocalGatewayApproverTests** (13) — IsLocalGateway for localhost/remote/edge cases
-- **SetupCodeDecoderTests** (14) — Base64url decode, size limits, JSON validation, URL/token extraction
-- **GatewayHealthCheckTests** (6) — Health URI building, scheme conversion, port preservation
-- **SecurityValidationTests** (16) — Locale whitelist, port range, path traversal, URI scheme validation
-- **WizardStepParsingTests** (12) — JSON step parsing, options, completion, sensitive fields
-- **GatewayDiscoveryServiceTests** — mDNS host selection and connection URL regression coverage
-- **LocalizationValidationTests** — locale key parity, onboarding key presence, duplicate detection, and all-or-none translation consistency
-
-#### Connection Architecture Tests
-
-- **GatewayRegistryTests / GatewayRegistryMigrationTests** — active gateway persistence, URL lookup, migration from legacy settings, per-gateway identity copy.
-- **CredentialResolverTests / InteractiveGatewayCredentialResolverTests** — device-token-first precedence, shared token fallback, bootstrap pairing state, legacy fallback for user-facing chat.
-- **GatewayConnectionManagerTests / ConnectionStateMachineTests** — operator/node lifecycle, transitions, diagnostics, reconnect/disconnect behavior.
-- **PairingFlowTests / SetupCodeFlowTests / StaleEventGuardTests** — bootstrap setup codes, pairing state transitions, and stale event suppression.
-- **RetryPolicyTests / ConnectionDiagnosticsTests / NodeConnectorTests / SettingsChangeImpactTests** — retry classification, diagnostics, node connector behavior, and settings change impact.
-
----
-
-## Running Tests
+## Running tests
```powershell
-# All tests
+# Required validation after code changes
+$env:OPENCLAW_REPO_ROOT = (Get-Location).Path
+.\build.ps1
+dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore
+dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore
+
+# All tests in the solution
dotnet test
# Single project
-dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore
-dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore
+dotnet test .\tests\OpenClaw.Connection.Tests\OpenClaw.Connection.Tests.csproj
# Specific test class
dotnet test --filter "FullyQualifiedName~MenuDisplayHelperTests"
-# Onboarding tests only
-dotnet test --filter "FullyQualifiedName~Onboarding"
-
# Verbose output
dotnet test --logger "console;verbosity=detailed"
```
-## Not Covered (Requires Integration Tests)
-
-- Windows shell tray hover/click behavior
-- Full WinUI onboarding wizard flow, including WebView2 navigation
-- Real gateway/node pairing against a live gateway
+In a fresh worktree, run the project once without `--no-restore` or build it
+first so `dotnet test --no-restore` cannot no-op before `bin\` exists.
----
+## Not fully covered by automated tests
-**Last Updated**: 2026-05-10
-**Framework**: xUnit / .NET 10.0
-**Status**: ✅ required suites passing (`.\build.ps1`, Shared tests, Tray tests)
+- Real shell tray hover/click behavior against Explorer.
+- Full live gateway/node pairing against a remote gateway.
+- Long-running soak behavior for reconnects, high-frequency activity updates,
+ and memory usage over multi-day sessions.
+- Manual visual acceptance for complex WinUI surfaces where screenshot
+ comparison would be brittle.
diff --git a/docs/a2ui/README.md b/docs/a2ui/README.md
index b4748b33c..0723390ea 100644
--- a/docs/a2ui/README.md
+++ b/docs/a2ui/README.md
@@ -4,10 +4,10 @@ This folder is the entry point for everything A2UI in this repo. It captures
the v0.8 specification, the standard catalog, and a side-by-side grading of
two implementations:
-- **Lit reference** in `C:\Users\andersonch\Code\openclaw` (web components,
- rendered in a browser via the OpenClaw canvas host).
-- **Native WinUI** in this repo (`src/OpenClaw.Tray.WinUI/A2UI/`,
- branch `feat/a2ui-native-winui`).
+- **Lit reference** from the upstream OpenClaw A2UI renderer
+ (`vendor/a2ui/renderers/lit/src/0.8`, web components rendered in a
+ browser via the OpenClaw canvas host).
+- **Native WinUI** in this repo (`src/OpenClaw.Tray.WinUI/A2UI/`).
The native WinUI design doc that predates this overview lives at
[`../A2UI_NATIVE_WINUI.md`](../A2UI_NATIVE_WINUI.md); this folder
diff --git a/docs/a2ui/grading.md b/docs/a2ui/grading.md
index 05e7c17b2..b3963653b 100644
--- a/docs/a2ui/grading.md
+++ b/docs/a2ui/grading.md
@@ -3,12 +3,12 @@
This grades two implementations against the v0.8 spec
():
-- **Lit reference** at `C:\Users\andersonch\Code\openclaw\vendor\a2ui\renderers\lit\src\0.8`
+- **Lit reference** at `vendor\a2ui\renderers\lit\src\0.8` in the
+ upstream OpenClaw checkout
- **Native WinUI** in this repo at `src/OpenClaw.Tray.WinUI/A2UI/`
The Lit code looks like the canonical browser renderer the OpenClaw
-canvas host ships; the WinUI code is this repo's branch
-`feat/a2ui-native-winui`.
+canvas host ships; the WinUI code is the native renderer in this repo.
Citations use repo-local paths. Lit paths are anchored at the OpenClaw
checkout: `openclaw\vendor\a2ui\renderers\lit\src\0.8\`. WinUI paths are
diff --git a/src/OpenClaw.Tray.WinUI/A2UI/Rendering/Renderers/ContainerRenderers.cs b/src/OpenClaw.Tray.WinUI/A2UI/Rendering/Renderers/ContainerRenderers.cs
index b966f38eb..aeae70110 100644
--- a/src/OpenClaw.Tray.WinUI/A2UI/Rendering/Renderers/ContainerRenderers.cs
+++ b/src/OpenClaw.Tray.WinUI/A2UI/Rendering/Renderers/ContainerRenderers.cs
@@ -242,8 +242,7 @@ public FrameworkElement Render(A2UIComponentDef c, RenderContext ctx)
{
// Render as a real ContentDialog launched from the entry-point child.
// The entry-point is shown inline; clicking it shows the dialog with
- // the content-child as its body. Falls back to Expander when no
- // XamlRoot can be resolved (e.g. surface not yet attached to a window).
+ // the content-child as its body once the trigger is attached to XAML.
var entryId = ctx.GetSingleChild(c, "entryPointChild");
var contentId = ctx.GetSingleChild(c, "contentChild");
diff --git a/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md b/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md
index 55598b5c6..75e0eb92e 100644
--- a/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md
+++ b/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md
@@ -2,7 +2,7 @@
The colorful sidebar SVG files in `Assets/SidebarIcons/` are derived from the
**Fluent UI System Icons** project by Microsoft, color variant
-(890 icons, also known as `fluent-color` in the Iconify ecosystem).
+(20 selected icons from the full `fluent-color` set in the Iconify ecosystem).
The app/tray/window lobster icon (`Assets/openclaw.ico`, plus the
`Square*` / `StoreLogo` / `LockScreenLogo` PNG family) is derived from the
diff --git a/src/skills/windows-a2ui/SKILL.md b/src/skills/windows-a2ui/SKILL.md
index 6c25465e1..f8d719e4f 100644
--- a/src/skills/windows-a2ui/SKILL.md
+++ b/src/skills/windows-a2ui/SKILL.md
@@ -86,7 +86,7 @@ Exactly one of `valueString` / `valueNumber` / `valueBoolean` / `valueMap` shoul
| **List** | `children: { "explicitList": [...] }` | `direction`: `vertical` (default) or `horizontal`; `alignment`. Wrapped in a `ScrollViewer`. **Template/data-bound rows are not supported in v1** — supply explicit child ids. |
| **Card** | `child: ""` (single string, not explicitList) | None. Renders as a themed `Border` with 16 px padding and rounded corners. |
| **Tabs** | per-tab via `tabItems` | `tabItems: [{ "title": , "child": "" }, ...]`. Renders as a `TabView` with non-closable, non-reorderable tabs. |
-| **Modal** | `entryPointChild: ""`, `contentChild: ""` | None. **Currently rendered inline as an `Expander`**, not a real dialog. The entryPointChild becomes the header; the contentChild becomes the expanded body. |
+| **Modal** | `entryPointChild: ""`, `contentChild: ""` | `title`: optional `A2UIValue`. Renders as a native `ContentDialog` triggered by the entryPointChild; the contentChild becomes the dialog body. |
| **Divider** | — | `axis`: `horizontal` (default) or `vertical`. Renders as a 1 px rectangle. |
Children are referenced by **id**, not nested in place. Build the surface as a flat array of components and let `beginRendering.root` pick the entry point.
@@ -234,7 +234,7 @@ The single biggest UX difference between a Windows-node A2UI surface that "just
- **Inline children.** Always declare components flat and reference by id via `children.explicitList` (containers) or `child` (Card/Button/Modal halves) or per-tab `child` (Tabs).
- **Template/data-bound List rows.** Not implemented — pre-expand or use repeated explicit children.
-- **Modal as a real dialog.** It currently renders as an inline Expander. Don't rely on focus-trapping or backdrop dismiss.
+- **Inline modal bodies.** Modal renders as a native `ContentDialog`, so don't design content that only works when expanded inline in the surface layout.
- **Custom icon names.** Anything outside the icon enum becomes the Help glyph. Don't ship Material/Fluent codepoints directly.
- **Putting secrets in implicit context.** `obscured` TextField paths only flow into `action.context` when listed in the source component's `dataBinding`. Make the opt-in explicit.
- **v0.9 envelope kinds.** `canvas.a2ui.schema`, `canvas.a2ui.dump`, etc. are not supported on this node; they will land in `UnknownEnvelopeMessage` and be dropped.
From cf611d4ab59f49a7e9b49415ed6d063d649d75c9 Mon Sep 17 00:00:00 2001
From: Barbara Kudiess
Date: Thu, 21 May 2026 20:32:56 -0700
Subject: [PATCH 059/115] feat(mxc): replace Node bridge with direct wxc-exec
Replace the Node-based MXC command bridge with direct wxc-exec.exe invocation and keep local Windows sandbox behavior reliable.
Adds least-privilege cwd read grants, the temporary readonly drive-root shell startup workaround, NTFS/ReFS diagnostics, local MXC test gating, and GitHub Actions self-skip for MXC integration tests.
Fixes #494
Co-authored-by: Barbara Kudiess <7658216+bkudiess@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/WINDOWS_NODE_TESTING.md | 13 +
package.json | 2 +-
.../Mxc/DirectAppContainerExecutor.cs | 269 +++++++
src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs | 7 +-
src/OpenClaw.Shared/Mxc/MxcAvailability.cs | 76 +-
src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 90 +--
src/OpenClaw.Shared/Mxc/MxcConfig.cs | 171 +++++
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 453 ++++++++++++
src/OpenClaw.Shared/Mxc/MxcExecutor.cs | 201 +++++
src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs | 15 +-
.../Mxc/OneShotAppContainerExecutor.cs | 344 ---------
src/OpenClaw.Shared/Mxc/SandboxPolicy.cs | 11 +-
.../Mxc/UnavailableSandboxExecutor.cs | 29 -
.../OpenClaw.Tray.WinUI.csproj | 107 +--
.../Pages/SandboxPage.xaml | 8 +
.../Pages/SandboxPage.xaml.cs | 29 +-
.../Services/NodeService.cs | 39 +-
.../Mxc/DirectAppContainerExecutorTests.cs | 83 +++
.../Mxc/Golden/sdk-config-balanced.json | 49 ++
.../Mxc/Golden/sdk-config-custom.json | 48 ++
.../Mxc/Golden/sdk-config-locked-down.json | 43 ++
.../Mxc/Golden/sdk-config-permissive.json | 48 ++
.../Mxc/MxcAvailabilityTests.cs | 8 +-
.../Mxc/MxcCommandRunnerIntegrationTests.cs | 110 ++-
.../Mxc/MxcCommandRunnerTests.cs | 98 ++-
.../Mxc/MxcConfigBuilderTests.cs | 492 +++++++++++++
.../OpenClaw.Shared.Tests.csproj | 6 +
tools/mxc/run-command.cjs | 696 ------------------
28 files changed, 2187 insertions(+), 1358 deletions(-)
create mode 100644 src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
create mode 100644 src/OpenClaw.Shared/Mxc/MxcConfig.cs
create mode 100644 src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
create mode 100644 src/OpenClaw.Shared/Mxc/MxcExecutor.cs
delete mode 100644 src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs
delete mode 100644 src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json
create mode 100644 tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
delete mode 100644 tools/mxc/run-command.cjs
diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md
index f81baac5a..4404621f2 100644
--- a/docs/WINDOWS_NODE_TESTING.md
+++ b/docs/WINDOWS_NODE_TESTING.md
@@ -113,6 +113,19 @@ 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
+### MXC sandbox filesystem grants
+- `system.run` with sandboxing enabled uses MXC AppContainer filesystem grants. The cwd is granted **read-only** automatically when it is not already covered by an explicit grant. If a command needs to write in its cwd, grant that folder read-write in Sandbox settings.
+- As a temporary MXC compatibility workaround, OpenClaw adds read-only grants for the drive roots used by sandbox grants and shell startup. `cmd.exe` currently stats the drive root during startup; this workaround should be removed after MXC no longer requires it.
+- MXC filesystem filtering requires NTFS-backed paths. ReFS volumes do not have the required filter-driver behavior, so grants on ReFS paths can fail with `Access is denied` even when the policy includes the path.
+- MXC integration tests self-skip on GitHub Actions because MXC/AppContainer filesystem behavior depends on local Windows sandbox support. Run MXC tests from an NTFS-backed checkout/output folder on a local Windows machine after building the tray app so `wxc-exec.exe` has been copied into `OpenClaw.Tray.WinUI\bin\...\tools\mxc\\`:
+
+ ```powershell
+ .\build.ps1
+ $env:OPENCLAW_RUN_INTEGRATION='1'
+ $env:OPENCLAW_WXC_EXEC='D:\github\moltbot-windows-hub\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.22621.0\win-x64\tools\mxc\x64\wxc-exec.exe'
+ dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc"
+ ```
+
## Remaining Work (Roadmap)
1. ~~**system.run + exec approvals**~~ ✅ Implemented
diff --git a/package.json b/package.json
index 82b632b94..7646d733e 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "openclaw-windows-node-mxc",
"version": "0.0.0",
"private": true,
- "description": "Node bridge dependencies for the OpenClaw tray's MXC sandbox integration. The C# tray spawns node.exe with scripts under tools/mxc/ that consume the @microsoft/mxc-sdk to drive wxc-exec.exe.",
+ "description": "MXC sandbox dependency used by the OpenClaw tray build to copy wxc-exec.exe into the app output.",
"dependencies": {
"@microsoft/mxc-sdk": "^0.1.8"
}
diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
new file mode 100644
index 000000000..43a175023
--- /dev/null
+++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
@@ -0,0 +1,269 @@
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenClaw.Shared.Mxc;
+
+///
+/// Implements by spawning wxc-exec.exe
+/// directly via . No Node.js runtime required.
+///
+///
+/// Responsibilities:
+///
+/// - Per-invocation scratch dir lifecycle.
+/// - Logging the final before sending — structured
+/// summary by default; full JSON when is set.
+/// - Host-side timeout + cancel + process-tree kill via
+/// 's CancellationToken plumbing.
+/// - Cmd-line overflow handling — falls back to --config <file>
+/// when the base64'd config exceeds the Windows command-line limit.
+///
+///
+public sealed class DirectAppContainerExecutor : ISandboxExecutor
+{
+ public string Name => "mxc-direct-appc";
+ public bool IsContained => true;
+
+ /// Default cap on stdout/stderr returned to the host (4 MiB).
+ public const long DefaultMaxOutputBytes = 4 * 1024 * 1024;
+
+ ///
+ /// When set to "1", the executor logs the FULL config JSON (paths, command
+ /// line) instead of the redacted summary. Env values are still keys-only.
+ /// Use for sandbox-debugging only; the redacted summary is the default.
+ ///
+ public const string LogFullConfigEnvVar = "OPENCLAW_MXC_LOG_FULL_CONFIG";
+
+ ///
+ /// Threshold for base64'd config length above which we switch to
+ /// --config <file>. Windows cmdline is capped near 32k chars;
+ /// 25k leaves headroom for the executable path and other args.
+ ///
+ private const int Base64ConfigCharLimit = 25_000;
+
+ private readonly MxcAvailability _availability;
+ private readonly IOpenClawLogger _logger;
+
+ public DirectAppContainerExecutor(MxcAvailability availability, IOpenClawLogger? logger = null)
+ {
+ _availability = availability;
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ public async Task ExecuteAsync(
+ SandboxExecutionRequest request,
+ CancellationToken ct = default)
+ {
+ if (!_availability.IsAppContainerAvailable)
+ throw new SandboxUnavailableException(
+ _availability.UnsupportedReasons.FirstOrDefault() ?? "AppContainer unavailable");
+
+ if (!_availability.IsWxcExecResolvable || string.IsNullOrEmpty(_availability.WxcExecPath))
+ throw new SandboxUnavailableException("wxc-exec.exe not found");
+
+ var capBytes = request.MaxOutputBytes is > 0 ? request.MaxOutputBytes.Value : DefaultMaxOutputBytes;
+ var capInt = capBytes > int.MaxValue ? int.MaxValue : (int)capBytes;
+
+ var scratchDir = CreateScratchDir();
+ string? tempConfigFile = null;
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ var config = MxcConfigBuilder.Build(request, scratchDir);
+ var configJson = JsonSerializer.Serialize(config, ConfigJson);
+ var launchWorkingDirectory = string.IsNullOrWhiteSpace(config.Process.Cwd)
+ ? null
+ : config.Process.Cwd;
+
+ WarnIfUnsupportedVolume(config);
+ LogConfig(config, configJson, request);
+
+ MxcExecutor executor;
+ try
+ {
+ executor = new MxcExecutor(_availability.WxcExecPath, stdoutCapBytes: capInt, stderrCapBytes: capInt);
+ }
+ catch (FileNotFoundException ex)
+ {
+ throw new SandboxUnavailableException($"wxc-exec.exe not found at {_availability.WxcExecPath}", ex);
+ }
+
+ // Local timeout + caller cancellation. Mirror the builder's
+ // effective timeout (request.TimeoutMs > 0 ? request.TimeoutMs :
+ // DefaultProcessTimeoutMs) so a request with TimeoutMs=0 doesn't
+ // skip the host-side bound entirely. Add a grace window above
+ // the per-process timeout so wxc-exec has a chance to clean up
+ // before we kill its process tree.
+ var effectiveTimeoutMs = request.TimeoutMs > 0
+ ? request.TimeoutMs
+ : MxcConfigBuilder.DefaultProcessTimeoutMs;
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ linked.CancelAfter(effectiveTimeoutMs + 5_000);
+
+ MxcResult result;
+ // Base64 cmdline grows ~4/3 vs the underlying JSON bytes. Count
+ // UTF-8 bytes (not chars) because non-ASCII text would otherwise
+ // under-estimate the encoded size and overflow the cmdline limit.
+ var configByteCount = Encoding.UTF8.GetByteCount(configJson);
+ var base64Len = ((configByteCount + 2) / 3) * 4;
+ if (base64Len <= Base64ConfigCharLimit)
+ {
+ result = await executor.RunAsync(config, linked.Token, workingDirectory: launchWorkingDirectory);
+ }
+ else
+ {
+ tempConfigFile = Path.Combine(scratchDir, "wxc-config.json");
+ await File.WriteAllTextAsync(tempConfigFile, configJson, Encoding.UTF8, linked.Token);
+ result = await executor.RunWithConfigFileAsync(tempConfigFile, linked.Token, workingDirectory: launchWorkingDirectory);
+ }
+
+ sw.Stop();
+
+ // Caller-cancellation precedence: if the caller's token tripped,
+ // surface as OperationCanceledException instead of falsely
+ // reporting TimedOut. Check this BEFORE the timeout branch so a
+ // race between caller-cancel and linked-cancel resolves correctly.
+ if (ct.IsCancellationRequested)
+ ct.ThrowIfCancellationRequested();
+
+ // If our linked token tripped (timeout) and the caller's didn't, surface
+ // as a TimedOut result rather than throwing.
+ var timedOut = result.TimedOut || linked.IsCancellationRequested;
+ if (timedOut)
+ {
+ return new SandboxExecutionResult(
+ ExitCode: -1,
+ Stdout: result.Output ?? string.Empty,
+ Stderr: result.Error ?? "Sandboxed invocation timed out.",
+ TimedOut: true,
+ DurationMs: result.DurationMs == 0 ? sw.ElapsedMilliseconds : result.DurationMs,
+ ContainmentTag: "mxc",
+ StructuredResult: null);
+ }
+
+ return new SandboxExecutionResult(
+ ExitCode: result.ExitCode,
+ Stdout: result.Output ?? string.Empty,
+ Stderr: result.Error ?? string.Empty,
+ TimedOut: false,
+ DurationMs: result.DurationMs == 0 ? sw.ElapsedMilliseconds : result.DurationMs,
+ ContainmentTag: "mxc",
+ StructuredResult: null);
+ }
+ finally
+ {
+ TryDelete(tempConfigFile);
+ TryDeleteDir(scratchDir);
+ }
+ }
+
+ private static string CreateScratchDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "openclaw-mxc-" + Guid.NewGuid().ToString("N").Substring(0, 12));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static void TryDelete(string? path)
+ {
+ if (string.IsNullOrEmpty(path)) return;
+ try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ }
+ }
+
+ private static void TryDeleteDir(string? path)
+ {
+ if (string.IsNullOrEmpty(path)) return;
+ try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* best-effort */ }
+ }
+
+ private void LogConfig(MxcConfig config, string configJson, SandboxExecutionRequest request)
+ {
+ // Default: redacted summary. Field counts only; no paths, no command line,
+ // no env values. Useful for verifying Sandbox UI settings round-tripped
+ // into wxc-exec without leaking the user's filesystem layout.
+ var envKeys = config.Process.Env?
+ .Select(kv => kv.Split('=', 2)[0])
+ .OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
+ .ToArray() ?? Array.Empty();
+
+ var summary =
+ "[mxc] wxc-exec config (redacted) " +
+ $"wxcExec={_availability.WxcExecPath}; configBytes={Encoding.UTF8.GetByteCount(configJson)}; " +
+ $"containerId={config.ContainerId}; version={config.Version}; " +
+ $"commandLineLength={config.Process.CommandLine?.Length ?? 0}; " +
+ $"cwd={(string.IsNullOrEmpty(config.Process.Cwd) ? "" : "")}; " +
+ $"envKeys=[{string.Join(",", envKeys)}]; " +
+ $"timeoutMs={config.Process.TimeoutMs?.ToString() ?? ""}; " +
+ $"capabilities=[{string.Join(",", config.AppContainer?.Capabilities ?? Array.Empty())}]; " +
+ $"readonlyCount={config.Filesystem?.ReadonlyPaths?.Length ?? 0}; " +
+ $"readwriteCount={config.Filesystem?.ReadwritePaths?.Length ?? 0}; " +
+ $"deniedCount={config.Filesystem?.DeniedPaths?.Length ?? 0}; " +
+ $"network={{defaultPolicy={config.Network?.DefaultPolicy ?? ""},enforcementMode={config.Network?.EnforcementMode ?? ""}}}; " +
+ $"ui={{disable={config.Ui?.Disable},clipboard={config.Ui?.Clipboard ?? ""},injection={config.Ui?.Injection}}}; " +
+ $"maxOutputBytes={request.MaxOutputBytes?.ToString() ?? ""}";
+ _logger.Debug(summary);
+ Trace.WriteLine(summary);
+
+ // Full repro: gated behind env var. Paths and command line included;
+ // env values still redacted (keys only) to avoid leaking caller tokens.
+ if (string.Equals(Environment.GetEnvironmentVariable(LogFullConfigEnvVar), "1", StringComparison.Ordinal))
+ {
+ var redactedConfig = config with
+ {
+ Process = config.Process with
+ {
+ Env = envKeys.Select(k => k + "=").ToArray(),
+ }
+ };
+ var fullJson = JsonSerializer.Serialize(redactedConfig, ConfigJson);
+ var fullMsg = $"[mxc] wxc-exec config (full, env-values redacted) configJson={fullJson}";
+ _logger.Debug(fullMsg);
+ Trace.WriteLine(fullMsg);
+ }
+ }
+
+ private void WarnIfUnsupportedVolume(MxcConfig config)
+ {
+ var paths = (config.Filesystem?.ReadonlyPaths ?? Array.Empty())
+ .Concat(config.Filesystem?.ReadwritePaths ?? Array.Empty())
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ var warnedRoots = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var path in paths)
+ {
+ string? root;
+ try { root = Path.GetPathRoot(Path.GetFullPath(path)); }
+ catch { continue; }
+
+ if (string.IsNullOrWhiteSpace(root) || !warnedRoots.Add(root))
+ continue;
+
+ try
+ {
+ var drive = new DriveInfo(root);
+ if (!drive.IsReady)
+ continue;
+
+ if (!string.Equals(drive.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Warn(
+ $"[mxc] filesystem grants on {drive.DriveFormat} volume {root} may fail: " +
+ "MXC AppContainer filesystem filtering requires NTFS-backed paths.");
+ }
+ }
+ catch
+ {
+ // Best-effort diagnostic only. The command result should reflect
+ // the real MXC failure if the volume cannot be queried.
+ }
+ }
+ }
+
+ private static readonly JsonSerializerOptions ConfigJson = new()
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+}
diff --git a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
index 21abae99e..69a59f53b 100644
--- a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
+++ b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
@@ -10,7 +10,7 @@ namespace OpenClaw.Shared.Mxc;
///
/// Implementations:
///
-/// - — per-call AppContainer via Node + mxc-sdk.
+/// - — per-call AppContainer via direct wxc-exec.exe spawn.
/// - HostFallbackExecutor — when containment unavailable in BestEffort mode.
///
/// All implementations are expected to throw
@@ -45,9 +45,8 @@ Task ExecuteAsync(
/// Pass <= 0 to let the executor use its default.
///
///
-/// Maximum stdout/stderr the executor will return. Pass null to use the
-/// executor's default (typically 4 MiB). The host capture cap and the bridge
-/// cap (run-command.cjs) honor this value.
+/// Maximum stdout/stderr the executor will return. Pass null to use
+/// the executor's default (typically 4 MiB).
///
public sealed record SandboxExecutionRequest(
string CapabilityCommand,
diff --git a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
index 9794e1dcf..a9e8cbdb3 100644
--- a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
@@ -9,16 +9,16 @@ namespace OpenClaw.Shared.Mxc;
/// Backends checked:
///
/// - — Windows 11 build >= 26100, UBR >= 7965 (per @microsoft/mxc-sdk README), x64 / arm64.
-/// - — wxc-exec.exe found at the expected node_modules path or via override.
+/// - — wxc-exec.exe found in the shipped tray output layout or via override.
/// - — requires AppContainer plus IsolationProxy.exe in System32.
///
///
public sealed class MxcAvailability
{
///
- /// Optional override path for wxc-exec.exe. When set, used instead of the
- /// default node_modules/@microsoft/mxc-sdk/bin/<arch>/wxc-exec.exe probe.
- /// Wired through environment variable OPENCLAW_WXC_EXEC.
+ /// Optional override path for wxc-exec.exe. When set, used instead of
+ /// probing the shipped tools\mxc\<arch>\wxc-exec.exe layout. Wired
+ /// through environment variable OPENCLAW_WXC_EXEC.
///
public const string WxcExecOverrideEnvVar = "OPENCLAW_WXC_EXEC";
@@ -38,26 +38,17 @@ public sealed class MxcAvailability
public bool IsWxcExecResolvable { get; }
public string? WxcExecPath { get; }
- ///
- /// Resolved path to tools/mxc/run-command.cjs (the productized Node bridge
- /// for MxcCommandRunner). The tray build copies this under the app base
- /// directory; probing intentionally does not walk parent directories so a
- /// user-writable parent cannot inject a replacement bridge.
- ///
- public string? RunCommandScriptPath { get; }
-
///
/// Human-readable list of reasons MXC may not be available. Empty when fully supported.
/// Surface to UX so users know why the sandbox toggle is disabled.
///
public IReadOnlyList UnsupportedReasons { get; }
- /// True iff at least one MXC backend is supported, the bridge script is found,
- /// AND wxc-exec.exe is resolvable. (Without wxc-exec the executor will refuse
+ /// True iff at least one MXC backend is supported AND
+ /// wxc-exec.exe is resolvable. (Without wxc-exec the executor will refuse
/// to run, so reporting "available" would lie to the UI.)
public bool HasAnyBackend =>
(IsAppContainerAvailable || IsIsolationSessionAvailable)
- && RunCommandScriptPath is not null
&& IsWxcExecResolvable;
public MxcAvailability(
@@ -65,14 +56,12 @@ public MxcAvailability(
bool isIsolationSessionAvailable,
bool isWxcExecResolvable,
string? wxcExecPath,
- string? runCommandScriptPath,
IReadOnlyList unsupportedReasons)
{
IsAppContainerAvailable = isAppContainerAvailable;
IsIsolationSessionAvailable = isIsolationSessionAvailable;
IsWxcExecResolvable = isWxcExecResolvable;
WxcExecPath = wxcExecPath;
- RunCommandScriptPath = runCommandScriptPath;
UnsupportedReasons = unsupportedReasons;
}
@@ -88,7 +77,7 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null)
if (!OperatingSystem.IsWindows())
{
reasons.Add("MXC requires Windows.");
- return new MxcAvailability(false, false, false, null, null, reasons);
+ return new MxcAvailability(false, false, false, null, reasons);
}
var (build, ubr) = ReadWindowsBuildAndUbr();
@@ -109,11 +98,7 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null)
var (wxcResolvable, wxcPath) = ResolveWxcExec();
if (!wxcResolvable)
- reasons.Add($"wxc-exec.exe not found. Set {WxcExecOverrideEnvVar} or run `npm ci` at the repository root.");
-
- var runCommandScriptPath = ResolveRunCommandScript();
- if (runCommandScriptPath is null)
- reasons.Add("tools/mxc/run-command.cjs not found in any expected location.");
+ reasons.Add($"wxc-exec.exe not found. Set {WxcExecOverrideEnvVar} or build the tray app to copy it into the output folder.");
// isolation_session additionally requires Feature_IsoBrokerSessionApis on the OS
// and IsolationProxy.exe in System32. We currently only check file presence.
@@ -128,7 +113,6 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null)
$"[mxc] availability: appcontainer={isAppContainerSupported} " +
$"isolation_session={isIsolationSessionSupported} " +
$"wxc-exec={(wxcResolvable ? wxcPath : "")} " +
- $"run-command.cjs={(runCommandScriptPath ?? "")} " +
$"reasons=[{string.Join(", ", reasons)}]");
return new MxcAvailability(
@@ -136,7 +120,6 @@ public static MxcAvailability Probe(IOpenClawLogger? logger = null)
isIsolationSessionSupported,
wxcResolvable,
wxcPath,
- runCommandScriptPath,
reasons);
}
@@ -170,7 +153,7 @@ private static (bool resolvable, string? path) ResolveWxcExec()
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
return (true, overridePath);
- var arch = MxcArchHelper.GetSdkArchString();
+ var arch = GetSdkArchString();
var probeRoots = new[]
{
AppContext.BaseDirectory,
@@ -182,42 +165,25 @@ private static (bool resolvable, string? path) ResolveWxcExec()
if (string.IsNullOrWhiteSpace(root))
continue;
- var candidate = Path.Combine(
+ // Preferred: tools/mxc//wxc-exec.exe — the layout the build
+ // target extracts to so we don't ship a node_modules/ tree.
+ var shipped = Path.Combine(root, "tools", "mxc", arch, "wxc-exec.exe");
+ if (File.Exists(shipped))
+ return (true, shipped);
+
+ // Legacy fallback: developer builds with node_modules/ still around.
+ var legacy = Path.Combine(
root,
"node_modules", "@microsoft", "mxc-sdk", "bin", arch, "wxc-exec.exe");
- if (File.Exists(candidate))
- return (true, candidate);
+ if (File.Exists(legacy))
+ return (true, legacy);
}
return (false, null);
}
- private static string? ResolveRunCommandScript()
- {
- var probeRoots = new[]
- {
- AppContext.BaseDirectory,
- Path.GetDirectoryName(typeof(MxcAvailability).Assembly.Location) ?? string.Empty,
- };
-
- foreach (var root in probeRoots)
- {
- if (string.IsNullOrWhiteSpace(root))
- continue;
-
- var candidate = Path.Combine(root, "tools", "mxc", "run-command.cjs");
- if (File.Exists(candidate))
- return candidate;
- }
-
- return null;
- }
-}
-
-internal static class MxcArchHelper
-{
- /// Returns "arm64" or "x64" matching the @microsoft/mxc-sdk bin/<arch>/ layout.
- public static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
+ /// Returns "arm64" or "x64" matching the @microsoft/mxc-sdk bin/<arch>/ layout.
+ private static string GetSdkArchString() => System.Runtime.InteropServices.RuntimeInformation.OSArchitecture switch
{
System.Runtime.InteropServices.Architecture.Arm64 => "arm64",
System.Runtime.InteropServices.Architecture.X64 => "x64",
diff --git a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs
index 18b34a518..b14e85beb 100644
--- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs
@@ -13,12 +13,9 @@ namespace OpenClaw.Shared.Mxc;
///
/// Honors :
///
-/// - true (default) — sandbox via MXC; deny invocation if MXC unavailable.
+/// - true (default) — sandbox via MXC when available; fall back uncontained when MXC is unavailable.
/// - false — bypass MXC; route through the host runner.
///
-/// There is no host-fallback path when sandbox is enabled and MXC is missing —
-/// the call is denied with an explanatory error. Per user directive: "if sandbox
-/// enabled, only run on sandbox."
///
public sealed class MxcCommandRunner : ICommandRunner
{
@@ -54,26 +51,18 @@ public async Task RunAsync(CommandRequest request, CancellationTo
{
var settings = _settingsProvider();
- // Fail-closed when MXC is unavailable. We do NOT route to host even if the
- // persisted toggle is OFF — the UI hides the toggle in that state so any
- // OFF value is stale (e.g., flipped on a previous run / different machine).
- // The UI's "Sandbox unavailable — commands blocked" claim must match
- // actual behavior or it's a lie.
+ // When MXC sandboxing isn't available on this host (e.g. Windows 10,
+ // build < 26100, or missing wxc-exec.exe), fall back to the host runner
+ // so the agent can still execute commands instead of being completely
+ // blocked. The Sandbox page is read-only in that state and tells the
+ // user their commands are running uncontained.
if (!_isSandboxAvailable())
{
_logger.Warn(
- "[mxc] system.run DENIED: sandbox unavailable. " +
- "Update Windows or install missing components to enable.");
- return new CommandResult
- {
- Stdout = string.Empty,
- Stderr =
- "Sandboxing is unavailable on this machine, so agent-started Windows " +
- "commands are blocked. Open the Sandbox page for fix instructions.",
- ExitCode = -1,
- TimedOut = false,
- DurationMs = 0,
- };
+ "[mxc] system.run UNCONTAINED: sandbox unavailable on this host. " +
+ "Commands will run on the host without containment. " +
+ "Update Windows to enable sandboxing.");
+ return await _hostFallback.RunAsync(request, ct);
}
if (!settings.SystemRunSandboxEnabled)
@@ -119,25 +108,16 @@ public async Task RunAsync(CommandRequest request, CancellationTo
catch (SandboxUnavailableException ex)
{
// Invalidate any cached availability — what we thought was available
- // turned out not to be. Next command re-probes. This handles the
- // case where MXC components were uninstalled (or wxc-exec moved)
- // between this NodeService starting and now.
+ // turned out not to be at runtime. Next command re-probes and the
+ // top-level !_isSandboxAvailable() branch will handle the fallback.
+ // We also fall back to host execution for THIS call instead of
+ // denying it, matching the policy from issue #494.
_invalidateAvailability?.Invoke();
_logger.Warn(
- $"[mxc] system.run DENIED (sandbox enabled but unavailable: {ex.Message}). " +
- "Disable the sandbox toggle in Debug to fall back to host execution.");
- return new CommandResult
- {
- Stdout = string.Empty,
- Stderr =
- "Sandboxing is enabled for system.run on this machine, but MXC is unavailable. " +
- $"Reason: {ex.Message}. " +
- "Update Windows or disable the system.run sandbox in the Debug page to run on host.",
- ExitCode = -1,
- TimedOut = false,
- DurationMs = 0,
- };
+ $"[mxc] system.run UNCONTAINED (sandbox enabled but unavailable at runtime: {ex.Message}). " +
+ "Falling back to host execution. Update Windows to enable sandboxing.");
+ return await _hostFallback.RunAsync(request, ct);
}
catch (OperationCanceledException)
{
@@ -196,7 +176,7 @@ private void LogSandboxRequest(
$"sandboxSettingsJson={settingsJson}; " +
$"shell={commandRequest.Shell ?? "powershell"}; " +
$"commandLength={commandRequest.Command?.Length ?? 0}; " +
- $"cwd={commandRequest.Cwd ?? ""}; " +
+ $"cwd={(string.IsNullOrEmpty(commandRequest.Cwd) ? "" : "")}; " +
$"envKeys=[{string.Join(",", commandRequest.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " +
$"timeoutMs={sandboxRequest.TimeoutMs}; maxOutputBytes={sandboxRequest.MaxOutputBytes?.ToString() ?? ""}; " +
$"policyJson={policyJson}";
@@ -205,11 +185,9 @@ private void LogSandboxRequest(
private static object ToSandboxSettingsDiagnostic(SettingsData settings, string settingsDirectoryPath)
{
- var preset = DetectPreset(settings);
return new
{
systemRunSandboxEnabled = settings.SystemRunSandboxEnabled,
- securityLevel = preset,
systemRunAllowOutbound = settings.SystemRunAllowOutbound,
sandboxClipboard = settings.SandboxClipboard,
sandboxDocumentsAccess = settings.SandboxDocumentsAccess,
@@ -226,38 +204,6 @@ private static object ToSandboxSettingsDiagnostic(SettingsData settings, string
};
}
- private static string DetectPreset(SettingsData settings)
- {
- if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: false, documents: null, downloads: null, desktop: null, clipboard: SandboxClipboardMode.None, timeoutMs: 30_000, maxOutputBytes: 4 * 1024 * 1024))
- return "LockedDown";
- if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: true, documents: SandboxFolderAccess.ReadOnly, downloads: SandboxFolderAccess.ReadOnly, desktop: SandboxFolderAccess.ReadOnly, clipboard: SandboxClipboardMode.Read, timeoutMs: 60_000, maxOutputBytes: 16 * 1024 * 1024))
- return "Balanced";
- if (MatchesPreset(settings, sandboxEnabled: true, allowOutbound: true, documents: SandboxFolderAccess.ReadWrite, downloads: SandboxFolderAccess.ReadWrite, desktop: SandboxFolderAccess.ReadWrite, clipboard: SandboxClipboardMode.Both, timeoutMs: 300_000, maxOutputBytes: 64 * 1024 * 1024))
- return "Permissive";
- return "Custom";
- }
-
- private static bool MatchesPreset(
- SettingsData settings,
- bool sandboxEnabled,
- bool allowOutbound,
- SandboxFolderAccess? documents,
- SandboxFolderAccess? downloads,
- SandboxFolderAccess? desktop,
- SandboxClipboardMode clipboard,
- int timeoutMs,
- long maxOutputBytes)
- {
- return settings.SystemRunSandboxEnabled == sandboxEnabled
- && settings.SystemRunAllowOutbound == allowOutbound
- && settings.SandboxDocumentsAccess == documents
- && settings.SandboxDownloadsAccess == downloads
- && settings.SandboxDesktopAccess == desktop
- && settings.SandboxClipboard == clipboard
- && settings.SandboxTimeoutMs == timeoutMs
- && settings.SandboxMaxOutputBytes == maxOutputBytes;
- }
-
private void LogSandboxResult(SandboxExecutionResult result)
{
LogMxcDiagnostic(
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfig.cs b/src/OpenClaw.Shared/Mxc/MxcConfig.cs
new file mode 100644
index 000000000..d7b690de5
--- /dev/null
+++ b/src/OpenClaw.Shared/Mxc/MxcConfig.cs
@@ -0,0 +1,171 @@
+using System.Text.Json.Serialization;
+
+namespace OpenClaw.Shared.Mxc;
+
+///
+/// POCO contract for the JSON config wxc-exec.exe consumes via
+/// --config-base64 or --config <file>. Shape mirrors the
+/// SDK's ContainerConfig (captured in tests/.../Mxc/Golden/*.json).
+///
+public sealed record MxcConfig
+{
+ [JsonPropertyName("version")]
+ public string Version { get; init; } = "0.4.0-alpha";
+
+ [JsonPropertyName("containerId")]
+ public required string ContainerId { get; init; }
+
+ [JsonPropertyName("containment")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Containment { get; init; }
+
+ [JsonPropertyName("process")]
+ public required MxcProcess Process { get; init; }
+
+ [JsonPropertyName("appContainer")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcAppContainer? AppContainer { get; init; }
+
+ [JsonPropertyName("filesystem")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcFilesystem? Filesystem { get; init; }
+
+ [JsonPropertyName("network")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcNetwork? Network { get; init; }
+
+ [JsonPropertyName("ui")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcUi? Ui { get; init; }
+
+ [JsonPropertyName("lifecycle")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcLifecycle? Lifecycle { get; init; }
+}
+
+public sealed record MxcProcess
+{
+ [JsonPropertyName("commandLine")]
+ public required string CommandLine { get; init; }
+
+ [JsonPropertyName("cwd")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Cwd { get; init; }
+
+ [JsonPropertyName("env")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public IReadOnlyList? Env { get; init; }
+
+ [JsonPropertyName("timeout")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public int? TimeoutMs { get; init; }
+}
+
+public sealed record MxcAppContainer
+{
+ [JsonPropertyName("capabilities")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? Capabilities { get; init; }
+
+ [JsonPropertyName("leastPrivilege")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? LeastPrivilege { get; init; }
+
+ [JsonPropertyName("ui")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public MxcBaseProcessUi? Ui { get; init; }
+}
+
+public sealed record MxcBaseProcessUi
+{
+ [JsonPropertyName("isolation")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Isolation { get; init; }
+
+ [JsonPropertyName("desktopSystemControl")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? DesktopSystemControl { get; init; }
+
+ [JsonPropertyName("systemSettings")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? SystemSettings { get; init; }
+
+ [JsonPropertyName("ime")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? Ime { get; init; }
+}
+
+public sealed record MxcFilesystem
+{
+ [JsonPropertyName("readonlyPaths")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? ReadonlyPaths { get; init; }
+
+ [JsonPropertyName("readwritePaths")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? ReadwritePaths { get; init; }
+
+ [JsonPropertyName("deniedPaths")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? DeniedPaths { get; init; }
+
+ [JsonPropertyName("clearPolicyOnExit")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? ClearPolicyOnExit { get; init; }
+}
+
+public sealed record MxcNetwork
+{
+ [JsonPropertyName("enforcementMode")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? EnforcementMode { get; init; }
+
+ [JsonPropertyName("defaultPolicy")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DefaultPolicy { get; init; }
+
+ [JsonPropertyName("allowedHosts")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? AllowedHosts { get; init; }
+
+ [JsonPropertyName("blockedHosts")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string[]? BlockedHosts { get; init; }
+}
+
+public sealed record MxcUi
+{
+ [JsonPropertyName("disable")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? Disable { get; init; }
+
+ [JsonPropertyName("clipboard")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Clipboard { get; init; }
+
+ [JsonPropertyName("injection")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? Injection { get; init; }
+}
+
+public sealed record MxcLifecycle
+{
+ [JsonPropertyName("destroyOnExit")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? DestroyOnExit { get; init; }
+
+ [JsonPropertyName("preservePolicy")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public bool? PreservePolicy { get; init; }
+}
+
+/// Result returned by after running wxc-exec.
+public sealed record MxcResult
+{
+ public bool Success { get; init; }
+ public int ExitCode { get; init; }
+ public string? Output { get; init; }
+ public string? Error { get; init; }
+ public bool TimedOut { get; init; }
+ public long DurationMs { get; init; }
+}
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
new file mode 100644
index 000000000..f5b7c6fe2
--- /dev/null
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -0,0 +1,453 @@
+using System.Text;
+
+namespace OpenClaw.Shared.Mxc;
+
+///
+/// Pure function: + scratch directory →
+/// for direct invocation of wxc-exec.exe.
+///
+///
+/// What this class does:
+///
+/// - Translates (from the Sandbox page) and the
+/// agent's request into the JSON shape wxc-exec consumes.
+/// - — grants every existing
+/// $PATH directory as readonly so command-line tools (git, node,
+/// python, ...) can be read from inside the sandbox. Drive roots skipped.
+/// - Scratch dir injection — adds the per-invocation scratch dir as
+/// readwrite and forces TEMP/TMP/TMPDIR at it so
+/// commands don't write to the user's real %TEMP%.
+/// - Cwd auto-grant — adds request.Cwd as readonly when not already
+/// covered by an allow grant. AppContainer does NOT auto-grant cwd, so this
+/// is required for commands to even start.
+/// - Defensive re-filter of allow lists against the deny list.
+/// - Shell command-line construction (cmd /S /C, powershell
+/// -EncodedCommand).
+///
+/// Env scrubbing happens upstream in SystemCapability.HandleRunAsync
+/// via ExecEnvSanitizer.Sanitize; this class doesn't scrub env.
+///
+public static class MxcConfigBuilder
+{
+ ///
+ /// Default per-process timeout when the caller doesn't supply one.
+ ///
+ public const int DefaultProcessTimeoutMs = 30_000;
+
+ ///
+ /// Build the for a sandboxed invocation.
+ ///
+ /// Capability invocation request.
+ /// Per-invocation scratch directory the executor created.
+ /// Optional explicit container id (test/diagnostic use). Random GUID when null.
+ /// Optional override for the PATH env-var contents (test use).
+ public static MxcConfig Build(
+ SandboxExecutionRequest request,
+ string scratchDir,
+ string? containerId = null,
+ string? pathEnvVar = null)
+ {
+ if (request is null) throw new ArgumentNullException(nameof(request));
+ if (string.IsNullOrWhiteSpace(scratchDir)) throw new ArgumentException("scratchDir required", nameof(scratchDir));
+
+ var policy = request.Policy;
+ var args = ParseSystemRunArgs(request.Args);
+
+ // commandLine — shell-quoted.
+ var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv);
+
+ // readonly = UI grants + every existing PATH dir (so tools like git,
+ // node, python can be read inside the sandbox). PATH drive roots are
+ // skipped by ResolvePathDirsForReadonly; shell startup roots are added
+ // explicitly below.
+ var roFromPolicy = (policy?.Filesystem?.ReadonlyPaths ?? Array.Empty()).ToList();
+ var pathDirs = ResolvePathDirsForReadonly(pathEnvVar);
+ foreach (var dir in pathDirs)
+ if (!roFromPolicy.Contains(dir, StringComparer.OrdinalIgnoreCase))
+ roFromPolicy.Add(dir);
+
+ // readwrite = UI grants + scratch dir.
+ var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList();
+ if (!rwFromPolicy.Contains(scratchDir, StringComparer.OrdinalIgnoreCase))
+ rwFromPolicy.Add(scratchDir);
+ AddShellStartupDriveRoots(roFromPolicy, roFromPolicy.Concat(rwFromPolicy).ToArray());
+
+ // denied list from policy (settings dir, ~/.ssh, browser profiles, ...).
+ var denied = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList();
+
+ // cwd auto-grant — AppContainer does not auto-grant the working
+ // directory. Give ungranted cwd read access so shells can start, but
+ // never silently upgrade it to write access; writes require an
+ // explicit readwrite folder grant.
+ if (!string.IsNullOrWhiteSpace(request.Cwd)
+ && !IsCoveredBy(request.Cwd, roFromPolicy)
+ && !IsCoveredBy(request.Cwd, rwFromPolicy))
+ {
+ if (!OverlapsAny(request.Cwd, denied))
+ roFromPolicy.Add(request.Cwd);
+ }
+
+ // Deny wins: strip any allow that overlaps a deny after the merges above.
+ roFromPolicy = FilterOutDenied(roFromPolicy, denied);
+ rwFromPolicy = FilterOutDenied(rwFromPolicy, denied);
+
+ // env — agent-supplied vars (already scrubbed upstream by
+ // ExecEnvSanitizer in SystemCapability) plus TEMP/TMP/TMPDIR forced
+ // to scratch.
+ var env = BuildEnv(request.Env, scratchDir, pathDirs);
+
+ // timeout — caller-supplied or default.
+ var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : DefaultProcessTimeoutMs;
+
+ // capabilities — only network for now.
+ var capabilities = new List();
+ if (policy?.Network?.AllowOutbound == true)
+ capabilities.Add("internetClient");
+
+ var network = new MxcNetwork
+ {
+ DefaultPolicy = policy?.Network?.AllowOutbound == true ? "allow" : "block",
+ EnforcementMode = "capabilities",
+ };
+
+ var topLevelUi = new MxcUi
+ {
+ Disable = true,
+ Clipboard = MapClipboard(policy?.Ui?.Clipboard ?? ClipboardPolicy.None),
+ Injection = false,
+ };
+
+ var appContainerUi = new MxcBaseProcessUi
+ {
+ Isolation = "container",
+ DesktopSystemControl = false,
+ SystemSettings = "none",
+ Ime = false,
+ };
+
+ return new MxcConfig
+ {
+ Version = MxcPolicyBuilder.SupportedPolicyVersion,
+ ContainerId = containerId ?? Guid.NewGuid().ToString("N"),
+ // Top-level "containment" is intentionally omitted; the SDK doesn't
+ // emit it either. Isolation lives in appContainer.ui.isolation.
+ Process = new MxcProcess
+ {
+ CommandLine = commandLine,
+ Cwd = string.IsNullOrWhiteSpace(request.Cwd) ? null : request.Cwd,
+ Env = env,
+ TimeoutMs = timeoutMs,
+ },
+ AppContainer = new MxcAppContainer
+ {
+ LeastPrivilege = false,
+ Capabilities = capabilities.ToArray(),
+ Ui = appContainerUi,
+ },
+ Filesystem = new MxcFilesystem
+ {
+ ReadonlyPaths = roFromPolicy.ToArray(),
+ ReadwritePaths = rwFromPolicy.ToArray(),
+ DeniedPaths = denied.ToArray(),
+ // SDK output didn't include clearPolicyOnExit even when the
+ // input policy had it set, so we omit it here too.
+ ClearPolicyOnExit = null,
+ },
+ Network = network,
+ Ui = topLevelUi,
+ Lifecycle = new MxcLifecycle
+ {
+ DestroyOnExit = true,
+ PreservePolicy = false,
+ },
+ };
+ }
+
+ ///
+ /// Walk PATH and return each existing directory as a readonly grant
+ /// candidate. Drive roots (e.g. C:\) are skipped so a misconfigured
+ /// PATH entry can't grant the entire system drive.
+ ///
+ public static List ResolvePathDirsForReadonly(string? pathEnvVar = null)
+ {
+ var path = pathEnvVar ?? Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
+ var pathDirs = path
+ .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
+ .Select(d => d.Trim().Trim('"'))
+ .Where(d => d.Length > 0)
+ .ToList();
+
+ var dirs = new List();
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var dir in pathDirs)
+ {
+ if (IsDriveRoot(dir)) continue;
+ try
+ {
+ if (!Directory.Exists(dir)) continue;
+ }
+ catch
+ {
+ continue;
+ }
+ if (seen.Add(dir)) dirs.Add(dir);
+ }
+
+ return dirs;
+ }
+
+ private static bool IsDriveRoot(string dir)
+ {
+ try
+ {
+ var root = Path.GetPathRoot(dir);
+ if (string.IsNullOrEmpty(root)) return false;
+ var trimmedDir = Path.TrimEndingDirectorySeparator(dir);
+ var trimmedRoot = Path.TrimEndingDirectorySeparator(root);
+ return string.Equals(trimmedDir, trimmedRoot, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static void AddShellStartupDriveRoots(List readonlyPaths, IEnumerable grantedPaths)
+ {
+ foreach (var path in grantedPaths)
+ AddDriveRoot(readonlyPaths, path);
+
+ AddDriveRoot(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows));
+ AddDriveRoot(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty);
+ }
+
+ private static void AddDriveRoot(List readonlyPaths, string path)
+ {
+ string? root;
+ try { root = Path.GetPathRoot(Path.GetFullPath(path)); }
+ catch { return; }
+
+ if (string.IsNullOrWhiteSpace(root))
+ return;
+
+ if (!readonlyPaths.Contains(root, StringComparer.OrdinalIgnoreCase))
+ readonlyPaths.Add(root);
+ }
+
+ ///
+ /// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit.
+ ///
+ ///
+ /// Env from the agent has already been scrubbed upstream in
+ /// SystemCapability.HandleRunAsync via
+ /// ExecEnvSanitizer.Sanitize (which rejects the whole command if
+ /// anything dangerous is present). We pass the surviving entries through
+ /// and force TEMP/TMP/TMPDIR to
+ /// so tools inside the sandbox don't write into the user's real %TEMP%.
+ ///
+ public static IReadOnlyList BuildEnv(
+ IReadOnlyDictionary? requestEnv,
+ string scratchDir,
+ IReadOnlyList? pathDirs = null)
+ {
+ // Windows env vars are case-insensitive — use OrdinalIgnoreCase so
+ // duplicate-case agent entries don't end up as separate strings.
+ var env = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (requestEnv is not null)
+ {
+ foreach (var (name, value) in requestEnv)
+ {
+ if (string.IsNullOrEmpty(name) || value is null) continue;
+ // Reject names with NUL/CR/LF/'=' so an agent can't smuggle
+ // a second KEY=VALUE pair into a single name field.
+ bool malformed = false;
+ foreach (var ch in name)
+ {
+ if (ch == '=' || ch == '\0' || ch == '\r' || ch == '\n')
+ {
+ malformed = true;
+ break;
+ }
+ }
+ if (malformed) continue;
+ env[name] = value;
+ }
+ }
+
+ env["TEMP"] = scratchDir;
+ env["TMP"] = scratchDir;
+ env["TMPDIR"] = scratchDir;
+ if (pathDirs is { Count: > 0 })
+ env["PATH"] = string.Join(Path.PathSeparator, pathDirs);
+
+ return env.Select(kvp => $"{kvp.Key}={kvp.Value}").ToList();
+ }
+
+ private static List FilterOutDenied(List allowed, List denied)
+ {
+ if (allowed.Count == 0 || denied.Count == 0) return allowed;
+ var normalizedDenied = denied
+ .Select(NormalizePath)
+ .Where(p => !string.IsNullOrEmpty(p))
+ .ToList();
+ return allowed
+ .Where(a =>
+ {
+ var na = NormalizePath(a);
+ if (string.IsNullOrEmpty(na)) return false;
+ foreach (var d in normalizedDenied)
+ if (PathsOverlap(na, d)) return false;
+ return true;
+ })
+ .ToList();
+ }
+
+ private static bool IsCoveredBy(string candidate, IEnumerable ancestors)
+ {
+ var nc = NormalizePath(candidate);
+ if (string.IsNullOrEmpty(nc)) return false;
+ foreach (var a in ancestors)
+ {
+ var na = NormalizePath(a);
+ if (string.IsNullOrEmpty(na)) continue;
+ if (IsSameOrNested(nc, na)) return true;
+ }
+ return false;
+ }
+
+ private static bool OverlapsAny(string candidate, IEnumerable paths)
+ {
+ var nc = NormalizePath(candidate);
+ if (string.IsNullOrEmpty(nc)) return false;
+ foreach (var path in paths)
+ {
+ var np = NormalizePath(path);
+ if (string.IsNullOrEmpty(np)) continue;
+ if (PathsOverlap(nc, np)) return true;
+ }
+ return false;
+ }
+
+ private static bool PathsOverlap(string left, string right) =>
+ IsSameOrNested(left, right) || IsSameOrNested(right, left);
+
+ private static bool IsSameOrNested(string path, string candidateParent)
+ {
+ if (string.Equals(path, candidateParent, StringComparison.OrdinalIgnoreCase)) return true;
+ return path.StartsWith(candidateParent + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
+ || path.StartsWith(candidateParent + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string NormalizePath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return string.Empty;
+ try { return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); }
+ catch { return path; }
+ }
+
+ private static string MapClipboard(ClipboardPolicy mode) => mode switch
+ {
+ ClipboardPolicy.Read => "read",
+ ClipboardPolicy.Write => "write",
+ ClipboardPolicy.All => "all",
+ _ => "none",
+ };
+
+ ///
+ /// Capability args envelope for system.run. Other capability shapes can add
+ /// their own parser here as they're rehosted.
+ ///
+ private static SystemRunArgs ParseSystemRunArgs(System.Text.Json.JsonElement args)
+ {
+ if (args.ValueKind != System.Text.Json.JsonValueKind.Object)
+ return new SystemRunArgs("", "powershell", Array.Empty());
+
+ string command = args.TryGetProperty("command", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.String
+ ? (c.GetString() ?? "") : "";
+ string shell = args.TryGetProperty("shell", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.String
+ ? (s.GetString() ?? "powershell") : "powershell";
+ string[] argv = Array.Empty();
+ if (args.TryGetProperty("args", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.Array)
+ {
+ argv = a.EnumerateArray()
+ .Where(e => e.ValueKind == System.Text.Json.JsonValueKind.String)
+ .Select(e => e.GetString() ?? "")
+ .ToArray();
+ }
+ return new SystemRunArgs(command, shell, argv);
+ }
+
+ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList Argv);
+}
+
+///
+/// Shell command-line construction for the sandboxed payload — wraps the
+/// agent's command in cmd.exe /S /C "..." or
+/// powershell.exe -EncodedCommand <utf16le-base64> so it can be
+/// passed verbatim to CreateProcessW inside the AppContainer.
+///
+internal static class ShellCommandLine
+{
+ public static string Build(string shell, string command, IReadOnlyList argv)
+ {
+ var normalized = (shell ?? "powershell").Trim().ToLowerInvariant();
+ return normalized switch
+ {
+ "cmd" => BuildCmd(command, argv),
+ "pwsh" or "powershell" => BuildPowershell(normalized == "pwsh" ? "pwsh.exe" : "powershell.exe", command, argv),
+ _ => BuildPowershell("powershell.exe", command, argv),
+ };
+ }
+
+ private static string BuildCmd(string command, IReadOnlyList argv)
+ {
+ // cmd /S /C " [args]" — /S strips outer quotes so cmd treats
+ // everything after /C as the command line verbatim.
+ var sb = new StringBuilder("cmd.exe /S /C \"");
+ sb.Append(command);
+ foreach (var a in argv)
+ {
+ sb.Append(' ');
+ sb.Append(QuoteForCmd(a));
+ }
+ sb.Append('"');
+ return sb.ToString();
+ }
+
+ private static string BuildPowershell(string exe, string command, IReadOnlyList argv)
+ {
+ // -EncodedCommand avoids quoting pitfalls entirely.
+ // We concatenate command + argv with spaces and let powershell parse it.
+ var sb = new StringBuilder(command);
+ foreach (var a in argv)
+ {
+ sb.Append(' ');
+ sb.Append(QuoteForPowershell(a));
+ }
+ var script = sb.ToString();
+ var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script));
+ return $"{exe} -NoProfile -NonInteractive -EncodedCommand {encoded}";
+ }
+
+ private static string QuoteForCmd(string arg)
+ {
+ // Note: `%VAR%` env-var expansion inside `cmd /S /C "..."` cannot be
+ // reliably suppressed via quoting (cmd parses % before applying quote
+ // rules). The cmd shell route is opt-in and runs inside the AppContainer
+ // with a controlled env, so the expansion target is sandbox-side, not
+ // host-side. Callers wanting verbatim arguments should use powershell
+ // (-EncodedCommand) which has no env-expansion ambiguity.
+ if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '"', '&', '|', '<', '>', '^', '(', ')', '%' }) < 0)
+ return arg;
+ return "\"" + arg.Replace("\"", "\"\"") + "\"";
+ }
+
+ private static string QuoteForPowershell(string arg)
+ {
+ if (arg.Length > 0 && arg.IndexOfAny(new[] { ' ', '\t', '\'', '"', '`', '$' }) < 0)
+ return arg;
+ return "'" + arg.Replace("'", "''") + "'";
+ }
+}
diff --git a/src/OpenClaw.Shared/Mxc/MxcExecutor.cs b/src/OpenClaw.Shared/Mxc/MxcExecutor.cs
new file mode 100644
index 000000000..e19c92d3f
--- /dev/null
+++ b/src/OpenClaw.Shared/Mxc/MxcExecutor.cs
@@ -0,0 +1,201 @@
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenClaw.Shared.Mxc;
+
+///
+/// Runs commands inside a Windows AppContainer via wxc-exec.exe. Throws
+/// on construction if the binary is absent.
+///
+public sealed class MxcExecutor
+{
+ private const int DefaultStdoutCapBytes = 40_000;
+ private const int DefaultStderrCapBytes = 5_000;
+
+ private readonly string _wxcExePath;
+ private readonly int _stdoutCapBytes;
+ private readonly int _stderrCapBytes;
+
+ private static readonly JsonSerializerOptions s_jsonOptions = new()
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public MxcExecutor(string wxcExePath, int? stdoutCapBytes = null, int? stderrCapBytes = null)
+ {
+ if (string.IsNullOrEmpty(wxcExePath)) throw new ArgumentException("wxcExePath required", nameof(wxcExePath));
+ if (!File.Exists(wxcExePath))
+ throw new FileNotFoundException($"wxc-exec.exe not found at: {wxcExePath}", wxcExePath);
+ _wxcExePath = wxcExePath;
+ _stdoutCapBytes = stdoutCapBytes is > 0 ? stdoutCapBytes.Value : DefaultStdoutCapBytes;
+ _stderrCapBytes = stderrCapBytes is > 0 ? stderrCapBytes.Value : DefaultStderrCapBytes;
+ }
+
+ public async Task RunAsync(
+ MxcConfig config,
+ CancellationToken ct = default,
+ bool experimental = false,
+ string? workingDirectory = null)
+ {
+ var json = JsonSerializer.Serialize(config, s_jsonOptions);
+ var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
+ var args = new List();
+ if (experimental) args.Add("--experimental");
+ args.Add("--config-base64");
+ args.Add(base64);
+ return await RunWithArgumentsAsync(args, ct, workingDirectory);
+ }
+
+ ///
+ /// Additive (OpenClaw): runs wxc-exec with --config <file> instead of
+ /// --config-base64. Use when the serialized config approaches the Windows
+ /// command-line limit (~32k chars). Caller owns the file lifetime.
+ ///
+ public Task RunWithConfigFileAsync(
+ string configFilePath,
+ CancellationToken ct = default,
+ bool experimental = false,
+ string? workingDirectory = null)
+ {
+ if (string.IsNullOrEmpty(configFilePath)) throw new ArgumentException("configFilePath required", nameof(configFilePath));
+ // Reject embedded quotes to avoid any argv-parsing ambiguity. NTFS allows
+ // names with most punctuation but disallows '"', so this is also a
+ // guard against malformed input rather than a real-world rejection.
+ if (configFilePath.IndexOf('"') >= 0)
+ throw new ArgumentException("configFilePath must not contain quote characters", nameof(configFilePath));
+ var args = new List();
+ if (experimental) args.Add("--experimental");
+ args.Add("--config");
+ args.Add(configFilePath);
+ return RunWithArgumentsAsync(args, ct, workingDirectory);
+ }
+
+ private async Task RunWithArgumentsAsync(
+ IReadOnlyList arguments,
+ CancellationToken ct,
+ string? workingDirectory)
+ {
+ using var process = new Process();
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = _wxcExePath,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+ if (!string.IsNullOrWhiteSpace(workingDirectory))
+ startInfo.WorkingDirectory = workingDirectory;
+ // ArgumentList avoids the manual-quoting trap that bites Process.Arguments
+ // (each entry is escaped per Win32 CommandLineToArgvW rules by the BCL).
+ foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
+ process.StartInfo = startInfo;
+
+ var stdoutBuilder = new StringBuilder();
+ var stderrBuilder = new StringBuilder();
+ // StringBuilder is not thread-safe; the async event handlers can fire
+ // concurrently with each other and with the post-kill ToString() read.
+ var outLock = new object();
+ var errLock = new object();
+
+ process.OutputDataReceived += (_, e) =>
+ {
+ if (e.Data is null) return;
+ lock (outLock)
+ {
+ if (stdoutBuilder.Length < _stdoutCapBytes * 2)
+ stdoutBuilder.AppendLine(e.Data);
+ }
+ };
+ process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data is null) return;
+ lock (errLock)
+ {
+ if (stderrBuilder.Length < _stderrCapBytes * 2)
+ stderrBuilder.AppendLine(e.Data);
+ }
+ };
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ bool completed;
+ try
+ {
+ await process.WaitForExitAsync(ct);
+ completed = true;
+ }
+ catch (OperationCanceledException)
+ {
+ completed = false;
+ }
+
+ if (!completed)
+ {
+ try { process.Kill(entireProcessTree: true); } catch { }
+ // WaitForExit() (sync) blocks until both stdout and stderr async
+ // readers have drained the redirected pipes. Without this the
+ // event handlers can still be appending while ToString() runs.
+ try { process.WaitForExit(); } catch { }
+ sw.Stop();
+ string capturedOut;
+ lock (outLock) { capturedOut = stdoutBuilder.ToString(); }
+ return new MxcResult
+ {
+ Success = false,
+ ExitCode = -1,
+ Output = Truncate(capturedOut, _stdoutCapBytes),
+ Error = "Execution was cancelled.",
+ TimedOut = true,
+ DurationMs = sw.ElapsedMilliseconds,
+ };
+ }
+
+ // Flush async readers before reading the StringBuilders.
+ try { process.WaitForExit(); } catch { }
+ sw.Stop();
+ string outRaw, errRaw;
+ lock (outLock) { outRaw = stdoutBuilder.ToString().Trim(); }
+ lock (errLock) { errRaw = stderrBuilder.ToString().Trim(); }
+ var stdout = Truncate(outRaw, _stdoutCapBytes);
+ var stderr = Truncate(errRaw, _stderrCapBytes);
+
+ return new MxcResult
+ {
+ Success = process.ExitCode == 0,
+ ExitCode = process.ExitCode,
+ Output = string.IsNullOrEmpty(stdout) ? null : stdout,
+ Error = string.IsNullOrEmpty(stderr) ? null : stderr,
+ TimedOut = false,
+ DurationMs = sw.ElapsedMilliseconds,
+ };
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ return new MxcResult
+ {
+ Success = false,
+ ExitCode = -1,
+ Error = $"Failed to launch wxc-exec.exe: {ex.Message}",
+ DurationMs = sw.ElapsedMilliseconds,
+ };
+ }
+ }
+
+ private static string Truncate(string text, int maxLength)
+ {
+ if (text.Length <= maxLength) return text;
+ return text[..maxLength] + $"\n\n... [TRUNCATED — showing first {maxLength} of {text.Length} chars]";
+ }
+}
diff --git a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs
index 721cc4a8d..2a001d1e2 100644
--- a/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcPolicyBuilder.cs
@@ -10,11 +10,11 @@ namespace OpenClaw.Shared.Mxc;
/// Policy decisions:
///
/// - readonlyPaths — populated from user-granted folders (Documents,
-/// Downloads, Desktop, custom). The Node bridge additionally merges PATH-specific
-/// tool directories so spawned shells can find git/node/python/etc.
-/// - readwritePaths — user-granted read+write folders. The Node bridge
-/// adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR to point
-/// at it, so the user's real %TEMP% stays out of reach.
+/// Downloads, Desktop, custom). The MXC config builder additionally merges
+/// PATH-specific tool directories so spawned shells can find git/node/python/etc.
+/// - readwritePaths — user-granted read+write folders. The MXC config
+/// builder adds a per-invocation scratch directory and rewrites TEMP/TMP/TMPDIR
+/// to point at it, so the user's real %TEMP% stays out of reach.
/// - deniedPaths — settings directory (protect MCP token, gateway
/// credentials, ElevenLabs key), ~/.ssh, and the common browser profile
/// roots (Chrome / Edge / Firefox / Brave). Always blocked regardless of grants.
@@ -25,8 +25,9 @@ namespace OpenClaw.Shared.Mxc;
public static class MxcPolicyBuilder
{
///
- /// Policy schema version. Per the @microsoft/mxc-sdk validator, this must be
- /// in the supported range (currently MIN 0.4.0-alpha, SUPPORTED 0.5.0-alpha).
+ /// Policy schema version. @microsoft/mxc-sdk 0.1.8 accepts 0.4.0-alpha
+ /// through 0.5.0-alpha; keep the documented 0.4.0-alpha schema until we
+ /// intentionally adopt a newer MXC policy contract.
///
public const string SupportedPolicyVersion = "0.4.0-alpha";
diff --git a/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs
deleted file mode 100644
index 4e92a0cba..000000000
--- a/src/OpenClaw.Shared/Mxc/OneShotAppContainerExecutor.cs
+++ /dev/null
@@ -1,344 +0,0 @@
-using System.Diagnostics;
-using System.Text;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace OpenClaw.Shared.Mxc;
-
-///
-/// Implements by spawning node.exe with
-/// tools/mxc/run-command.cjs, which calls
-/// @microsoft/mxc-sdk.spawnSandboxFromConfig({usePty:false}) to run the
-/// payload inside a one-shot AppContainer.
-///
-public sealed class OneShotAppContainerExecutor : ISandboxExecutor
-{
- public string Name => "mxc-oneshot-appc";
- public bool IsContained => true;
-
- private readonly MxcAvailability _availability;
- private readonly string _runCommandScriptPath;
- private readonly string _nodeExecutablePath;
- private readonly IOpenClawLogger _logger;
-
- /// Default cap on stdout/stderr returned to the host (4 MiB).
- public const long DefaultMaxOutputBytes = 4 * 1024 * 1024;
-
- ///
- /// Optional environment variable override for the Node executable used by the
- /// runner. Falls back to node.exe on PATH.
- ///
- public const string NodeExecutableOverrideEnvVar = "OPENCLAW_NODE_EXEC";
-
- public OneShotAppContainerExecutor(
- MxcAvailability availability,
- string runCommandScriptPath,
- IOpenClawLogger? logger = null,
- string? nodeExecutableOverride = null)
- {
- _availability = availability;
- _runCommandScriptPath = runCommandScriptPath;
- _logger = logger ?? NullLogger.Instance;
- _nodeExecutablePath = nodeExecutableOverride
- ?? Environment.GetEnvironmentVariable(NodeExecutableOverrideEnvVar)
- ?? ResolveExecutableOnPath("node.exe")
- ?? "node.exe";
- }
-
- public async Task ExecuteAsync(
- SandboxExecutionRequest request,
- CancellationToken ct = default)
- {
- if (!_availability.IsAppContainerAvailable)
- throw new SandboxUnavailableException(
- _availability.UnsupportedReasons.FirstOrDefault() ?? "AppContainer unavailable");
-
- if (!_availability.IsWxcExecResolvable)
- throw new SandboxUnavailableException("wxc-exec.exe not found");
-
- if (!File.Exists(_runCommandScriptPath))
- throw new SandboxUnavailableException(
- $"run-command.cjs not found at {_runCommandScriptPath}");
-
- // Per-request output cap. Default applies only when the caller doesn't
- // pass one. Used to be baked at construction; that caused stale floors
- // when the user lowered SandboxMaxOutputBytes after the executor was
- // built (Math.Max(stale, new) kept the larger old value).
- var capBytes = request.MaxOutputBytes is > 0 ? request.MaxOutputBytes.Value : DefaultMaxOutputBytes;
-
- var bridgeRequest = new BridgeRequest(
- CapabilityCommand: request.CapabilityCommand,
- Args: request.Args,
- Policy: request.Policy,
- Cwd: request.Cwd,
- Env: request.Env,
- TimeoutMs: request.TimeoutMs,
- MaxOutputBytes: capBytes,
- WxcExecPath: _availability.WxcExecPath);
-
- var requestJson = JsonSerializer.Serialize(bridgeRequest, BridgeJson);
- LogDiagnostic(
- "[mxc] bridge request prepared " +
- $"node={_nodeExecutablePath}; script={_runCommandScriptPath}; " +
- $"wxcExec={_availability.WxcExecPath ?? ""}; timeoutMs={request.TimeoutMs}; " +
- $"maxOutputBytes={capBytes}; cwd={request.Cwd ?? ""}; " +
- $"envKeys=[{string.Join(",", request.Env?.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase) ?? Enumerable.Empty())}]; " +
- $"requestBytes={Encoding.UTF8.GetByteCount(requestJson)}");
-
- var psi = new ProcessStartInfo
- {
- FileName = _nodeExecutablePath,
- UseShellExecute = false,
- RedirectStandardInput = true,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true,
- StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
- StandardOutputEncoding = Encoding.UTF8,
- StandardErrorEncoding = Encoding.UTF8,
- };
- psi.ArgumentList.Add(_runCommandScriptPath);
-
- var sw = Stopwatch.StartNew();
- using var process = new Process { StartInfo = psi };
-
- try
- {
- process.Start();
- LogDiagnostic($"[mxc] bridge process started pid={process.Id}");
- }
- catch (Exception ex)
- {
- throw new SandboxUnavailableException(
- $"Failed to start node.exe at '{_nodeExecutablePath}': {ex.Message}", ex);
- }
-
- // Caller-controlled timeout governs how long the bridge has to return.
- // Add a small grace so the bridge can clean up before we kill it.
- var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs + 5000 : 0;
- using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
- if (timeoutMs > 0)
- cts.CancelAfter(timeoutMs);
-
- try
- {
- await process.StandardInput.WriteAsync(requestJson.AsMemory(), cts.Token);
- await process.StandardInput.FlushAsync(cts.Token);
- process.StandardInput.Close();
- LogDiagnostic($"[mxc] bridge request written pid={process.Id}; bytes={Encoding.UTF8.GetByteCount(requestJson)}");
- }
- catch (OperationCanceledException)
- {
- // Either the caller cancelled or the timeout fired — in both cases
- // the spawned node + sandboxed payload must be killed so we don't
- // leak processes after the host gives up.
- KillProcessTree(process);
- throw;
- }
-
- // Envelope cap: caller-cap covers stdout AND stderr each. Allow up to
- // 2× that plus envelope/JSON overhead so a worst-case bridge response
- // (large stdout + large stderr) still fits without truncation.
- var envelopeCap = (capBytes * 2L) + (256L * 1024L);
-
- var stdoutTask = ReadCappedAsync(process.StandardOutput, envelopeCap, cts.Token);
- var stderrTask = ReadCappedAsync(process.StandardError, envelopeCap, cts.Token);
-
- bool timedOut = false;
- try
- {
- await process.WaitForExitAsync(cts.Token);
- }
- catch (OperationCanceledException)
- {
- // ALWAYS kill the process tree on cancellation, whether the source is
- // the caller's CancellationToken (agent abort) or our local timeout.
- // Without this the sandboxed payload keeps running after we return.
- KillProcessTree(process);
-
- // Distinguish caller cancel from local-timeout for the return path.
- if (!ct.IsCancellationRequested)
- timedOut = true;
- else
- throw;
- }
-
- var stdout = await stdoutTask;
- var stderr = await stderrTask;
-
- sw.Stop();
- if (!string.IsNullOrWhiteSpace(stderr))
- LogDiagnostic($"[mxc] bridge diagnostics pid={SafeProcessId(process)}; stderr={Truncate(stderr, 4000)}");
- LogDiagnostic(
- "[mxc] bridge process completed " +
- $"pid={SafeProcessId(process)}; exitCode={(process.HasExited ? process.ExitCode : -1)}; " +
- $"durationMs={sw.ElapsedMilliseconds}; timedOut={timedOut}; " +
- $"stdoutChars={stdout.Length}; stderrChars={stderr.Length}");
-
- if (timedOut)
- {
- return new SandboxExecutionResult(
- ExitCode: -1,
- Stdout: stdout,
- Stderr: stderr.Length > 0 ? stderr : "Sandboxed invocation timed out.",
- TimedOut: true,
- DurationMs: sw.ElapsedMilliseconds,
- ContainmentTag: "mxc",
- StructuredResult: null);
- }
-
- // Bridge writes a single JSON envelope to stdout on completion.
- if (TryParseBridgeResponse(stdout, out var response))
- {
- LogDiagnostic(
- "[mxc] bridge response parsed " +
- $"exitCode={response.ExitCode}; timedOut={response.TimedOut}; " +
- $"durationMs={response.DurationMs}; containment={response.ContainmentTag ?? "mxc"}; " +
- $"stdoutChars={response.Stdout?.Length ?? 0}; stderrChars={response.Stderr?.Length ?? 0}; " +
- $"structured={response.StructuredResult.HasValue}");
- return new SandboxExecutionResult(
- ExitCode: response.ExitCode,
- Stdout: response.Stdout,
- Stderr: response.Stderr,
- TimedOut: response.TimedOut,
- DurationMs: response.DurationMs == 0 ? sw.ElapsedMilliseconds : response.DurationMs,
- ContainmentTag: response.ContainmentTag ?? "mxc",
- StructuredResult: response.StructuredResult);
- }
-
- // Bridge crashed or returned malformed output. Surface as a sandbox failure
- // — node-side stderr likely has the diagnostic.
- _logger.Warn($"[mxc] bridge returned malformed output ({stdout.Length} bytes); stderr={Truncate(stderr, 200)}");
- return new SandboxExecutionResult(
- ExitCode: process.ExitCode,
- Stdout: stdout,
- Stderr: stderr,
- TimedOut: false,
- DurationMs: sw.ElapsedMilliseconds,
- ContainmentTag: "mxc",
- StructuredResult: null);
- }
-
- private static async Task ReadCappedAsync(StreamReader reader, long maxBytes, CancellationToken ct)
- {
- var sb = new StringBuilder();
- var buffer = new char[8192];
- long bytesRead = 0;
- while (true)
- {
- int read;
- try { read = await reader.ReadAsync(buffer, ct); }
- catch (OperationCanceledException) { break; }
- catch (IOException) { break; }
-
- if (read == 0)
- break;
-
- // Approximate cap: chars × 2 bytes upper bound for UTF-16.
- bytesRead += read * 2;
- sb.Append(buffer, 0, read);
- if (bytesRead >= maxBytes)
- {
- sb.Append("\n[output truncated]");
- break;
- }
- }
- return sb.ToString();
- }
-
- private static void KillProcessTree(Process process)
- {
- try
- {
- if (!process.HasExited)
- process.Kill(entireProcessTree: true);
- }
- catch { /* best-effort */ }
- }
-
- private void LogDiagnostic(string message)
- {
- _logger.Debug(message);
- Trace.WriteLine(message);
- }
-
- private static int SafeProcessId(Process process)
- {
- try { return process.Id; }
- catch { return -1; }
- }
-
- private static string? ResolveExecutableOnPath(string fileName)
- {
- var path = Environment.GetEnvironmentVariable("PATH");
- if (string.IsNullOrWhiteSpace(path))
- return null;
-
- foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
- {
- try
- {
- var candidate = Path.Combine(directory.Trim(), fileName);
- if (File.Exists(candidate))
- return candidate;
- }
- catch
- {
- // Ignore malformed PATH entries.
- }
- }
-
- return null;
- }
-
- private static bool TryParseBridgeResponse(string json, out BridgeResponse response)
- {
- response = default!;
- if (string.IsNullOrWhiteSpace(json))
- return false;
- try
- {
- response = JsonSerializer.Deserialize(json.Trim(), BridgeJson)!;
- return response is not null;
- }
- catch (JsonException)
- {
- return false;
- }
- }
-
- private static string Truncate(string s, int max) =>
- s.Length <= max ? s : string.Concat(s.AsSpan(0, max), "…");
-
- private static readonly JsonSerializerOptions BridgeJson = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- Converters =
- {
- // Enums must serialize as camelCase strings so @microsoft/mxc-sdk
- // (which expects "none" / "read" / "write" / "all") accepts them.
- new System.Text.Json.Serialization.JsonStringEnumConverter(
- System.Text.Json.JsonNamingPolicy.CamelCase),
- },
- };
-
- private sealed record BridgeRequest(
- string CapabilityCommand,
- JsonElement Args,
- SandboxPolicy Policy,
- string? Cwd,
- IReadOnlyDictionary? Env,
- int TimeoutMs,
- long MaxOutputBytes,
- string? WxcExecPath);
-
- private sealed record BridgeResponse(
- int ExitCode,
- string Stdout,
- string Stderr,
- bool TimedOut,
- long DurationMs,
- string? ContainmentTag,
- JsonElement? StructuredResult);
-}
diff --git a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs
index d78d1680a..c9a25c1e9 100644
--- a/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs
+++ b/src/OpenClaw.Shared/Mxc/SandboxPolicy.cs
@@ -4,7 +4,7 @@ namespace OpenClaw.Shared.Mxc;
/// Cross-platform sandbox policy expressing what a contained payload can access.
/// Mirrors the SandboxPolicy shape from @microsoft/mxc-sdk's
/// TypeScript types (see microsoft/mxc/sdk/src/types.ts). C# representation
-/// so we can build policy without going through the Node bridge.
+/// so we can build policy for direct wxc-exec.exe invocation.
///
public sealed record SandboxPolicy(
string Version,
@@ -40,13 +40,14 @@ public enum ClipboardPolicy
///
/// When is true, system.run
-/// is contained via MXC AppContainer. When MXC is unavailable on the host, the call
-/// is denied (no fallback). When the toggle is false, system.run runs on the
-/// host as before.
+/// is contained via MXC AppContainer. When MXC is unavailable on the host, system.run
+/// falls back to the host runner with a warning so older Windows builds are not
+/// completely blocked. When the toggle is false, system.run runs on the host
+/// as before.
///
public enum SandboxMode
{
- /// Sandbox required; fail-closed if unavailable.
+ /// Use MXC when available; otherwise fall back uncontained with a warning.
Enabled,
/// Bypass MXC entirely.
diff --git a/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs
deleted file mode 100644
index 953d45a5f..000000000
--- a/src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace OpenClaw.Shared.Mxc;
-
-///
-/// implementation that always throws
-/// . Used when MXC is not installed
-/// on the host so can still honor the
-/// toggle: when sandbox
-/// is enabled and MXC is absent, the invocation is denied (fail-closed)
-/// rather than silently routed to the host.
-///
-public sealed class UnavailableSandboxExecutor : ISandboxExecutor
-{
- public string Name => "mxc-unavailable";
- public bool IsContained => false;
-
- private readonly string _reason;
-
- public UnavailableSandboxExecutor(string reason)
- {
- _reason = reason;
- }
-
- public Task ExecuteAsync(
- SandboxExecutionRequest request,
- CancellationToken ct = default)
- {
- throw new SandboxUnavailableException(_reason);
- }
-}
diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
index 7d78a0987..1361fe0bf 100644
--- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
+++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj
@@ -78,6 +78,13 @@
$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\'))
+
+ arm64
+ x64
+ arm64
+ x64
+ $(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\bin\$(MxcArch)\
+
+
+
+
+
-
+
-
+
+
- <_MxcBridgeFiles Include="$(OpenClawRepoRoot)tools\mxc\**\*" />
- <_MxcSdkFiles Include="$(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\**\*" />
- <_MxcNodePtyFiles Include="$(OpenClawRepoRoot)node_modules\node-pty\**\*" />
- <_MxcNodeAddonApiFiles Include="$(OpenClawRepoRoot)node_modules\node-addon-api\**\*" />
- <_MxcSemverFiles Include="$(OpenClawRepoRoot)node_modules\semver\**\*" />
+ <_WxcExecFiles Include="$(MxcSdkBinDir)wxc-exec.exe" />
+ <_WxcExecFiles Include="$(MxcSdkBinDir)*.dll" />
-
-
-
-
-
+ Condition="'@(_WxcExecFiles)' != ''" />
-
+
+
- <_MxcPublishBridgeFiles Include="$(OpenClawRepoRoot)tools\mxc\**\*" />
- <_MxcPublishSdkFiles Include="$(OpenClawRepoRoot)node_modules\@microsoft\mxc-sdk\**\*" />
- <_MxcPublishNodePtyFiles Include="$(OpenClawRepoRoot)node_modules\node-pty\**\*" />
- <_MxcPublishNodeAddonApiFiles Include="$(OpenClawRepoRoot)node_modules\node-addon-api\**\*" />
- <_MxcPublishSemverFiles Include="$(OpenClawRepoRoot)node_modules\semver\**\*" />
+ <_WxcExecPublishFiles Include="$(MxcSdkBinDir)wxc-exec.exe" />
+ <_WxcExecPublishFiles Include="$(MxcSdkBinDir)*.dll" />
-
-
-
-
-
+ Condition="'@(_WxcExecPublishFiles)' != ''" />
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
index 510e07c05..517d37038 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
@@ -280,6 +280,14 @@
+
+
+
private void UpdateSandboxStatusCard()
{
@@ -159,8 +159,8 @@ private void UpdateSandboxStatusCard()
if (!available)
{
SandboxStatusIcon.Text = "⚠";
- SandboxStatusTitle.Text = "Node Sandbox unavailable — commands blocked";
- SandboxStatusSubtext.Text = "Containment isn't available on this PC.";
+ SandboxStatusTitle.Text = "Node Sandbox unavailable — commands run uncontained";
+ SandboxStatusSubtext.Text = "Containment isn't available on this PC, so commands run without sandbox protection.";
SandboxEnabledToggle.Visibility = Visibility.Collapsed;
return;
}
@@ -185,7 +185,7 @@ private void UpdateSandboxStatusCard()
/// Shows or hides the prominent "Sandbox unavailable" InfoBar based on availability,
/// and categorizes the failure mode so we can suggest a relevant action:
/// - Windows build/UBR too old → "Open Windows Update"
- /// - wxc-exec.exe or run-command.cjs missing → "Show install instructions"
+ /// - wxc-exec.exe missing → "Show install instructions"
/// - Anything else → no primary action, just the learn-more hyperlink
///
private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability availability)
@@ -205,15 +205,14 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability avai
r.Contains("Windows build", StringComparison.OrdinalIgnoreCase) ||
r.Contains("Windows UBR", StringComparison.OrdinalIgnoreCase));
- var isSetupIssue = !availability.IsWxcExecResolvable
- || availability.RunCommandScriptPath is null;
+ var isSetupIssue = !availability.IsWxcExecResolvable;
if (isWindowsIssue)
{
UnavailableActionBar.Title = "Your Windows version doesn't support sandboxing yet";
UnavailableActionMessage.Text =
- $"{reasonText}\n\nMXC sandboxing requires a recent Windows build with the AppContainer primitives shipped. " +
- "Install the latest Windows updates (or join the Windows Insider Program for the newest builds).";
+ $"{reasonText}\n\nCommands run uncontained on this machine — sandboxing requires a recent Windows build with the AppContainer primitives shipped. " +
+ "Install the latest Windows updates (or join the Windows Insider Program for the newest builds) to enable containment.";
UnavailablePrimaryButton.Content = "Open Windows Update";
UnavailablePrimaryButton.Tag = "windowsupdate";
UnavailablePrimaryButton.Visibility = Visibility.Visible;
@@ -222,16 +221,16 @@ private void UpdateUnavailableActionBar(OpenClaw.Shared.Mxc.MxcAvailability avai
{
UnavailableActionBar.Title = "Sandboxing components are missing";
UnavailableActionMessage.Text =
- $"{reasonText}\n\nThe MXC bridge script or the wxc-exec binary couldn't be located. " +
- "If this is a developer build, run `npm ci` at the repository root. " +
- "Otherwise reinstall the companion app.";
+ $"{reasonText}\n\nThe wxc-exec binary couldn't be located, so commands run uncontained. " +
+ "If this is a developer build, build the tray app so wxc-exec.exe is copied into the output folder. " +
+ "Otherwise reinstall the companion app to restore sandboxing.";
UnavailablePrimaryButton.Content = "Show install instructions";
UnavailablePrimaryButton.Tag = "install";
UnavailablePrimaryButton.Visibility = Visibility.Visible;
}
else
{
- UnavailableActionBar.Title = "Sandbox unavailable";
+ UnavailableActionBar.Title = "Sandbox unavailable — commands run uncontained";
UnavailableActionMessage.Text = reasonText;
UnavailablePrimaryButton.Visibility = Visibility.Collapsed;
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index fdb57e1c4..1b2ee2491 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -499,40 +499,33 @@ private void DetachClientHandlers(WindowsNodeClient client)
}
///
- /// Build the for system.run. Picks
- /// wrapping a one-shot AppContainer when MXC is
- /// available; falls back to with an explanatory
- /// log when it isn't. The choice respects :
- /// Required (default) fail-closes; BestEffort uses a host fallback inside MxcCommandRunner;
- /// Off bypasses MXC entirely.
+ /// Build the for system.run. Returns an
+ /// wrapping .
+ /// The runner honors
+ /// and, per issue #494, falls back to
+ /// at runtime when MXC isn't available on this host.
///
private ICommandRunner BuildSystemRunRunner()
{
var availability = _mxcAvailability ??= MxcAvailability.Probe(_logger);
var hostRunner = new LocalCommandRunner(_logger);
+ var executor = new DirectAppContainerExecutor(availability, _logger);
- ISandboxExecutor executor;
- if (!availability.HasAnyBackend || availability.RunCommandScriptPath is null)
+ if (availability.HasAnyBackend)
{
- // No MXC on this host. We still route through MxcCommandRunner so the
- // SystemRunSandboxEnabled toggle is honored: when ON, invocation is
- // denied (fail-closed); when OFF, the inner runner falls back to host.
- var reason = !availability.HasAnyBackend
- ? string.Join("; ", availability.UnsupportedReasons)
- : "tools/mxc/run-command.cjs not found";
- executor = new UnavailableSandboxExecutor(reason);
- _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable: {reason})");
- }
- else
- {
- executor = new OneShotAppContainerExecutor(
- availability,
- availability.RunCommandScriptPath,
- _logger);
_logger.Info(
$"[mxc] system.run runner = MxcCommandRunner " +
$"(executor={executor.Name}, sandboxEnabled={(_settings?.SystemRunSandboxEnabled ?? true)})");
}
+ else
+ {
+ // MXC unavailable on this host. The runner's top-level
+ // !_isSandboxAvailable() guard will route to the host fallback
+ // for every call; the executor is constructed only to satisfy
+ // the constructor contract and is never invoked.
+ var reason = string.Join("; ", availability.UnsupportedReasons);
+ _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable, commands will run uncontained: {reason})");
+ }
var settingsDirectory = SettingsManager.SettingsDirectoryPath;
return new MxcCommandRunner(
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs
new file mode 100644
index 000000000..24682c2e7
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/DirectAppContainerExecutorTests.cs
@@ -0,0 +1,83 @@
+using System.Text.Json;
+using Xunit;
+using OpenClaw.Shared;
+using OpenClaw.Shared.Mxc;
+
+namespace OpenClaw.Shared.Tests.Mxc;
+
+///
+/// Unit tests for that don't actually
+/// spawn wxc-exec. End-to-end smoke is covered by
+/// .
+///
+public class DirectAppContainerExecutorTests
+{
+ private static SandboxExecutionRequest NewRequest() => new(
+ CapabilityCommand: "system.run",
+ Args: JsonDocument.Parse("{\"command\":\"echo hi\",\"shell\":\"cmd\"}").RootElement,
+ Policy: new SandboxPolicy(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: Array.Empty(),
+ ReadonlyPaths: Array.Empty(),
+ DeniedPaths: Array.Empty(),
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(false, false),
+ Ui: new UiPolicy(false, ClipboardPolicy.None, false),
+ TimeoutMs: 30_000),
+ TimeoutMs: 30_000);
+
+ [Fact]
+ public async Task ExecuteAsync_AppContainerUnavailable_Throws()
+ {
+ var availability = new MxcAvailability(
+ isAppContainerAvailable: false,
+ isIsolationSessionAvailable: false,
+ isWxcExecResolvable: false,
+ wxcExecPath: null,
+ unsupportedReasons: new[] { "test reason" });
+ var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance);
+
+ var ex = await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest()));
+ Assert.Contains("test reason", ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WxcExecNotResolvable_Throws()
+ {
+ var availability = new MxcAvailability(
+ isAppContainerAvailable: true,
+ isIsolationSessionAvailable: false,
+ isWxcExecResolvable: false,
+ wxcExecPath: null,
+ unsupportedReasons: Array.Empty());
+ var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance);
+
+ var ex = await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest()));
+ Assert.Contains("wxc-exec.exe not found", ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WxcExecPathMissingOnDisk_Throws()
+ {
+ var availability = new MxcAvailability(
+ isAppContainerAvailable: true,
+ isIsolationSessionAvailable: false,
+ isWxcExecResolvable: true,
+ wxcExecPath: "C:\\does\\not\\exist\\wxc-exec.exe",
+ unsupportedReasons: Array.Empty());
+ var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance);
+
+ // MxcExecutor's ctor throws FileNotFoundException → wrapped in SandboxUnavailableException.
+ await Assert.ThrowsAsync(() => executor.ExecuteAsync(NewRequest()));
+ }
+
+ [Fact]
+ public void Name_IsStableForTelemetry()
+ {
+ var availability = new MxcAvailability(false, false, false, null, Array.Empty());
+ var executor = new DirectAppContainerExecutor(availability, NullLogger.Instance);
+ Assert.Equal("mxc-direct-appc", executor.Name);
+ Assert.True(executor.IsContained);
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json
new file mode 100644
index 000000000..43c731074
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-balanced.json
@@ -0,0 +1,49 @@
+{
+ "version": "0.4.0-alpha",
+ "containerId": "golden-balanced",
+ "lifecycle": {
+ "destroyOnExit": true,
+ "preservePolicy": false
+ },
+ "process": {},
+ "filesystem": {
+ "readwritePaths": [
+ "C:\\Golden\\Scratch"
+ ],
+ "readonlyPaths": [
+ "C:\\Golden\\Documents",
+ "C:\\Golden\\Downloads",
+ "C:\\Golden\\Desktop"
+ ],
+ "deniedPaths": [
+ "C:\\Golden\\Settings",
+ "C:\\Golden\\.ssh",
+ "C:\\Golden\\Chrome",
+ "C:\\Golden\\Edge",
+ "C:\\Golden\\Brave",
+ "C:\\Golden\\Firefox",
+ "C:\\Golden\\PSReadLine"
+ ]
+ },
+ "ui": {
+ "disable": true,
+ "clipboard": "read",
+ "injection": false
+ },
+ "network": {
+ "defaultPolicy": "allow",
+ "enforcementMode": "capabilities"
+ },
+ "appContainer": {
+ "leastPrivilege": false,
+ "capabilities": [
+ "internetClient"
+ ],
+ "ui": {
+ "isolation": "container",
+ "desktopSystemControl": false,
+ "systemSettings": "none",
+ "ime": false
+ }
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json
new file mode 100644
index 000000000..9b41d91f1
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-custom.json
@@ -0,0 +1,48 @@
+{
+ "version": "0.4.0-alpha",
+ "containerId": "golden-custom",
+ "lifecycle": {
+ "destroyOnExit": true,
+ "preservePolicy": false
+ },
+ "process": {},
+ "filesystem": {
+ "readwritePaths": [
+ "C:\\Golden\\Custom",
+ "C:\\Golden\\Scratch"
+ ],
+ "readonlyPaths": [
+ "C:\\Golden\\Documents"
+ ],
+ "deniedPaths": [
+ "C:\\Golden\\Settings",
+ "C:\\Golden\\.ssh",
+ "C:\\Golden\\Chrome",
+ "C:\\Golden\\Edge",
+ "C:\\Golden\\Brave",
+ "C:\\Golden\\Firefox",
+ "C:\\Golden\\PSReadLine"
+ ]
+ },
+ "ui": {
+ "disable": true,
+ "clipboard": "all",
+ "injection": false
+ },
+ "network": {
+ "defaultPolicy": "allow",
+ "enforcementMode": "capabilities"
+ },
+ "appContainer": {
+ "leastPrivilege": false,
+ "capabilities": [
+ "internetClient"
+ ],
+ "ui": {
+ "isolation": "container",
+ "desktopSystemControl": false,
+ "systemSettings": "none",
+ "ime": false
+ }
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json
new file mode 100644
index 000000000..21a3a9837
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-locked-down.json
@@ -0,0 +1,43 @@
+{
+ "version": "0.4.0-alpha",
+ "containerId": "golden-locked-down",
+ "lifecycle": {
+ "destroyOnExit": true,
+ "preservePolicy": false
+ },
+ "process": {},
+ "filesystem": {
+ "readwritePaths": [
+ "C:\\Golden\\Scratch"
+ ],
+ "readonlyPaths": [],
+ "deniedPaths": [
+ "C:\\Golden\\Settings",
+ "C:\\Golden\\.ssh",
+ "C:\\Golden\\Chrome",
+ "C:\\Golden\\Edge",
+ "C:\\Golden\\Brave",
+ "C:\\Golden\\Firefox",
+ "C:\\Golden\\PSReadLine"
+ ]
+ },
+ "ui": {
+ "disable": true,
+ "clipboard": "none",
+ "injection": false
+ },
+ "network": {
+ "defaultPolicy": "block",
+ "enforcementMode": "capabilities"
+ },
+ "appContainer": {
+ "leastPrivilege": false,
+ "capabilities": [],
+ "ui": {
+ "isolation": "container",
+ "desktopSystemControl": false,
+ "systemSettings": "none",
+ "ime": false
+ }
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json
new file mode 100644
index 000000000..0ee7957a0
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/Golden/sdk-config-permissive.json
@@ -0,0 +1,48 @@
+{
+ "version": "0.4.0-alpha",
+ "containerId": "golden-permissive",
+ "lifecycle": {
+ "destroyOnExit": true,
+ "preservePolicy": false
+ },
+ "process": {},
+ "filesystem": {
+ "readwritePaths": [
+ "C:\\Golden\\Documents",
+ "C:\\Golden\\Downloads",
+ "C:\\Golden\\Desktop",
+ "C:\\Golden\\Scratch"
+ ],
+ "readonlyPaths": [],
+ "deniedPaths": [
+ "C:\\Golden\\Settings",
+ "C:\\Golden\\.ssh",
+ "C:\\Golden\\Chrome",
+ "C:\\Golden\\Edge",
+ "C:\\Golden\\Brave",
+ "C:\\Golden\\Firefox",
+ "C:\\Golden\\PSReadLine"
+ ]
+ },
+ "ui": {
+ "disable": true,
+ "clipboard": "all",
+ "injection": false
+ },
+ "network": {
+ "defaultPolicy": "allow",
+ "enforcementMode": "capabilities"
+ },
+ "appContainer": {
+ "leastPrivilege": false,
+ "capabilities": [
+ "internetClient"
+ ],
+ "ui": {
+ "isolation": "container",
+ "desktopSystemControl": false,
+ "systemSettings": "none",
+ "ime": false
+ }
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs
index 7c098d2df..5e96bff00 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcAvailabilityTests.cs
@@ -38,12 +38,10 @@ public void Probe_Result_IsConsistent()
Assert.False(string.IsNullOrWhiteSpace(availability.WxcExecPath));
}
- // HasAnyBackend requires: a backend supported, wxc-exec resolvable, AND
- // the run-command.cjs bridge script present. All three must be true.
+ // HasAnyBackend requires: a backend supported AND wxc-exec resolvable.
Assert.Equal(
(availability.IsAppContainerAvailable || availability.IsIsolationSessionAvailable)
- && availability.IsWxcExecResolvable
- && availability.RunCommandScriptPath is not null,
+ && availability.IsWxcExecResolvable,
availability.HasAnyBackend);
}
@@ -56,14 +54,12 @@ public void Constructor_StoresAllFields()
isIsolationSessionAvailable: false,
isWxcExecResolvable: true,
wxcExecPath: "C:\\fake\\wxc-exec.exe",
- runCommandScriptPath: "C:\\fake\\run-command.cjs",
unsupportedReasons: reasons);
Assert.True(availability.IsAppContainerAvailable);
Assert.False(availability.IsIsolationSessionAvailable);
Assert.True(availability.IsWxcExecResolvable);
Assert.Equal("C:\\fake\\wxc-exec.exe", availability.WxcExecPath);
- Assert.Equal("C:\\fake\\run-command.cjs", availability.RunCommandScriptPath);
Assert.Single(availability.UnsupportedReasons);
Assert.True(availability.HasAnyBackend);
}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
index d4249ac5b..f2cdc7e7d 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
@@ -7,9 +7,9 @@ namespace OpenClaw.Shared.Tests.Mxc;
///
/// End-to-end smoke test for the MxcCommandRunner pipeline. Actually spawns
-/// node.exe + run-command.cjs + wxc-exec.exe to run a real shell payload
-/// inside an AppContainer. Gated by OPENCLAW_RUN_INTEGRATION=1 so it doesn't
-/// run by default on CI; matches the existing LocalCommandRunnerIntegrationTests pattern.
+/// wxc-exec.exe to run a real shell payload inside an AppContainer. Gated by
+/// OPENCLAW_RUN_INTEGRATION=1 so it doesn't run by default on CI; matches the
+/// existing LocalCommandRunnerIntegrationTests pattern.
///
/// Additionally skips (passes without running) when MXC is not available on the
/// host (e.g. older Windows UBR or wxc-exec.exe missing). Hosts with MXC enabled
@@ -17,8 +17,15 @@ namespace OpenClaw.Shared.Tests.Mxc;
///
public class MxcCommandRunnerIntegrationTests
{
- private static MxcCommandRunner? TryBuildRunner(bool sandboxEnabled = true)
+ private static MxcCommandRunner? TryBuildRunner(bool sandboxEnabled = true, Action? configure = null)
{
+ if (IsGitHubActions())
+ {
+ Console.WriteLine(
+ "[mxc-integration] SKIPPING: GitHub Actions does not provide reliable MXC/AppContainer filesystem filtering.");
+ return null;
+ }
+
var availability = MxcAvailability.Probe(NullLogger.Instance);
if (!availability.HasAnyBackend)
{
@@ -28,22 +35,31 @@ public class MxcCommandRunnerIntegrationTests
return null;
}
- if (availability.RunCommandScriptPath is null)
+ if (!IsNtfsBackedPath(AppContext.BaseDirectory, out var testRoot, out var testFormat))
{
- Console.WriteLine("[mxc-integration] SKIPPING: tools/mxc/run-command.cjs not resolvable.");
+ Console.WriteLine(
+ $"[mxc-integration] SKIPPING: test output path is on {testFormat} volume {testRoot}. " +
+ "MXC filesystem grants require NTFS-backed paths.");
return null;
}
- var executor = new OneShotAppContainerExecutor(
- availability,
- availability.RunCommandScriptPath,
- new ConsoleLogger());
+ if (!string.IsNullOrWhiteSpace(availability.WxcExecPath)
+ && !IsNtfsBackedPath(availability.WxcExecPath, out var wxcRoot, out var wxcFormat))
+ {
+ Console.WriteLine(
+ $"[mxc-integration] SKIPPING: wxc-exec.exe is on {wxcFormat} volume {wxcRoot}. " +
+ "Build or override OPENCLAW_WXC_EXEC from an NTFS-backed output folder.");
+ return null;
+ }
+
+ var executor = new DirectAppContainerExecutor(availability, new ConsoleLogger());
var settings = new SettingsData
{
SystemRunSandboxEnabled = sandboxEnabled,
SystemRunAllowOutbound = false,
};
+ configure?.Invoke(settings);
var hostFallback = new LocalCommandRunner(NullLogger.Instance);
@@ -125,5 +141,79 @@ public async Task SystemRun_PipelineSmokeTest_WithDenyPaths_ReturnsResult()
Assert.True(result.DurationMs > 0, $"Result should have measurable duration: {result.DurationMs}ms");
Assert.False(result.TimedOut, "Should not have timed out");
}
+
+ [IntegrationFact]
+ public async Task SystemRun_CmdDir_ReadsGrantedCustomFolder()
+ {
+ var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "openclaw-mxc-grant-smoke-" + Guid.NewGuid().ToString("N"))).FullName;
+ await File.WriteAllTextAsync(Path.Combine(dir, "sentinel.txt"), "hello");
+
+ try
+ {
+ if (!IsNtfsBackedPath(dir, out var grantRoot, out var grantFormat))
+ {
+ Console.WriteLine(
+ $"[mxc-integration] SKIPPING: custom grant path is on {grantFormat} volume {grantRoot}. " +
+ "MXC filesystem grants require NTFS-backed paths.");
+ return;
+ }
+
+ var runner = TryBuildRunner(configure: settings =>
+ {
+ settings.SandboxCustomFolders = new List
+ {
+ new() { Path = dir, Access = SandboxFolderAccess.ReadWrite },
+ };
+ });
+ if (runner is null) return; // skip — MXC unavailable on this host
+
+ var result = await runner.RunAsync(new CommandRequest
+ {
+ Command = "dir",
+ Shell = "cmd",
+ Cwd = dir,
+ TimeoutMs = 30_000,
+ });
+
+ Assert.True(
+ result.ExitCode == 0 && result.Stdout.Contains("sentinel.txt", StringComparison.OrdinalIgnoreCase),
+ $"ExitCode={result.ExitCode}\nStdout={result.Stdout}\nStderr={result.Stderr}\nTimedOut={result.TimedOut}\nDurationMs={result.DurationMs}\nDir={dir}");
+ }
+ finally
+ {
+ try { Directory.Delete(dir, recursive: true); } catch { }
+ }
+ }
+
+ private static bool IsNtfsBackedPath(string path, out string root, out string format)
+ {
+ root = string.Empty;
+ format = "unknown";
+
+ try
+ {
+ root = Path.GetPathRoot(Path.GetFullPath(path)) ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(root))
+ return false;
+
+ var drive = new DriveInfo(root);
+ if (!drive.IsReady)
+ {
+ format = "not-ready";
+ return false;
+ }
+
+ format = drive.DriveFormat;
+ return string.Equals(format, "NTFS", StringComparison.OrdinalIgnoreCase);
+ }
+ catch (Exception ex)
+ {
+ format = ex.GetType().Name;
+ return false;
+ }
+ }
+
+ private static bool IsGitHubActions()
+ => string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase);
}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
index 6b8787522..a91621b3f 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
@@ -34,19 +34,23 @@ private static MxcCommandRunner NewRunner(
}
[Fact]
- public async Task RunAsync_SandboxEnabled_DeniesWhenSandboxUnavailable()
+ public async Task RunAsync_SandboxEnabled_FallsBackToHostWhenExecutorIsUnavailable()
{
+ // Issue #494: when MXC is enabled but the executor reports unavailable at
+ // runtime, fall back to host instead of denying — older Windows users
+ // need their commands to run uncontained, with a warning in the UI.
var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "test reason" };
- var fallback = new FakeCommandRunner();
+ var fallback = new FakeCommandRunner
+ {
+ Result = new CommandResult { ExitCode = 0, Stdout = "host-ran" },
+ };
var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true));
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
- Assert.Equal(-1, result.ExitCode);
- Assert.Contains("Sandboxing is enabled", result.Stderr);
- Assert.Contains("test reason", result.Stderr);
- // Fallback must NOT have been called.
- Assert.Null(fallback.LastRequest);
+ Assert.Equal(0, result.ExitCode);
+ Assert.Equal("host-ran", result.Stdout);
+ Assert.NotNull(fallback.LastRequest);
}
[Fact]
@@ -68,12 +72,13 @@ public async Task RunAsync_SandboxDisabled_AlwaysRoutesToHost()
}
[Fact]
- public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOff()
+ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOff()
{
- // The UI hides the toggle when MXC is unavailable. A persisted toggle=OFF
- // (from a previous run or different machine) must NOT cause the runner to
- // silently route to host — the page says "commands blocked" and the
- // runner must match that promise.
+ // Issue #494: on hosts where MXC is unavailable (Windows 10 / old build /
+ // missing wxc-exec), the agent must still be able to run commands.
+ // Route through the host runner; the Sandbox page UI shows a clear
+ // "running uncontained" warning so the user knows the protection
+ // boundary isn't active.
var executor = new FakeSandboxExecutor();
var fallback = new FakeCommandRunner
{
@@ -87,21 +92,23 @@ public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOff()
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
- Assert.Equal(-1, result.ExitCode);
- Assert.Contains("unavailable", result.Stderr, StringComparison.OrdinalIgnoreCase);
- Assert.Contains("blocked", result.Stderr, StringComparison.OrdinalIgnoreCase);
- // Neither the sandbox executor nor the host fallback should have run.
+ Assert.Equal(0, result.ExitCode);
+ Assert.Equal("host", result.Stdout);
+ Assert.NotNull(fallback.LastRequest);
Assert.Null(executor.LastRequest);
- Assert.Null(fallback.LastRequest);
}
[Fact]
- public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOn()
+ public async Task RunAsync_MxcUnavailable_FallsBackToHost_WithSandboxToggleOn()
{
- // Same as the toggle-off variant but with toggle=ON. The unavailability
- // short-circuit should fire BEFORE we get to the executor path.
+ // Same as the toggle-off variant — the !_isSandboxAvailable() short-circuit
+ // fires before either the toggle check or the executor path, and both
+ // routes lead to the host fallback.
var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "MXC missing" };
- var fallback = new FakeCommandRunner();
+ var fallback = new FakeCommandRunner
+ {
+ Result = new CommandResult { ExitCode = 0, Stdout = "host" },
+ };
var runner = NewRunner(
executor,
fallback,
@@ -110,9 +117,10 @@ public async Task RunAsync_MxcUnavailable_BlocksEvenWithSandboxToggleOn()
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
- Assert.Equal(-1, result.ExitCode);
- Assert.Contains("unavailable", result.Stderr, StringComparison.OrdinalIgnoreCase);
- Assert.Null(fallback.LastRequest);
+ Assert.Equal(0, result.ExitCode);
+ Assert.Equal("host", result.Stdout);
+ Assert.NotNull(fallback.LastRequest);
+ Assert.Null(executor.LastRequest);
}
[Fact]
@@ -180,14 +188,17 @@ public async Task RunAsync_SandboxEnabled_DoesNotFallBack_OnSandboxFailure()
}
[Fact]
- public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCache()
+ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCacheAndFallsBack()
{
- // When the executor throws SandboxUnavailableException, the runner should
- // invoke its invalidate-availability callback so the next command re-probes.
- // Handles the case where MXC components were removed between this NodeService
- // starting up and the agent invoking a command.
+ // When the executor throws SandboxUnavailableException at runtime the
+ // runner invokes its invalidate-availability callback (so the next
+ // command re-probes) AND falls back to the host runner for this call
+ // (issue #494 — don't strand the agent).
var executor = new FakeSandboxExecutor { ThrowsUnavailable = true, UnavailableReason = "wxc-exec went missing" };
- var fallback = new FakeCommandRunner();
+ var fallback = new FakeCommandRunner
+ {
+ Result = new CommandResult { ExitCode = 0, Stdout = "host" },
+ };
var invalidationCount = 0;
var runner = new MxcCommandRunner(
executor,
@@ -200,9 +211,10 @@ public async Task RunAsync_SandboxUnavailableException_InvalidatesAvailabilityCa
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
- Assert.Equal(-1, result.ExitCode);
+ Assert.Equal(0, result.ExitCode);
+ Assert.Equal("host", result.Stdout);
Assert.Equal(1, invalidationCount);
- Assert.Contains("wxc-exec went missing", result.Stderr);
+ Assert.NotNull(fallback.LastRequest);
}
[Fact]
@@ -339,7 +351,6 @@ public async Task RunAsync_LogsSandboxSettingsSnapshotAndPolicy()
var requestLog = Assert.Single(logger.DebugMessages, m => m.Contains("system.run sandbox request", StringComparison.Ordinal));
Assert.Contains("sandboxSettingsJson=", requestLog);
- Assert.Contains("\"securityLevel\":\"Custom\"", requestLog);
Assert.Contains("\"systemRunAllowOutbound\":true", requestLog);
Assert.Contains("\"sandboxClipboard\":\"both\"", requestLog);
Assert.Contains("\"path\":\"C:\\\\Code\\\\repo\"", requestLog);
@@ -366,16 +377,25 @@ public async Task RunAsync_PolicyTimeoutCapsAgentTimeout()
}
[Fact]
- public async Task RunAsync_UnavailableExecutor_DeniesWithReason()
+ public async Task RunAsync_UnavailableExecutor_FallsBackToHost()
{
- var executor = new UnavailableSandboxExecutor("test: MXC not installed");
- var fallback = new FakeCommandRunner();
+ // Issue #494: executor reports unavailable at runtime → fall back to
+ // host runner with a warning, not a -1 deny.
+ var executor = new FakeSandboxExecutor
+ {
+ ThrowsUnavailable = true,
+ UnavailableReason = "test: MXC not installed",
+ };
+ var fallback = new FakeCommandRunner
+ {
+ Result = new CommandResult { ExitCode = 0, Stdout = "host" },
+ };
var runner = NewRunner(executor, fallback, NewSettings(sandboxEnabled: true));
var result = await runner.RunAsync(new CommandRequest { Command = "echo hi" });
- Assert.Equal(-1, result.ExitCode);
- Assert.Contains("MXC not installed", result.Stderr);
- Assert.Null(fallback.LastRequest); // never delegated to host
+ Assert.Equal(0, result.ExitCode);
+ Assert.Equal("host", result.Stdout);
+ Assert.NotNull(fallback.LastRequest);
}
}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
new file mode 100644
index 000000000..32b85ec9d
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
@@ -0,0 +1,492 @@
+using System.Text.Json;
+using Xunit;
+using OpenClaw.Shared.Mxc;
+
+namespace OpenClaw.Shared.Tests.Mxc;
+
+///
+/// Tests for . Includes:
+///
+/// - Golden tests vs SDK output captured by tools/mxc/dump-sdk-config.cjs.
+/// - Per-Sandbox-UI-setting audit tests.
+/// - Round-trip JSON shape (camelCase, no orphan fields).
+/// - Env scrub case-insensitivity.
+/// - ResolveToolDirsFromPath via synthetic PATH.
+///
+///
+public class MxcConfigBuilderTests
+{
+ private const string GoldenContainerId = "golden-test";
+
+ // The harness used these placeholder paths; mirror them here so the C#
+ // builder's filesystem grants match the golden fixtures.
+ private static class P
+ {
+ public const string Documents = "C:\\Golden\\Documents";
+ public const string Downloads = "C:\\Golden\\Downloads";
+ public const string Desktop = "C:\\Golden\\Desktop";
+ public const string Custom = "C:\\Golden\\Custom";
+ public const string Settings = "C:\\Golden\\Settings";
+ public const string Ssh = "C:\\Golden\\.ssh";
+ public const string Chrome = "C:\\Golden\\Chrome";
+ public const string Edge = "C:\\Golden\\Edge";
+ public const string Brave = "C:\\Golden\\Brave";
+ public const string Firefox = "C:\\Golden\\Firefox";
+ public const string PsRead = "C:\\Golden\\PSReadLine";
+ public const string Scratch = "C:\\Golden\\Scratch";
+ }
+
+ private static readonly string[] AlwaysDenied =
+ {
+ P.Settings, P.Ssh, P.Chrome, P.Edge, P.Brave, P.Firefox, P.PsRead,
+ };
+
+ private static SandboxPolicy LockedDownPolicy() => new(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: Array.Empty(),
+ ReadonlyPaths: Array.Empty(),
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(AllowOutbound: false, AllowLocalNetwork: false),
+ Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.None, AllowInputInjection: false),
+ TimeoutMs: 30_000);
+
+ private static SandboxPolicy BalancedPolicy() => new(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: Array.Empty(),
+ ReadonlyPaths: new[] { P.Documents, P.Downloads, P.Desktop },
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false),
+ Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.Read, AllowInputInjection: false),
+ TimeoutMs: 60_000);
+
+ private static SandboxPolicy PermissivePolicy() => new(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: new[] { P.Documents, P.Downloads, P.Desktop },
+ ReadonlyPaths: Array.Empty(),
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false),
+ Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.All, AllowInputInjection: false),
+ TimeoutMs: 300_000);
+
+ private static SandboxPolicy CustomPolicy() => new(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: new[] { P.Custom },
+ ReadonlyPaths: new[] { P.Documents },
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(AllowOutbound: true, AllowLocalNetwork: false),
+ Ui: new UiPolicy(AllowWindows: false, Clipboard: ClipboardPolicy.All, AllowInputInjection: false),
+ TimeoutMs: 60_000);
+
+ private static SandboxExecutionRequest RequestFor(SandboxPolicy policy) => new(
+ CapabilityCommand: "system.run",
+ Args: JsonDocument.Parse("{}").RootElement,
+ Policy: policy,
+ TimeoutMs: 0); // explicitly zero so timeout in builder uses request.TimeoutMs=0 → default 30s
+
+ [Theory]
+ [InlineData("locked-down", "LockedDown")]
+ [InlineData("balanced", "Balanced")]
+ [InlineData("permissive", "Permissive")]
+ [InlineData("custom", "Custom")]
+ public void BuiltConfig_MatchesSdkGolden(string preset, string presetMethod)
+ {
+ SandboxPolicy policy = presetMethod switch
+ {
+ "LockedDown" => LockedDownPolicy(),
+ "Balanced" => BalancedPolicy(),
+ "Permissive" => PermissivePolicy(),
+ _ => CustomPolicy(),
+ };
+
+ // The golden test reproduces the exact harness recipe: no commandLine,
+ // no env, no cwd, no PATH-resolved tool dirs. We pass an empty PATH and
+ // an empty agent env. The harness stripped process.* (commandLine, cwd,
+ // env, timeout) too; do the same on the C# side before comparing.
+ var request = RequestFor(policy);
+ var config = MxcConfigBuilder.Build(
+ request,
+ scratchDir: P.Scratch,
+ containerId: GoldenContainerId,
+ pathEnvVar: "");
+
+ // Strip the process bag the harness dropped, plus normalize containerId.
+ var stripped = config with
+ {
+ ContainerId = $"golden-{preset}",
+ Process = config.Process with { CommandLine = "", Cwd = null, Env = null, TimeoutMs = null },
+ Filesystem = config.Filesystem is null ? null : config.Filesystem with
+ {
+ ReadonlyPaths = config.Filesystem.ReadonlyPaths?
+ .Where(p => !IsDriveRoot(p))
+ .ToArray(),
+ },
+ };
+
+ var actualJson = JsonSerializer.Serialize(stripped, new JsonSerializerOptions
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ });
+
+ // Drop "commandLine":"" because the SDK output has the process bag completely empty.
+ using var actualDoc = JsonDocument.Parse(actualJson);
+ var actualObj = JsonObjectNode.FromJsonElement(actualDoc.RootElement);
+ if (actualObj.Contains("process") && actualObj["process"] is JsonObjectNode procNode &&
+ procNode.TryGetString("commandLine", out var cmd) && cmd == string.Empty)
+ {
+ procNode.Remove("commandLine");
+ }
+
+ var goldenPath = ResolveGoldenPath(preset);
+ var expectedJson = File.ReadAllText(goldenPath);
+ using var expectedDoc = JsonDocument.Parse(expectedJson);
+ var expectedObj = JsonObjectNode.FromJsonElement(expectedDoc.RootElement);
+
+ // Deep structural equality, tolerant of property order.
+ AssertJsonEqual(expectedObj, actualObj, path: "$");
+ }
+
+ private static string ResolveGoldenPath(string preset)
+ {
+ var local = Path.Combine(AppContext.BaseDirectory, "Mxc", "Golden", $"sdk-config-{preset}.json");
+ if (File.Exists(local)) return local;
+ // Fallback: walk up from the assembly dir to find the source path.
+ var dir = AppContext.BaseDirectory;
+ for (int i = 0; i < 6 && dir is not null; i++)
+ {
+ var probe = Path.Combine(dir, "tests", "OpenClaw.Shared.Tests", "Mxc", "Golden", $"sdk-config-{preset}.json");
+ if (File.Exists(probe)) return probe;
+ dir = Directory.GetParent(dir)?.FullName;
+ }
+ throw new FileNotFoundException($"Golden not found for preset {preset}");
+ }
+
+ [Fact]
+ public void Build_OutboundOn_AddsInternetClientCapability()
+ {
+ var policy = BalancedPolicy();
+ var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: "");
+ Assert.Contains("internetClient", config.AppContainer!.Capabilities!);
+ Assert.Equal("allow", config.Network!.DefaultPolicy);
+ }
+
+ [Fact]
+ public void Build_OutboundOff_OmitsInternetClient_AndNetworkBlocks()
+ {
+ var policy = LockedDownPolicy();
+ var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: "");
+ Assert.DoesNotContain("internetClient", config.AppContainer!.Capabilities!);
+ Assert.Equal("block", config.Network!.DefaultPolicy);
+ }
+
+ [Theory]
+ [InlineData(ClipboardPolicy.None, "none")]
+ [InlineData(ClipboardPolicy.Read, "read")]
+ [InlineData(ClipboardPolicy.Write, "write")]
+ [InlineData(ClipboardPolicy.All, "all")]
+ public void Build_ClipboardMode_RoundTripsToWxcExecString(ClipboardPolicy mode, string expected)
+ {
+ var policy = LockedDownPolicy() with { Ui = new UiPolicy(false, mode, false) };
+ var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: "");
+ Assert.Equal(expected, config.Ui!.Clipboard);
+ }
+
+ [Fact]
+ public void Build_AddsScratchDirToReadwritePaths()
+ {
+ var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: "");
+ Assert.Contains(P.Scratch, config.Filesystem!.ReadwritePaths!);
+ }
+
+ [Fact]
+ public void Build_OverridesTempEnvVarsToScratch()
+ {
+ var request = RequestFor(BalancedPolicy()) with
+ {
+ Env = new Dictionary
+ {
+ ["TEMP"] = "C:\\real-temp",
+ ["TMP"] = "C:\\real-tmp",
+ ["TMPDIR"] = "C:\\real-tmpdir",
+ },
+ };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+ var env = config.Process.Env!;
+ Assert.Contains($"TEMP={P.Scratch}", env);
+ Assert.Contains($"TMP={P.Scratch}", env);
+ Assert.Contains($"TMPDIR={P.Scratch}", env);
+ }
+
+ [Fact]
+ public void Build_AutoGrantsCwdAsReadonly_WhenNotAlreadyCovered()
+ {
+ var request = RequestFor(BalancedPolicy()) with { Cwd = "C:\\unrelated\\workdir" };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+ Assert.Contains("C:\\unrelated\\workdir", config.Filesystem!.ReadonlyPaths!);
+ Assert.DoesNotContain("C:\\unrelated\\workdir", config.Filesystem!.ReadwritePaths!);
+ }
+
+ [Fact]
+ public void Build_DoesNotDowngradeCwd_WhenAlreadyCoveredByReadwrite()
+ {
+ var policy = PermissivePolicy(); // Documents already in readwrite
+ var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+
+ Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!);
+ Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadwritePaths!);
+ }
+
+ [Fact]
+ public void Build_DoesNotAutoGrantCwd_WhenAlreadyCovered()
+ {
+ var policy = BalancedPolicy(); // Documents already in readonly
+ var request = RequestFor(policy) with { Cwd = Path.Combine(P.Documents, "subfolder") };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+ // Should not have added the subfolder explicitly (parent already grants).
+ Assert.DoesNotContain(Path.Combine(P.Documents, "subfolder"), config.Filesystem!.ReadonlyPaths!);
+ }
+
+ [Fact]
+ public void Build_DoesNotAutoGrantCwd_WhenOverlapsDenied()
+ {
+ var policy = BalancedPolicy();
+ var request = RequestFor(policy) with { Cwd = Path.Combine(P.Ssh, "keys") };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+ Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadonlyPaths!);
+ }
+
+ [Fact]
+ public void ResolvePathDirsForReadonly_ReturnsExistingPathDirs()
+ {
+ // Synthesize an existing dir on PATH; ensure it shows up as a readonly
+ // grant candidate. No tool-name filter — every existing PATH dir
+ // counts (mirrors the SDK's getAvailableToolsPolicy behavior).
+ var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-tool-test-" + Guid.NewGuid().ToString("N"))).FullName;
+ try
+ {
+ var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: tempDir);
+ Assert.Contains(tempDir, dirs);
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void Build_SynthesizesPathEnvFromGrantedPathDirs()
+ {
+ var tempDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "mxc-path-env-test-" + Guid.NewGuid().ToString("N"))).FullName;
+ try
+ {
+ var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: tempDir);
+ Assert.Contains($"PATH={tempDir}", config.Process.Env!);
+ Assert.Contains(tempDir, config.Filesystem!.ReadonlyPaths!);
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void Build_AddsDriveRootReadonlyForGrantedFolderTraversal()
+ {
+ var policy = new SandboxPolicy(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: new[] { "C:\\workspace\\out" },
+ ReadonlyPaths: Array.Empty(),
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(false, false),
+ Ui: new UiPolicy(false, ClipboardPolicy.None, false),
+ TimeoutMs: 30_000);
+
+ var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: "");
+ Assert.Contains("C:\\", config.Filesystem!.ReadonlyPaths!);
+ }
+
+ [Fact]
+ public void Build_DoesNotTreatReadwriteChildAsCoveringParentCwd()
+ {
+ var policy = new SandboxPolicy(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: new[] { "C:\\workspace\\out" },
+ ReadonlyPaths: Array.Empty(),
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(false, false),
+ Ui: new UiPolicy(false, ClipboardPolicy.None, false),
+ TimeoutMs: 30_000);
+
+ var config = MxcConfigBuilder.Build(RequestFor(policy) with { Cwd = "C:\\workspace" }, P.Scratch, pathEnvVar: "");
+ Assert.Contains("C:\\workspace", config.Filesystem!.ReadonlyPaths!);
+ Assert.DoesNotContain("C:\\workspace", config.Filesystem!.ReadwritePaths!);
+ }
+
+ [Fact]
+ public void ResolvePathDirsForReadonly_SkipsNonExistentDirs()
+ {
+ var fake = Path.Combine(Path.GetTempPath(), "definitely-not-real-xyzqq-" + Guid.NewGuid().ToString("N"));
+ var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: fake);
+ Assert.Empty(dirs);
+ }
+
+ [Fact]
+ public void ResolvePathDirsForReadonly_SkipsDriveRoots()
+ {
+ var dirs = MxcConfigBuilder.ResolvePathDirsForReadonly(pathEnvVar: "C:\\");
+ Assert.Empty(dirs);
+ }
+
+ [Fact]
+ public void Build_DefensiveFilterStripsAllowEntriesOverlappingDenied()
+ {
+ // Caller (somehow) provides a custom RW folder pointing inside ~/.ssh.
+ var policy = new SandboxPolicy(
+ Version: MxcPolicyBuilder.SupportedPolicyVersion,
+ Filesystem: new FilesystemPolicy(
+ ReadwritePaths: new[] { Path.Combine(P.Ssh, "keys") },
+ ReadonlyPaths: new[] { Path.Combine(P.Chrome, "Profile 1") },
+ DeniedPaths: AlwaysDenied,
+ ClearPolicyOnExit: true),
+ Network: new NetworkPolicy(false, false),
+ Ui: new UiPolicy(false, ClipboardPolicy.None, false),
+ TimeoutMs: 30_000);
+ var config = MxcConfigBuilder.Build(RequestFor(policy), P.Scratch, pathEnvVar: "");
+ Assert.DoesNotContain(Path.Combine(P.Ssh, "keys"), config.Filesystem!.ReadwritePaths!);
+ Assert.DoesNotContain(Path.Combine(P.Chrome, "Profile 1"), config.Filesystem!.ReadonlyPaths!);
+ }
+
+ [Fact]
+ public void Build_TimeoutDefaultsTo30sWhenRequestZero()
+ {
+ var config = MxcConfigBuilder.Build(RequestFor(BalancedPolicy()), P.Scratch, pathEnvVar: "");
+ Assert.Equal(30_000, config.Process.TimeoutMs);
+ }
+
+ [Fact]
+ public void Build_TimeoutHonorsRequestValue()
+ {
+ var req = RequestFor(BalancedPolicy()) with { TimeoutMs = 12_345 };
+ var config = MxcConfigBuilder.Build(req, P.Scratch, pathEnvVar: "");
+ Assert.Equal(12_345, config.Process.TimeoutMs);
+ }
+
+ // ---- helpers for tolerant JSON comparison ----
+
+ private static void AssertJsonEqual(JsonObjectNode expected, JsonObjectNode actual, string path)
+ {
+ foreach (var key in expected.Keys)
+ {
+ Assert.True(actual.Contains(key), $"Missing key {path}.{key} in actual; actual keys=[{string.Join(",", actual.Keys)}]");
+ var ev = expected[key];
+ var av = actual[key];
+ AssertNodeEqual(ev, av, $"{path}.{key}");
+ }
+
+ // Symmetric check: any extra keys on `actual` that aren't in `expected`
+ // are reported, except for the small allow-list of fields our C# emits
+ // that the SDK doesn't (and that we explicitly want to surface) and the
+ // per-invocation random fields we already strip from the golden.
+ foreach (var key in actual.Keys)
+ {
+ if (expected.Contains(key)) continue;
+ if (IsToleratedExtraKey($"{path}.{key}")) continue;
+ Assert.Fail($"Unexpected extra key in actual at {path}.{key}; expected keys=[{string.Join(",", expected.Keys)}]");
+ }
+ }
+
+ private static bool IsDriveRoot(string path)
+ {
+ try
+ {
+ var root = Path.GetPathRoot(path);
+ if (string.IsNullOrEmpty(root)) return false;
+ return string.Equals(
+ Path.TrimEndingDirectorySeparator(path),
+ Path.TrimEndingDirectorySeparator(root),
+ StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool IsToleratedExtraKey(string fullPath)
+ {
+ // commandLine/cwd/env/timeoutMs live under "process" — the SDK leaves
+ // process empty when called with createConfigFromPolicy and we add
+ // these ourselves. appContainer.name is a per-invocation random hex
+ // that we stripped from the goldens.
+ return fullPath is
+ "$.process.commandLine" or
+ "$.process.cwd" or
+ "$.process.env" or
+ "$.process.timeoutMs" or
+ "$.appContainer.name";
+ }
+
+ private static void AssertNodeEqual(object? expected, object? actual, string path)
+ {
+ switch (expected)
+ {
+ case JsonObjectNode eo when actual is JsonObjectNode ao:
+ AssertJsonEqual(eo, ao, path); break;
+ case List el when actual is List al:
+ Assert.True(el.Count == al.Count, $"{path}: expected length {el.Count}, got {al.Count}");
+ for (int i = 0; i < el.Count; i++) AssertNodeEqual(el[i], al[i], $"{path}[{i}]");
+ break;
+ default:
+ Assert.True(Equals(expected, actual) || string.Equals(expected?.ToString(), actual?.ToString(), StringComparison.Ordinal),
+ $"{path}: expected {expected} ({expected?.GetType().Name ?? "null"}), got {actual} ({actual?.GetType().Name ?? "null"})");
+ break;
+ }
+ }
+
+ /// Trivial ordered-key JSON object/array tree we can compare and mutate.
+ private sealed class JsonObjectNode
+ {
+ private readonly Dictionary _map = new(StringComparer.Ordinal);
+ private readonly List _order = new();
+ public IEnumerable Keys => _order;
+ public bool Contains(string key) => _map.ContainsKey(key);
+ public object? this[string key] { get => _map[key]; set { if (!_map.ContainsKey(key)) _order.Add(key); _map[key] = value; } }
+ public bool TryGetString(string key, out string value)
+ {
+ if (_map.TryGetValue(key, out var v) && v is string s) { value = s; return true; }
+ value = string.Empty; return false;
+ }
+ public void Remove(string key) { _map.Remove(key); _order.Remove(key); }
+
+ public static JsonObjectNode FromJsonElement(JsonElement root)
+ {
+ if (root.ValueKind != JsonValueKind.Object) throw new InvalidOperationException("Expected object root");
+ var node = new JsonObjectNode();
+ foreach (var prop in root.EnumerateObject()) node[prop.Name] = Convert(prop.Value);
+ return node;
+ }
+
+ private static object? Convert(JsonElement el) => el.ValueKind switch
+ {
+ JsonValueKind.Object => FromJsonElement(el),
+ JsonValueKind.Array => el.EnumerateArray().Select(Convert).ToList(),
+ JsonValueKind.String => el.GetString(),
+ JsonValueKind.Number => el.TryGetInt64(out var i) ? (object)i : el.GetDouble(),
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ _ => null,
+ };
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
index 90b89cbcc..de9e158d8 100644
--- a/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
+++ b/tests/OpenClaw.Shared.Tests/OpenClaw.Shared.Tests.csproj
@@ -4,4 +4,10 @@
+
+
+ PreserveNewest
+
+
+
diff --git a/tools/mxc/run-command.cjs b/tools/mxc/run-command.cjs
deleted file mode 100644
index dbf37fce8..000000000
--- a/tools/mxc/run-command.cjs
+++ /dev/null
@@ -1,696 +0,0 @@
-#!/usr/bin/env node
-/**
- * tools/mxc/run-command.cjs — productized runner for OpenClaw MxcCommandRunner.
- *
- * Reads a single JSON request from stdin describing a system.run invocation
- * plus the SandboxPolicy to apply. Spawns wxc-exec via @microsoft/mxc-sdk's
- * spawnSandboxFromConfig({ usePty: false }) so stdout / stderr stay separate
- * and the exit code is reliable. Writes a single JSON envelope to stdout on
- * completion; node-side errors go to stderr.
- *
- * Wire request (matches BridgeRequest in OneShotAppContainerExecutor.cs):
- * {
- * "capabilityCommand": "system.run",
- * "args": { command: "...", shell: "powershell"|"cmd"|"pwsh", args?: [], ... },
- * "policy": { version, filesystem, network, ui, timeoutMs },
- * "cwd": "...", "env": {...}, "timeoutMs": 30000,
- * "wxcExecPath": "...optional override..."
- * }
- *
- * Wire response (matches BridgeResponse):
- * { exitCode, stdout, stderr, timedOut, durationMs, containmentTag }
- *
- * Currently handles system.run only. Other capabilities follow the same envelope
- * shape with capabilityCommand set appropriately and structuredResult populated.
- */
-
-const {
- createConfigFromPolicy,
- spawnSandboxFromConfig,
- getAvailableToolsPolicy,
- getTemporaryFilesPolicy,
-} = require('@microsoft/mxc-sdk');
-const fs = require('node:fs');
-const path = require('node:path');
-const os = require('node:os');
-
-const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; // mirrors C# DefaultMaxOutputBytes
-const HARD_MAX_OUTPUT_BYTES = 256 * 1024 * 1024; // safety ceiling regardless of caller
-const DIRECT_DEBUG_LOG_PATH = path.join(
- process.env.OPENCLAW_TRAY_DATA_DIR ||
- process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'OpenClawTray') ||
- os.tmpdir(),
- 'openclaw-mxc-debug.log');
-const DIRECT_DEBUG_DIR = path.dirname(DIRECT_DEBUG_LOG_PATH);
-const PREFLIGHT_ONLY = true;
-
-/**
- * Match a drive root like "C:\", "D:", "c:\\", etc. The MXC SDK's
- * getAvailableToolsPolicy adds the system drive root as readonly when pwsh.exe
- * is on PATH. That's WAY more access than any preset claims to give the agent
- * (e.g., Locked Down promises "no standard user folders"). We strip drive
- * roots from the merged policy; PATH-specific tool dirs and PSReadLine paths
- * stay, so commands still run.
- */
-function isDriveRoot(p) {
- if (!p) return false;
- const norm = path.normalize(p).replace(/[\\/]+$/, '');
- return /^[A-Za-z]:$/.test(norm);
-}
-
-/**
- * Match the user's real %TEMP% / %TMP% / os.tmpdir() roots so we can strip
- * them from the merged policy. The bridge substitutes a fresh per-invocation
- * scratch directory in their place (see createScratchDir below).
- */
-function isUserTempRoot(p) {
- if (!p) return false;
- const norm = path.normalize(p).toLowerCase().replace(/[\\/]+$/, '');
- const candidates = [
- process.env.TEMP,
- process.env.TMP,
- os.tmpdir(),
- ].filter(Boolean).map(c => path.normalize(c).toLowerCase().replace(/[\\/]+$/, ''));
- return candidates.some(c => c === norm);
-}
-
-/**
- * Remove any allow-list entry that overlaps a denied path.
- * Mirrors C# MxcPolicyBuilder.FilterOutDenied. Case-insensitive (NTFS
- * semantics) and tolerant of trailing slashes / mixed separators. Returns
- * a new array; doesn't mutate the input.
- */
-function filterOutDenied(allowed, denied) {
- return filterOutDeniedWithReasons(allowed, denied).allowed;
-}
-
-function filterOutDeniedWithReasons(allowed, denied) {
- const source = Array.isArray(allowed) ? allowed : [];
- if (source.length === 0) return { allowed: [], removed: [] };
- if (!Array.isArray(denied) || denied.length === 0) return { allowed: source, removed: [] };
- const normalizedDenied = denied
- .map(d => ({ original: d, normalized: normalizePath(d) }))
- .filter(d => d.normalized);
- if (normalizedDenied.length === 0) return { allowed: source, removed: [] };
-
- const kept = [];
- const removed = [];
- for (const candidate of source) {
- const normalizedCandidate = normalizePath(candidate);
- if (!normalizedCandidate) {
- removed.push({ path: candidate, reason: 'invalid-path' });
- continue;
- }
-
- const matchedDeny = normalizedDenied.find(d => pathsOverlap(normalizedCandidate, d.normalized));
- if (matchedDeny) {
- removed.push({ path: candidate, reason: 'overlaps-denied-path', deniedPath: matchedDeny.original });
- continue;
- }
-
- kept.push(candidate);
- }
-
- return { allowed: kept, removed };
-}
-
-function buildPathAccounting(callerPolicy, tools, temp, mergedPolicy) {
- const callerFs = callerPolicy?.filesystem ?? {};
- const toolReadonly = tools?.readonlyPaths ?? [];
- const tempReadwrite = temp?.readwritePaths ?? [];
- return {
- caller: {
- readonlyPaths: callerFs.readonlyPaths ?? [],
- readwritePaths: callerFs.readwritePaths ?? [],
- deniedPaths: callerFs.deniedPaths ?? [],
- },
- sdkAdds: {
- toolReadonlyPaths: toolReadonly,
- tempReadwritePaths: tempReadwrite,
- },
- mergedBeforeScopeFilters: {
- readonlyPaths: mergedPolicy?.filesystem?.readonlyPaths ?? [],
- readwritePaths: mergedPolicy?.filesystem?.readwritePaths ?? [],
- deniedPaths: mergedPolicy?.filesystem?.deniedPaths ?? [],
- },
- network: mergedPolicy?.network ?? null,
- ui: mergedPolicy?.ui ?? null,
- };
-}
-
-function pathsOverlap(left, right) {
- return isSameOrNested(left, right) || isSameOrNested(right, left);
-}
-
-function isSameOrNested(child, parent) {
- return child === parent || child.startsWith(parent + '\\') || child.startsWith(parent + '/');
-}
-
-function normalizePath(p) {
- if (!p) return '';
- try {
- return path.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
- } catch {
- return String(p).toLowerCase();
- }
-}
-
-async function main() {
- const startTime = Date.now();
- const req = await readJsonFromStdin();
-
- // Args specific to system.run. Other capabilities will have their own shapes.
- const args = req.args ?? {};
- const command = typeof args.command === 'string' ? args.command : '';
- const shell = typeof args.shell === 'string' ? args.shell : 'powershell';
- const argv = Array.isArray(args.args) ? args.args : [];
-
- if (!command) {
- return emit(failResponse(-1, 'Missing required arg: command', startTime));
- }
-
- // Honor the caller-supplied maxOutputBytes (from C# bridge). Clamp to a
- // hard ceiling so a misconfigured caller can't OOM the bridge process.
- const callerMaxOutput = Number.isFinite(req.maxOutputBytes) && req.maxOutputBytes > 0
- ? Math.min(req.maxOutputBytes, HARD_MAX_OUTPUT_BYTES)
- : DEFAULT_MAX_OUTPUT_BYTES;
-
- // Compose host-discovered tool/temp paths into the policy supplied by C#.
- const tools = getAvailableToolsPolicy(process.env, { containerType: 'appcontainer' });
- const temp = getTemporaryFilesPolicy(process.env);
- diag('input policy', summarizePolicy(req.policy));
- diag('sdk tools policy', summarizePolicy({ filesystem: tools }));
- diag('sdk temp policy', summarizePolicy({ filesystem: temp }));
-
- const policy = mergePolicy(req.policy, tools, temp);
- diag('policy merge path accounting', buildPathAccounting(req.policy, tools, temp, policy));
-
- // SCOPE the merged policy: strip the SDK's "convenience" grants that
- // bypass the user's explicit choices in the Sandbox UI.
- // - Drive root (C:\) — SDK adds this when pwsh.exe is on PATH. Strip it;
- // PATH-specific tool dirs (git, node, python, etc.) stay because they
- // remain in the filtered list.
- // - User's real %TEMP% — wholesale temp access leaks other apps' files.
- // We substitute a fresh per-invocation scratch dir as the only writable
- // temp area, and override TEMP/TMP/TMPDIR in the spawned process's env
- // so commands that write to %TEMP% land in our scratch dir.
- const beforeScopeReadonly = policy.filesystem.readonlyPaths || [];
- const beforeScopeReadwrite = policy.filesystem.readwritePaths || [];
- const driveRootRemovals = beforeScopeReadonly.filter(isDriveRoot);
- const tempRootRemovals = beforeScopeReadwrite.filter(isUserTempRoot);
- policy.filesystem.readonlyPaths = beforeScopeReadonly.filter(p => !isDriveRoot(p));
- policy.filesystem.readwritePaths = beforeScopeReadwrite.filter(p => !isUserTempRoot(p));
- if (driveRootRemovals.length || tempRootRemovals.length) {
- diag('policy scope filtered sdk convenience grants', {
- removedReadonlyDriveRoots: driveRootRemovals,
- removedReadwriteTempRoots: tempRootRemovals,
- });
- }
-
- // Mirror the C# MxcPolicyBuilder.FilterOutDenied logic on the JS side after
- // merging the SDK's tools/temp policies. The C# side already stripped any
- // allow-list entry that overlapped a denied path, but the SDK merge re-adds
- // its own allow grants (PATH dirs, PSReadLine history, etc.) that the C#
- // filter never saw. Without this pass, the SDK could grant access to a
- // parent of a denied path — e.g., %LOCALAPPDATA% which contains the browser
- // profile dirs we deny in MxcPolicyBuilder. Belt-and-suspenders deny precedence
- // independent of the @microsoft/mxc-sdk's (undocumented, alpha) deny semantics.
- const readonlyDeniedFilter = filterOutDeniedWithReasons(policy.filesystem.readonlyPaths, policy.filesystem.deniedPaths);
- const readwriteDeniedFilter = filterOutDeniedWithReasons(policy.filesystem.readwritePaths, policy.filesystem.deniedPaths);
- policy.filesystem.readonlyPaths = readonlyDeniedFilter.allowed;
- policy.filesystem.readwritePaths = readwriteDeniedFilter.allowed;
- if (readonlyDeniedFilter.removed.length || readwriteDeniedFilter.removed.length) {
- diag('policy denied-overlap filtered allow grants', {
- removedReadonlyPaths: readonlyDeniedFilter.removed,
- removedReadwritePaths: readwriteDeniedFilter.removed,
- });
- }
-
- let scratchDir = null;
- try {
- scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-mxc-'));
- } catch (e) {
- return emit(failResponse(-1, `Failed to create scratch dir: ${e.message}`, startTime));
- }
- policy.filesystem.readwritePaths.push(scratchDir);
- diag('policy scratch grant added', { scratchDir });
- diag('effective policy before config', summarizePolicy(policy));
-
- try {
- let config;
- try {
- config = createConfigFromPolicy(policy, 'process');
- diag('sandbox config after policy conversion', summarizeConfig(config));
- } catch (e) {
- diag('policy conversion failed', { error: e.message, effectivePolicy: summarizePolicy(policy) });
- return emit(failResponse(-1, `Policy invalid: ${e.message}`, startTime));
- }
-
- // Build the shell command line. Quote the inner command for the chosen shell.
- config.process.commandLine = buildShellCommandLine(shell, command, argv);
- if (req.cwd) config.process.cwd = req.cwd;
- diag('sandbox process command prepared', {
- shell,
- commandLength: command.length,
- argCount: argv.length,
- cwd: config.process.cwd || null,
- timeoutMs: req.timeoutMs,
- });
-
- // Override TEMP/TMP/TMPDIR so commands inside the sandbox write to our
- // scratch dir, not the user's real %TEMP% (which we stripped above).
- // New-TemporaryFile, mkdtemp(), etc. all respect these.
- config.process.env = buildSandboxEnv(req.env, scratchDir);
- const sdkTimeoutMs = req.timeoutMs > 0 ? req.timeoutMs : 30000;
- config.process.timeout = sdkTimeoutMs;
- diag('sandbox process env prepared', {
- envKeys: config.process.env.map(e => String(e).split('=')[0]).sort(),
- scratchDir,
- sdkTimeoutMs,
- });
-
- // CRITICAL: usePty:false — the @microsoft/mxc-sdk default uses node-pty which
- // conflates stdout/stderr and rounds exit codes through PTY signals. We want
- // LocalCommandRunner-equivalent semantics here (separate streams, reliable
- // exit code).
- const spawnOptions = {
- usePty: false,
- debug: false,
- };
- if (req.wxcExecPath) {
- spawnOptions.executablePath = req.wxcExecPath;
- }
- diag('spawnSandboxFromConfig before', {
- spawnOptions,
- config: summarizeConfig(config),
- effectivePolicy: summarizePolicy(policy),
- });
-
- const artifactPaths = writePreflightArtifacts(policy, config, spawnOptions);
- diag('preflight artifacts written', artifactPaths);
-
- if (PREFLIGHT_ONLY) {
- diag('spawnSandboxFromConfig skipped', {
- reason: 'preflight-only mode; sandbox execution intentionally disabled',
- artifactPaths,
- });
- return emit({
- exitCode: 0,
- stdout:
- "MXC preflight completed; sandbox execution intentionally skipped.\n" +
- `Effective policy: ${artifactPaths.effectivePolicyPath}\n` +
- `Container config: ${artifactPaths.configPath}\n` +
- `Spawn options: ${artifactPaths.spawnOptionsPath}\n`,
- stderr: '',
- timedOut: false,
- durationMs: Math.max(0, Date.now() - startTime),
- containmentTag: 'mxc-preflight',
- });
- }
-
- let child;
- try {
- child = spawnSandboxFromConfig(config, spawnOptions);
- diag('spawnSandboxFromConfig after', {
- pid: child && typeof child.pid === 'number' ? child.pid : null,
- stdout: Boolean(child?.stdout),
- stderr: Boolean(child?.stderr),
- });
- } catch (e) {
- diag('spawnSandboxFromConfig failed', { error: e.message, config: summarizeConfig(config) });
- return emit(failResponse(-1, `spawnSandboxFromConfig failed: ${e.message}`, startTime));
- }
-
- let stdout = '';
- let stderr = '';
- let stdoutBytes = 0;
- let stderrBytes = 0;
- let truncated = false;
-
- child.stdout?.on('data', (chunk) => {
- const text = chunk.toString();
- if (stdoutBytes + text.length > callerMaxOutput) {
- stdout += text.substring(0, callerMaxOutput - stdoutBytes);
- stdoutBytes = callerMaxOutput;
- truncated = true;
- } else {
- stdout += text;
- stdoutBytes += text.length;
- }
- });
- child.stderr?.on('data', (chunk) => {
- const text = chunk.toString();
- if (stderrBytes + text.length > callerMaxOutput) {
- stderr += text.substring(0, callerMaxOutput - stderrBytes);
- stderrBytes = callerMaxOutput;
- truncated = true;
- } else {
- stderr += text;
- stderrBytes += text.length;
- }
- });
-
- const exitCode = await new Promise((resolve) => {
- child.on('close', (code) => resolve(code ?? -1));
- child.on('error', (err) => {
- stderr += `\n[bridge] spawn error: ${err.message}`;
- resolve(-1);
- });
- });
-
- if (truncated) {
- stderr += `\n[bridge] output truncated at ${callerMaxOutput} bytes`;
- }
-
- // Heuristic for SDK-level timeout: if elapsed >= the timeout we passed to the
- // SDK and the child exited non-zero, we treat it as a timeout. The SDK kills
- // the child on timeout but doesn't surface a distinct exit code, so this is
- // the cleanest signal available without poking SDK internals.
- const durationMs = Date.now() - startTime;
- const timedOut = exitCode !== 0 && durationMs >= sdkTimeoutMs;
- diag('sandbox child closed', {
- exitCode,
- durationMs,
- timedOut,
- stdoutBytes,
- stderrBytes,
- truncated,
- });
-
- emit({
- exitCode,
- stdout,
- stderr,
- timedOut,
- durationMs,
- containmentTag: 'mxc',
- });
- } finally {
- // Best-effort scratch cleanup. If the user's command spawned a detached
- // process that's still using the dir we may fail here — that's fine, the
- // OS will reap it eventually since these live under %TEMP%.
- if (scratchDir) {
- try { fs.rmSync(scratchDir, { recursive: true, force: true }); } catch { /* ignore */ }
- }
- }
-}
-
-function mergePolicy(callerPolicy, tools, temp) {
- const fs0 = callerPolicy?.filesystem ?? {};
- return {
- version: callerPolicy?.version ?? '0.4.0-alpha',
- filesystem: {
- readonlyPaths: dedupe([
- ...(fs0.readonlyPaths ?? []),
- ...(tools?.readonlyPaths ?? []),
- ]),
- readwritePaths: dedupe([
- ...(fs0.readwritePaths ?? []),
- ...(temp?.readwritePaths ?? []),
- ]),
- deniedPaths: fs0.deniedPaths ?? [],
- clearPolicyOnExit: fs0.clearPolicyOnExit ?? true,
- },
- network: callerPolicy?.network ?? { allowOutbound: false, allowLocalNetwork: false },
- ui: callerPolicy?.ui ?? { allowWindows: false, clipboard: 'none', allowInputInjection: false },
- timeoutMs: callerPolicy?.timeoutMs,
- };
-}
-
-function dedupe(arr) {
- return Array.from(new Set(arr.filter(Boolean)));
-}
-
-const BASE_ENV_ALLOWLIST = [
- 'ALLUSERSPROFILE',
- 'APPDATA',
- 'ComSpec',
- 'CommonProgramFiles',
- 'CommonProgramFiles(x86)',
- 'CommonProgramW6432',
- 'HOMEDRIVE',
- 'HOMEPATH',
- 'LOCALAPPDATA',
- 'NUMBER_OF_PROCESSORS',
- 'OS',
- 'PATH',
- 'PATHEXT',
- 'PROCESSOR_ARCHITECTURE',
- 'PROCESSOR_IDENTIFIER',
- 'PROCESSOR_LEVEL',
- 'PROCESSOR_REVISION',
- 'ProgramData',
- 'ProgramFiles',
- 'ProgramFiles(x86)',
- 'ProgramW6432',
- 'PUBLIC',
- 'SystemDrive',
- 'SystemRoot',
- 'USERDOMAIN',
- 'USERNAME',
- 'USERPROFILE',
- 'windir',
-];
-
-function buildSandboxEnv(requestEnv, scratchDir) {
- const env = {};
- for (const name of BASE_ENV_ALLOWLIST) {
- if (Object.prototype.hasOwnProperty.call(process.env, name)) {
- env[name] = process.env[name];
- }
- }
-
- if (requestEnv && typeof requestEnv === 'object') {
- for (const [name, value] of Object.entries(requestEnv)) {
- if (isRequestEnvNameBlocked(name) || value == null) continue;
- env[name] = String(value);
- }
- }
-
- env.TEMP = scratchDir;
- env.TMP = scratchDir;
- env.TMPDIR = scratchDir;
- return Object.entries(env).map(([k, v]) => `${k}=${v}`);
-}
-
-function isRequestEnvNameBlocked(name) {
- if (!name || /[=\0\r\n\s]/.test(name)) return true;
- const upper = String(name).toUpperCase();
- if ([
- 'PATH',
- 'PATHEXT',
- 'COMSPEC',
- 'PSMODULEPATH',
- 'NODE_OPTIONS',
- 'NODE_PATH',
- 'PYTHONPATH',
- 'PYTHONSTARTUP',
- 'PYTHONUSERBASE',
- 'RUBYOPT',
- 'RUBYLIB',
- 'PERL5OPT',
- 'PERL5LIB',
- 'PERLIO',
- 'GIT_SSH',
- 'GIT_SSH_COMMAND',
- 'GIT_EXEC_PATH',
- 'GIT_PROXY_COMMAND',
- 'GIT_ASKPASS',
- 'BASH_ENV',
- 'ENV',
- 'CDPATH',
- 'PROMPT_COMMAND',
- 'ZDOTDIR',
- ].includes(upper)) return true;
- if (upper.startsWith('LD_') || upper.startsWith('DYLD_')) return true;
- return hasCredentialMarker(upper);
-}
-
-function hasCredentialMarker(name) {
- const segments = name.split(/[_\-.]/).filter(Boolean);
- const has = (segment) => segments.includes(segment);
- const hasPair = (first, second) => {
- for (let i = 0; i < segments.length - 1; i++) {
- if (segments[i] === first && segments[i + 1] === second) return true;
- }
- return false;
- };
- return has('TOKEN') ||
- has('SECRET') ||
- has('PASSWORD') ||
- has('PASSWD') ||
- has('CREDENTIAL') ||
- has('CREDENTIALS') ||
- hasPair('API', 'KEY') ||
- hasPair('ACCESS', 'KEY') ||
- hasPair('PRIVATE', 'KEY') ||
- hasPair('CLIENT', 'SECRET') ||
- hasPair('CONNECTION', 'STRING') ||
- name.includes('CONNSTR');
-}
-
-function buildShellCommandLine(shell, command, argv) {
- const sh = shell.toLowerCase();
- if (sh === 'cmd') {
- // For cmd.exe, wrap the entire command line in outer quotes via /S /C.
- // /S strips exactly the first and last `"` of the operand before parsing,
- // so the inner content (already quoteArg-escaped for args) is passed
- // through verbatim. DO NOT double-escape `"` here — quoteArg already
- // doubles inner quotes per cmd's escape convention; running .replace on
- // the concatenated string would quadruple them.
- const argsSuffix = (argv && argv.length > 0)
- ? ' ' + argv.map((a) => quoteArg(a, /*isCmd*/ true)).join(' ')
- : '';
- const inner = command + argsSuffix;
- return `cmd.exe /S /C "${inner}"`;
- }
- // PowerShell variants: use -EncodedCommand with UTF-16LE Base64. PowerShell
- // decodes it back into a single command expression that is NOT subject to
- // outer command-line metacharacter interpretation. This is the most robust
- // way to pass an agent-supplied command without leaking control characters
- // (`;`, `|`, `&`, etc.) into the outer cmdline parser.
- const argsSuffix = (argv && argv.length > 0)
- ? ' ' + argv.map((a) => quoteArg(a, /*isCmd*/ false)).join(' ')
- : '';
- const psExpression = command + argsSuffix;
- const encoded = Buffer.from(psExpression, 'utf16le').toString('base64');
- if (sh === 'pwsh') {
- return `pwsh.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
- }
- return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`;
-}
-
-// Shell metacharacters whose presence forces quoting. Matches the set used by
-// OpenClaw.Shared.ShellQuoting.NeedsQuoting on the C# side so the bridge has
-// the same quoting behavior as LocalCommandRunner. Quoting a switch like
-// `-Name` would break PowerShell parameter binding (PowerShell sees it as a
-// string literal, not a parameter) — we conditional-quote like the host does.
-const SHELL_METACHARS = /[ \t"'&|;<>()^%!$`*?\[\]{}~\n\r]/;
-
-function needsQuoting(arg) {
- // Empty string needs explicit quotes so it's preserved as an argv element.
- if (arg === '' || arg == null) return true;
- return SHELL_METACHARS.test(arg);
-}
-
-function quoteArg(arg, isCmd) {
- if (!needsQuoting(arg)) return String(arg);
- // Minimal quoting; matches OpenClaw.Shared/ShellQuoting semantics for cmd
- // (double-quote with escaped inner quotes) and PowerShell (single quotes).
- if (isCmd) {
- return `"${String(arg).replace(/"/g, '""')}"`;
- }
- return `'${String(arg).replace(/'/g, "''")}'`;
-}
-
-function readJsonFromStdin() {
- return new Promise((resolve, reject) => {
- const chunks = [];
- process.stdin.on('data', (c) => chunks.push(c));
- process.stdin.on('end', () => {
- try {
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
- } catch (e) {
- reject(e);
- }
- });
- process.stdin.on('error', reject);
- });
-}
-
-function emit(response) {
- process.stdout.write(JSON.stringify(response));
-}
-
-function diag(label, detail) {
- const line = `[${new Date().toISOString()}] [openclaw-mxc-debug] ${label}: ${JSON.stringify(detail)}\n`;
- try {
- process.stderr.write(line);
- } catch {
- // Diagnostics must never affect sandbox execution.
- }
- try {
- fs.mkdirSync(DIRECT_DEBUG_DIR, { recursive: true });
- fs.appendFileSync(DIRECT_DEBUG_LOG_PATH, line, 'utf8');
- } catch {
- // Diagnostics must never affect sandbox execution.
- }
-}
-
-function writePreflightArtifacts(policy, config, spawnOptions) {
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '');
- const prefix = path.join(DIRECT_DEBUG_DIR, `openclaw-mxc-preflight-${stamp}-${process.pid}`);
- const effectivePolicyPath = `${prefix}.effective-policy.json`;
- const configPath = `${prefix}.container-config.json`;
- const spawnOptionsPath = `${prefix}.spawn-options.json`;
-
- fs.mkdirSync(DIRECT_DEBUG_DIR, { recursive: true });
- fs.writeFileSync(effectivePolicyPath, JSON.stringify(policy, null, 2), 'utf8');
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
- fs.writeFileSync(spawnOptionsPath, JSON.stringify(spawnOptions, null, 2), 'utf8');
-
- return { effectivePolicyPath, configPath, spawnOptionsPath };
-}
-
-function summarizePolicy(policy) {
- const fsPolicy = policy?.filesystem ?? {};
- return {
- version: policy?.version ?? null,
- filesystem: {
- readonlyPaths: fsPolicy.readonlyPaths ?? [],
- readwritePaths: fsPolicy.readwritePaths ?? [],
- deniedPaths: fsPolicy.deniedPaths ?? [],
- clearPolicyOnExit: fsPolicy.clearPolicyOnExit ?? null,
- },
- network: policy?.network ?? null,
- ui: policy?.ui ?? null,
- timeoutMs: policy?.timeoutMs ?? null,
- };
-}
-
-function summarizeConfig(config) {
- const processConfig = config?.process ?? {};
- return {
- keys: config && typeof config === 'object' ? Object.keys(config).sort() : [],
- policyPath: firstString(config, ['policyPath', 'policyFile', 'policyFilePath', 'configPath', 'configFilePath']),
- process: {
- commandLineLength: typeof processConfig.commandLine === 'string' ? processConfig.commandLine.length : 0,
- cwd: processConfig.cwd ?? null,
- timeout: processConfig.timeout ?? null,
- envKeys: Array.isArray(processConfig.env)
- ? processConfig.env.map(e => String(e).split('=')[0]).sort()
- : [],
- },
- };
-}
-
-function firstString(source, names) {
- if (!source || typeof source !== 'object') return null;
- for (const name of names) {
- if (typeof source[name] === 'string') return source[name];
- }
- return null;
-}
-
-function failResponse(exitCode, errorMessage, startTime = Date.now()) {
- return {
- exitCode,
- stdout: '',
- stderr: errorMessage,
- timedOut: false,
- durationMs: Math.max(0, Date.now() - startTime),
- containmentTag: 'mxc',
- };
-}
-
-main().catch((err) => {
- process.stdout.write(JSON.stringify({
- exitCode: -1,
- stdout: '',
- stderr: `[bridge] unhandled error: ${err && err.message ? err.message : String(err)}`,
- timedOut: false,
- durationMs: 0,
- containmentTag: 'mxc',
- }));
- process.exit(0); // exit 0 so the host always sees our envelope, not a Node crash
-});
From 4ebd03c66b5389bf6a58d65990421c9e4802c86b Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Thu, 21 May 2026 20:34:18 -0700
Subject: [PATCH 060/115] docs: update coverage totals after PR integration
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/TEST_COVERAGE.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md
index 68b406945..49527039f 100644
--- a/docs/TEST_COVERAGE.md
+++ b/docs/TEST_COVERAGE.md
@@ -11,10 +11,10 @@ These are the suites every agent must run after code changes, as documented in
| Suite | Latest runtime result |
|---|---:|
-| `OpenClaw.Shared.Tests` | 1,897 total: 1,869 passed, 28 skipped |
-| `OpenClaw.Tray.Tests` | 1,191 total: 1,191 passed, 0 skipped |
+| `OpenClaw.Shared.Tests` | 1,890 total: 1,862 passed, 28 skipped |
+| `OpenClaw.Tray.Tests` | 1,178 total: 1,178 passed, 0 skipped |
-Runtime totals come from `dotnet test` on 2026-05-21. They are higher than
+Runtime totals come from `dotnet test` on 2026-05-22. They are higher than
method counts because some `[Theory]` tests expand into multiple cases.
## Test project inventory
From 85d19370e415cd5524e6a7ad9eb9668b8a7e6ff8 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Thu, 21 May 2026 20:43:05 -0700
Subject: [PATCH 061/115] docs: update master coverage totals
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/TEST_COVERAGE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md
index 49527039f..e615137fc 100644
--- a/docs/TEST_COVERAGE.md
+++ b/docs/TEST_COVERAGE.md
@@ -11,7 +11,7 @@ These are the suites every agent must run after code changes, as documented in
| Suite | Latest runtime result |
|---|---:|
-| `OpenClaw.Shared.Tests` | 1,890 total: 1,862 passed, 28 skipped |
+| `OpenClaw.Shared.Tests` | 1,920 total: 1,891 passed, 29 skipped |
| `OpenClaw.Tray.Tests` | 1,178 total: 1,178 passed, 0 skipped |
Runtime totals come from `dotnet test` on 2026-05-22. They are higher than
From c6b921879c429c9575f72044da48583cd6bdb8a0 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 21:12:27 -0700
Subject: [PATCH 062/115] chore(deps): Bump github/gh-aw-actions from 0.72.1 to
0.74.4 (#446)
Bumps [github/gh-aw-actions](https://github.com/github/gh-aw-actions) from 0.72.1 to 0.74.4.
- [Release notes](https://github.com/github/gh-aw-actions/releases)
- [Changelog](https://github.com/github/gh-aw-actions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/gh-aw-actions/compare/bc56a0cad2f450c562810785ef38649c04db812a...d3abfe96a194bce3a523ed2093ddedd5704cdf62)
---
updated-dependencies:
- dependency-name: github/gh-aw-actions
dependency-version: 0.74.4
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/copilot-setup-steps.yml | 2 +-
.github/workflows/localization-audit.lock.yml | 12 ++++++------
.github/workflows/repo-assist.lock.yml | 16 ++++++++--------
3 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
index 0ae42f6f5..18b4efab0 100644
--- a/.github/workflows/copilot-setup-steps.yml
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -21,6 +21,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install gh-aw extension
- uses: github/gh-aw-actions/setup-cli@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup-cli@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
version: v0.72.1
diff --git a/.github/workflows/localization-audit.lock.yml b/.github/workflows/localization-audit.lock.yml
index 43a8dd2a2..ec0098629 100644
--- a/.github/workflows/localization-audit.lock.yml
+++ b/.github/workflows/localization-audit.lock.yml
@@ -37,7 +37,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.41
@@ -85,7 +85,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -340,7 +340,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -964,7 +964,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1101,7 +1101,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1312,7 +1312,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
diff --git a/.github/workflows/repo-assist.lock.yml b/.github/workflows/repo-assist.lock.yml
index ea90453fa..502ed16fc 100644
--- a/.github/workflows/repo-assist.lock.yml
+++ b/.github/workflows/repo-assist.lock.yml
@@ -50,7 +50,7 @@
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
-# - github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.41
@@ -133,7 +133,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -461,7 +461,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1366,7 +1366,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1538,7 +1538,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1722,7 +1722,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1775,7 +1775,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1875,7 +1875,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
- uses: github/gh-aw-actions/setup@bc56a0cad2f450c562810785ef38649c04db812a # v0.72.1
+ uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
From ece297ad0080ff3ac2f2f5cedd9f9ac54bce0346 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Thu, 21 May 2026 22:29:45 -0700
Subject: [PATCH 063/115] docs(mxc): trim local sandbox implementation notes
Keep public troubleshooting guidance at the product level while preserving local sandbox behavior and validation coverage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/WINDOWS_NODE_TESTING.md | 9 ++---
.../Mxc/DirectAppContainerExecutor.cs | 4 +--
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 17 +++++-----
.../Mxc/MxcCommandRunnerIntegrationTests.cs | 33 +++++++------------
4 files changed, 24 insertions(+), 39 deletions(-)
diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md
index 4404621f2..c0e5cba3e 100644
--- a/docs/WINDOWS_NODE_TESTING.md
+++ b/docs/WINDOWS_NODE_TESTING.md
@@ -113,16 +113,13 @@ 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
-### MXC sandbox filesystem grants
-- `system.run` with sandboxing enabled uses MXC AppContainer filesystem grants. The cwd is granted **read-only** automatically when it is not already covered by an explicit grant. If a command needs to write in its cwd, grant that folder read-write in Sandbox settings.
-- As a temporary MXC compatibility workaround, OpenClaw adds read-only grants for the drive roots used by sandbox grants and shell startup. `cmd.exe` currently stats the drive root during startup; this workaround should be removed after MXC no longer requires it.
-- MXC filesystem filtering requires NTFS-backed paths. ReFS volumes do not have the required filter-driver behavior, so grants on ReFS paths can fail with `Access is denied` even when the policy includes the path.
-- MXC integration tests self-skip on GitHub Actions because MXC/AppContainer filesystem behavior depends on local Windows sandbox support. Run MXC tests from an NTFS-backed checkout/output folder on a local Windows machine after building the tray app so `wxc-exec.exe` has been copied into `OpenClaw.Tray.WinUI\bin\...\tools\mxc\\`:
+### Local sandbox validation
+- Sandbox integration tests are intended for local Windows development machines and may skip when the required local sandbox prerequisites are unavailable.
+- Build the tray app before running local sandbox validation so the required sandbox helper binaries are present in the app output.
```powershell
.\build.ps1
$env:OPENCLAW_RUN_INTEGRATION='1'
- $env:OPENCLAW_WXC_EXEC='D:\github\moltbot-windows-hub\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.22621.0\win-x64\tools\mxc\x64\wxc-exec.exe'
dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --filter "FullyQualifiedName~Mxc"
```
diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
index 43a175023..87eb8fb4d 100644
--- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
+++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
@@ -249,8 +249,8 @@ private void WarnIfUnsupportedVolume(MxcConfig config)
if (!string.Equals(drive.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase))
{
_logger.Warn(
- $"[mxc] filesystem grants on {drive.DriveFormat} volume {root} may fail: " +
- "MXC AppContainer filesystem filtering requires NTFS-backed paths.");
+ $"[mxc] sandbox filesystem grants may be unsupported for volume {root}; " +
+ "commands that need that path may fail.");
}
}
catch
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
index f5b7c6fe2..bc5f67506 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -57,9 +57,8 @@ public static MxcConfig Build(
var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv);
// readonly = UI grants + every existing PATH dir (so tools like git,
- // node, python can be read inside the sandbox). PATH drive roots are
- // skipped by ResolvePathDirsForReadonly; shell startup roots are added
- // explicitly below.
+ // node, python can be read inside the sandbox). Additional compatibility
+ // paths are added below.
var roFromPolicy = (policy?.Filesystem?.ReadonlyPaths ?? Array.Empty()).ToList();
var pathDirs = ResolvePathDirsForReadonly(pathEnvVar);
foreach (var dir in pathDirs)
@@ -70,7 +69,7 @@ public static MxcConfig Build(
var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList();
if (!rwFromPolicy.Contains(scratchDir, StringComparer.OrdinalIgnoreCase))
rwFromPolicy.Add(scratchDir);
- AddShellStartupDriveRoots(roFromPolicy, roFromPolicy.Concat(rwFromPolicy).ToArray());
+ AddCompatibilityReadonlyPaths(roFromPolicy, roFromPolicy.Concat(rwFromPolicy).ToArray());
// denied list from policy (settings dir, ~/.ssh, browser profiles, ...).
var denied = (policy?.Filesystem?.DeniedPaths ?? Array.Empty()).ToList();
@@ -213,16 +212,16 @@ private static bool IsDriveRoot(string dir)
}
}
- private static void AddShellStartupDriveRoots(List readonlyPaths, IEnumerable grantedPaths)
+ private static void AddCompatibilityReadonlyPaths(List readonlyPaths, IEnumerable grantedPaths)
{
foreach (var path in grantedPaths)
- AddDriveRoot(readonlyPaths, path);
+ AddCompatibilityReadonlyPath(readonlyPaths, path);
- AddDriveRoot(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows));
- AddDriveRoot(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty);
+ AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetFolderPath(Environment.SpecialFolder.Windows));
+ AddCompatibilityReadonlyPath(readonlyPaths, Environment.GetEnvironmentVariable("SystemDrive") ?? string.Empty);
}
- private static void AddDriveRoot(List readonlyPaths, string path)
+ private static void AddCompatibilityReadonlyPath(List readonlyPaths, string path)
{
string? root;
try { root = Path.GetPathRoot(Path.GetFullPath(path)); }
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
index f2cdc7e7d..53c97d9f7 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerIntegrationTests.cs
@@ -22,7 +22,7 @@ public class MxcCommandRunnerIntegrationTests
if (IsGitHubActions())
{
Console.WriteLine(
- "[mxc-integration] SKIPPING: GitHub Actions does not provide reliable MXC/AppContainer filesystem filtering.");
+ "[mxc-integration] SKIPPING: GitHub Actions does not provide the required local sandbox environment.");
return null;
}
@@ -35,20 +35,18 @@ public class MxcCommandRunnerIntegrationTests
return null;
}
- if (!IsNtfsBackedPath(AppContext.BaseDirectory, out var testRoot, out var testFormat))
+ if (!HasSupportedSandboxPath(AppContext.BaseDirectory))
{
Console.WriteLine(
- $"[mxc-integration] SKIPPING: test output path is on {testFormat} volume {testRoot}. " +
- "MXC filesystem grants require NTFS-backed paths.");
+ "[mxc-integration] SKIPPING: test output path is not in a supported local sandbox location.");
return null;
}
if (!string.IsNullOrWhiteSpace(availability.WxcExecPath)
- && !IsNtfsBackedPath(availability.WxcExecPath, out var wxcRoot, out var wxcFormat))
+ && !HasSupportedSandboxPath(availability.WxcExecPath))
{
Console.WriteLine(
- $"[mxc-integration] SKIPPING: wxc-exec.exe is on {wxcFormat} volume {wxcRoot}. " +
- "Build or override OPENCLAW_WXC_EXEC from an NTFS-backed output folder.");
+ "[mxc-integration] SKIPPING: sandbox helper path is not in a supported local sandbox location.");
return null;
}
@@ -150,11 +148,10 @@ public async Task SystemRun_CmdDir_ReadsGrantedCustomFolder()
try
{
- if (!IsNtfsBackedPath(dir, out var grantRoot, out var grantFormat))
+ if (!HasSupportedSandboxPath(dir))
{
Console.WriteLine(
- $"[mxc-integration] SKIPPING: custom grant path is on {grantFormat} volume {grantRoot}. " +
- "MXC filesystem grants require NTFS-backed paths.");
+ "[mxc-integration] SKIPPING: custom grant path is not in a supported local sandbox location.");
return;
}
@@ -185,30 +182,22 @@ public async Task SystemRun_CmdDir_ReadsGrantedCustomFolder()
}
}
- private static bool IsNtfsBackedPath(string path, out string root, out string format)
+ private static bool HasSupportedSandboxPath(string path)
{
- root = string.Empty;
- format = "unknown";
-
try
{
- root = Path.GetPathRoot(Path.GetFullPath(path)) ?? string.Empty;
+ var root = Path.GetPathRoot(Path.GetFullPath(path)) ?? string.Empty;
if (string.IsNullOrWhiteSpace(root))
return false;
var drive = new DriveInfo(root);
if (!drive.IsReady)
- {
- format = "not-ready";
return false;
- }
- format = drive.DriveFormat;
- return string.Equals(format, "NTFS", StringComparison.OrdinalIgnoreCase);
+ return string.Equals(drive.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase);
}
- catch (Exception ex)
+ catch
{
- format = ex.GetType().Name;
return false;
}
}
From 547ba9b2e05754834f46b19089c3ed63e582eae4 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:05:08 -0700
Subject: [PATCH 064/115] Skills page: remove title emoji, center content
Remove the puzzle emoji from the page title and switch the root Grid
from HorizontalAlignment=Stretch to Center so the MaxWidth=900 content
stays centered with growing side margins as the window widens.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
index 6124e39f1..5b1ca342b 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
@@ -4,7 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
+
@@ -13,7 +13,7 @@
-
+
From 46c2a183f6bb6b0bce857d2cbf6b68d8fd41cd5d Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:05:51 -0700
Subject: [PATCH 065/115] Organize Bindings page to match Fluent / Win11
Settings pattern
- Drop emoji from page title; use system text styles in row template.
- Remove redundant Grid wrapper so content centers with growing white side bars (matches PermissionsPage).
- Replace literal arrow TextBlock with Segoe Fluent FontIcon (E72A).
- Stretch ListView items to full card width via ItemContainerStyle.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/BindingsPage.xaml | 58 ++++++++++++-------
1 file changed, 36 insertions(+), 22 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
index 590fa2e08..51a9fdd2c 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
@@ -4,24 +4,26 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
-
-
+
+
-
+
+
-
-
+
+
+ Style="{StaticResource CaptionTextBlockStyle}"
+ TextWrapping="Wrap"/>
-
From 66d0e6f34baaca1d5f194389fd553f6cde951375 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:07:05 -0700
Subject: [PATCH 066/115] Skills page: replace emoji with FontIcon, use system
typography
Swap the puzzle emoji in the empty state for a Segoe Fluent Puzzle
glyph (U+EA86) so the page renders correctly across themes and high
contrast, and replace raw FontWeight/FontSize on the Enabled/Disabled
group headers with BodyStrongTextBlockStyle to follow Fluent typography.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
index 5b1ca342b..1984ea66f 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
@@ -37,7 +37,8 @@
BorderThickness="0" CornerRadius="4">
-
@@ -54,7 +55,8 @@
BorderThickness="0" CornerRadius="4" Visibility="Collapsed">
-
@@ -76,7 +78,8 @@
-
+
From d4b35d4941c05ecfd03c26acfae4c9bd320c030f Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:15:04 -0700
Subject: [PATCH 067/115] Localize hard-coded XAML strings across 17 WinUI
files (#491)
Adds x:Uid and resw entries for 305 previously hard-coded strings spanning 13 Pages and 4 Windows. Seeds English in all 5 locales (en-us/fr-fr/nl-nl/zh-cn/zh-tw) and provides translations for 286 user-facing keys; 19 truly invariant identifiers (system.run, URL/IP placeholders, timezone IDs, MiB unit labels) remain English in all locales and are registered in InvariantOrDeferredResourceKeys.
Validation: Test-Localization.ps1 -StrictHardcodedXaml passes with zero warnings; build.ps1 + Shared tests (1891 passed) + Tray tests (1178 passed) all green.
Closes #491
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/BindingsPage.xaml | 6 +-
.../Pages/ConnectionPage.xaml | 128 +--
src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml | 114 +--
src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml | 22 +-
.../Pages/InstancesPage.xaml | 6 +-
.../Pages/PermissionsPage.xaml | 32 +-
.../Pages/SandboxPage.xaml | 116 +--
.../Pages/SessionsPage.xaml | 22 +-
.../Pages/SettingsPage.xaml | 18 +-
src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 14 +-
src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml | 8 +-
.../Pages/VoiceSettingsPage.xaml | 4 +-
.../Pages/WorkspacePage.xaml | 2 +-
.../Strings/en-us/Resources.resw | 915 ++++++++++++++++++
.../Strings/fr-fr/Resources.resw | 915 ++++++++++++++++++
.../Strings/nl-nl/Resources.resw | 915 ++++++++++++++++++
.../Strings/zh-cn/Resources.resw | 915 ++++++++++++++++++
.../Strings/zh-tw/Resources.resw | 915 ++++++++++++++++++
.../Windows/ChatWindow.xaml | 4 +-
.../Windows/ConnectionStatusWindow.xaml | 68 +-
.../Windows/DiagnosticsBundleDialog.xaml | 4 +-
.../Windows/HubWindow.xaml | 8 +-
.../LocalizationValidationTests.cs | 23 +
23 files changed, 4886 insertions(+), 288 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
index 590fa2e08..99d8b7d36 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/BindingsPage.xaml
@@ -40,13 +40,13 @@
Title="Single-agent mode"
Message="All messages route to the default agent. Add bindings for multi-agent routing."/>
-
-
+
@@ -54,7 +54,7 @@
HorizontalAlignment="Center" Padding="0,24,0,16"
Visibility="Collapsed">
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
index a2f7a8c8e..7e4290313 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml
@@ -81,7 +81,7 @@
-
-
-
-
-
@@ -228,19 +228,19 @@
Foreground="{ThemeResource SystemFillColorNeutralBrush}"
VerticalAlignment="Center" HorizontalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"/>
-
-
-
-
-
@@ -316,7 +316,7 @@
VerticalAlignment="Center" HorizontalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"/>
-
-
-
+
-
-
-
@@ -618,7 +618,7 @@
the WinUI-canonical "featured callout" surface —
soft blue tint + info icon are theme-aware and
match tokens.md guidance (no custom brushes). -->
-
-
-
@@ -656,10 +656,10 @@
-
-
@@ -679,10 +679,10 @@
-
-
@@ -690,7 +690,7 @@
-
@@ -702,7 +702,7 @@
-
-
+
-
@@ -750,25 +750,25 @@
-
+
-
-
-
-
@@ -776,17 +776,17 @@
-
-
-
@@ -794,7 +794,7 @@
-
@@ -803,12 +803,12 @@
-
-
@@ -837,15 +837,15 @@
needed: the install wizard creates the gateway,
pairs this PC into it, and saves the record. -->
-
-
-
-
@@ -869,11 +869,11 @@
-
-
@@ -883,11 +883,11 @@
-
-
@@ -908,12 +908,12 @@
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
index 03ba856ce..18e9bba38 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
@@ -25,20 +25,20 @@
-
+
-
+
-
+
@@ -61,13 +61,13 @@
-
-
+
@@ -76,7 +76,7 @@
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8" Padding="20" Margin="0,0,0,12">
-
+
@@ -85,25 +85,25 @@
-
-
+
-
-
-
-
+
+
+
-
@@ -111,15 +111,15 @@
-
-
-
-
-
@@ -130,24 +130,24 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -161,18 +161,18 @@
-
-
-
-
-
+
+
+
@@ -180,23 +180,23 @@
-
-
-
-
-
-
@@ -207,25 +207,25 @@
-
-
-
+
+
-
-
+
-
@@ -236,19 +236,19 @@
-
-
-
+
+
-
-
-
+
+
@@ -261,8 +261,8 @@
-
-
+
@@ -278,7 +278,7 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Visible">
-
@@ -288,10 +288,10 @@
VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="8"
Visibility="Collapsed">
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
index 81e46d73f..17aef5f08 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
@@ -81,7 +81,7 @@
System → Storage). Padding=0 strips button chrome
so they read as inline links flush with the
message text. -->
-
-
-
-
-
+
+
+
-
-
-
+
+
+
@@ -416,7 +416,7 @@
-
+
-
+
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
index 357b1c759..3ff645f60 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml
@@ -92,17 +92,17 @@
Foreground="{ThemeResource SystemFillColorCautionBrush}"
VerticalAlignment="Center"/>
-
-
-
-
+
-
@@ -59,9 +59,9 @@
-
-
@@ -97,9 +97,9 @@
-
-
-
-
-
+
@@ -271,12 +271,12 @@
-
-
-
@@ -296,7 +296,7 @@
-
-
-
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
index 517d37038..e9ca7f69a 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
@@ -25,11 +25,11 @@
VerticalAlignment="Center" />
-
-
@@ -68,9 +68,9 @@
Glyph=""
VerticalAlignment="Center" />
-
-
@@ -91,9 +91,9 @@
VerticalAlignment="Top"
Margin="0,2,0,0" />
-
-
-
+
+
+
@@ -108,7 +108,7 @@
Foreground="{ThemeResource SystemFillColorCautionBrush}"
VerticalAlignment="Top"
Margin="0,2,0,0" />
-
@@ -116,7 +116,7 @@
-
-
@@ -142,9 +142,9 @@
CornerRadius="8"
Padding="20">
-
-
@@ -165,10 +165,10 @@
CornerRadius="8"
Click="OnPresetLockedClick">
-
-
@@ -186,10 +186,10 @@
CornerRadius="8"
Click="OnPresetBalancedClick">
-
-
@@ -207,10 +207,10 @@
CornerRadius="8"
Click="OnPresetPermissiveClick">
-
-
@@ -219,7 +219,7 @@
-
-
+
-
@@ -252,38 +252,38 @@
-
+
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
+
-
-
-
+
+
+
-
@@ -294,8 +294,8 @@
CornerRadius="6"
Padding="8">
-
-
+
@@ -321,9 +321,9 @@
SelectedIndex="{Binding AccessIndex, Mode=TwoWay}"
SelectionChanged="OnCustomFolderAccessChanged"
Tag="{Binding Path}">
-
-
-
+
+
+
-
-
+
@@ -347,35 +347,35 @@
-
+
-
-
-
-
-
+
+
+
+
-
+
-
-
-
@@ -385,24 +385,24 @@
-
+
-
+
-
+
-
-
-
-
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
index 4b571bb0d..274fce74a 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
@@ -24,7 +24,7 @@
AutomationProperties.AutomationId="BackToConnectionLink">
-
+
@@ -34,11 +34,11 @@
-
+
-
+
@@ -46,15 +46,15 @@
-
+
-
-
+
@@ -62,7 +62,7 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed">
-
@@ -72,7 +72,7 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed">
-
@@ -134,13 +134,13 @@
-
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
index cd5cb45b0..208af339a 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
@@ -19,12 +19,12 @@
-
-
-
-
-
@@ -260,19 +260,19 @@
Visibility="Collapsed"
AutomationProperties.AutomationId="LocalGatewayExpander">
-
-
-
-
-
+
-
+
@@ -37,7 +37,7 @@
BorderThickness="0" CornerRadius="4">
-
@@ -54,7 +54,7 @@
BorderThickness="0" CornerRadius="4" Visibility="Collapsed">
-
@@ -68,7 +68,7 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed">
-
@@ -77,10 +77,10 @@
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
index d4c40cc93..a5d852e7b 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
@@ -10,12 +10,12 @@
-
-
+
@@ -135,11 +135,11 @@
-
-
-
+
@@ -95,7 +95,7 @@
Style="{StaticResource AccentButtonStyle}" Padding="12,8">
-
+
-
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..90aae2e87 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -3555,4 +3555,919 @@ On your gateway host (Mac/Linux), run:
Connection Status
+
+ Gateway disconnected
+
+
+ Connect to a gateway to load bindings.
+
+
+ Open Connection
+
+
+ Loading bindings...
+
+
+ Approvals waiting on you
+
+
+ Not connected
+
+
+ Connect
+
+
+ Operator
+
+
+ Send commands and view sessions on this gateway from this PC.
+
+
+ Inactive
+
+
+ See sessions ›
+
+
+ See instances ›
+
+
+ Node mode
+
+
+ When on, this PC registers as a node and offers the capabilities below to agents over the gateway.
+
+
+ Node mode disabled
+
+
+ Copy
+
+
+ Connect
+
+
+ Manage permissions ›
+
+
+ Help us fix this connection:
+
+
+ SSH tunnel is down.
+
+
+ Restart tunnel
+
+
+ Edit tunnel settings
+
+
+ Paste a new setup code from the gateway host.
+
+
+ Paste setup code here…
+
+
+ Apply
+
+
+ Run on the gateway host:
+
+
+ Copy
+
+
+ Connect
+
+
+ Disconnect
+
+
+ Saved gateways
+
+
+ Gateways
+
+
+ Scan
+
+
+ Add gateway
+
+
+ No saved gateways yet.
+
+
+ Discovered on your network
+
+
+ Add a gateway
+
+
+ Get started — install a local gateway
+
+
+ The fastest way to start: sets up a WSL-hosted gateway on this PC and makes it the active connection.
+
+
+ Install
+
+
+ Or connect to an existing one:
+
+
+ Direct
+
+
+ URL + token
+
+
+ Setup code
+
+
+ Paste QR payload
+
+
+ Each method supports an optional SSH tunnel.
+
+
+ Or look for one on your network.
+
+
+ Scan
+
+
+ Discovered on your network
+
+
+ Back
+
+
+ Add a gateway
+
+
+ Direct
+
+
+ Setup code
+
+
+ Local WSL
+
+
+ Gateway URL
+
+
+ ws://host.local:18790
+
+
+ Shared token
+
+
+ Paste shared gateway token
+
+
+ Name (optional)
+
+
+ My gateway
+
+
+ Paste a setup code from the gateway's QR or `openclaw qr` output.
+
+
+ Paste setup code here…
+
+
+ Decode
+
+
+ Install a new WSL gateway on this PC and make it the active connection. The setup wizard will install WSL if needed, provision the gateway, and pair this device automatically.
+
+
+ Install local gateway
+
+
+ Local-WSL setup isn't available on this machine — the install wizard couldn't be initialized.
+
+
+ Use SSH tunnel (optional)
+
+
+ SSH user
+
+
+ user
+
+
+ SSH host
+
+
+ machine-name
+
+
+ Remote port
+
+
+ Local port
+
+
+ Save & connect
+
+
+ Cancel
+
+
+ ⏱️ Cron Jobs
+
+
+ Refresh
+
+
+ New Job
+
+
+ Gateway disconnected
+
+
+ Connect to a gateway to load cron jobs.
+
+
+ Open Connection
+
+
+ New Cron Job
+
+
+ NAME
+
+
+ Morning brief
+
+
+ SCHEDULE
+
+
+ Every
+
+
+ At
+
+
+ Cron
+
+
+ QUICK PRESETS
+
+
+ ⏰ Hourly
+
+
+ 🌅 Daily 9am
+
+
+ 🌆 Daily 6pm
+
+
+ 💼 Weekdays
+
+
+ 📅 Weekly
+
+
+ EXPRESSION
+
+
+ TIMEZONE
+
+
+ Optional
+
+
+ America/New_York
+
+
+ America/Chicago
+
+
+ America/Denver
+
+
+ America/Los_Angeles
+
+
+ Europe/London
+
+
+ Europe/Berlin
+
+
+ Asia/Tokyo
+
+
+ UTC
+
+
+ EVERY
+
+
+ UNIT
+
+
+ Minutes
+
+
+ Hours
+
+
+ Days
+
+
+ RUN AT
+
+
+ Date
+
+
+ 3:30 PM
+
+
+ Delete job after it runs
+
+
+ PROMPT / PAYLOAD
+
+
+ What should the agent do?
+
+
+ DELIVERY
+
+
+ Silent (none)
+
+
+ Notify me (announce)
+
+
+ CHANNEL
+
+
+ e.g. webchat
+
+
+ Advanced options
+
+
+ SESSION BEHAVIOR
+
+
+ New session each run
+
+
+ Resume main session
+
+
+ WAKE MODE
+
+
+ Now (immediate)
+
+
+ Queue (wait for idle)
+
+
+ Cancel
+
+
+ Create Job
+
+
+ Loading cron jobs...
+
+
+ No cron jobs configured
+
+
+ Cron jobs will appear here when configured via the gateway.
+
+
+ Status
+
+
+ Copied
+
+
+ No override
+
+
+ Force Gateway Chat UI
+
+
+ Force Companion Chat UI
+
+
+ No override
+
+
+ Force Gateway Chat UI
+
+
+ Force Companion Chat UI
+
+
+ Refresh
+
+
+ Copy
+
+
+ Open file
+
+
+ Pairing approval required
+
+
+ A node has connected but its capabilities won't activate until you approve the pairing request.
+
+
+ Review on Connection page
+
+
+ Back to Connection
+
+
+ Permissions
+
+
+ Node mode
+
+
+ When on, this PC registers as a node and offers the capabilities below to agents over the gateway.
+
+
+ Capabilities
+
+
+ Toggle individual features that agents may request from this PC.
+
+
+ Integrations
+
+
+ Default action
+
+
+ What happens when a command doesn't match any rule below.
+
+
+ Patterns are matched left-to-right against the full command line. Use * as a wildcard. Changes save automatically.
+
+
+ Saved
+
+
+ 0 rules
+
+
+ No rules yet. Add a pattern below to allow or deny specific commands.
+
+
+ Windows privacy
+
+
+ Camera, microphone & screen access
+
+
+ System-level privacy permissions are managed by Windows. Open Windows Privacy Settings to grant or revoke OpenClaw access.
+
+
+ Node sandbox is on
+
+
+ Programs the agent runs on this PC are contained.
+
+
+ What gets contained
+
+
+ See which commands this page covers — and which need different controls.
+
+
+ Commands the agent runs through the Windows node (
+
+
+ system.run
+
+
+ ) are contained by the settings below.
+
+
+ Commands the agent runs directly on the gateway aren't. If the gateway is on WSL on this PC they can reach your Windows files, clipboard, and network — use the gateway's exec approvals.
+
+
+ Sandbox unavailable
+
+
+ Learn more about MXC sandboxing
+
+
+ Security level
+
+
+ One click to set every control below. Custom folders stay as you set them.
+
+
+ 🔒 Locked Down
+
+
+ No internet, no clipboard, no standard user folders.
+
+
+ 🛡 Recommended
+
+
+ Internet on. Read-only on Documents, Downloads, Desktop. Clipboard read.
+
+
+ ⚠ Unprotected
+
+
+ Internet on. Read+write on Documents, Downloads, Desktop. Clipboard read+write.
+
+
+ Custom folder grants still active
+
+
+ 📁 Files
+
+
+ By default the agent only has a temporary workspace folder it can read and write. Grant access to user folders below. SSH keys, browser profiles, and OpenClaw's own settings are always blocked.
+
+
+ Documents
+
+
+ Blocked
+
+
+ Read only
+
+
+ Read & write
+
+
+ Downloads
+
+
+ Blocked
+
+
+ Read only
+
+
+ Read & write
+
+
+ Desktop
+
+
+ Blocked
+
+
+ Read only
+
+
+ Read & write
+
+
+ Tool directories on your PATH are also granted read-only inside the sandbox, so command-line tools like git, node, python, and dotnet are usable. Drive roots are never granted.
+
+
+ Custom folders
+
+
+ Custom grants are added on top of the rules above. Adding a folder inside Documents/Downloads/Desktop with broader access will override the row-level setting for that path.
+
+
+ Blocked
+
+
+ Read only
+
+
+ Read & write
+
+
+ No custom folders added.
+
+
+ Add folder
+
+
+ 📋 Clipboard
+
+
+ Clipboard often contains passwords, tokens, and private text. Read access may leak this to the agent (and over the internet, if enabled).
+
+
+ None (default)
+
+
+ Read — agent can see what you copied
+
+
+ Write — agent can replace your clipboard (cannot read it)
+
+
+ Read & write
+
+
+ 📡 Network
+
+
+ Allow internet
+
+
+ If file or clipboard read access is enabled, the agent may send that data over the internet.
+
+
+ Local network devices and shares are not included yet.
+
+
+ ⏱ Limits
+
+
+ Command timeout: 30 sec
+
+
+ Maximum captured output (truncates beyond this; does not limit disk, memory, or CPU):
+
+
+ 1 MiB
+
+
+ 4 MiB (default)
+
+
+ 16 MiB
+
+
+ 64 MiB
+
+
+ Back to Connection
+
+
+ Sessions
+
+
+ Refresh
+
+
+ All
+
+
+ Gateway disconnected
+
+
+ Connect to a gateway to load sessions.
+
+
+ Open Connection
+
+
+ Loading sessions...
+
+
+ No active sessions
+
+
+ Reset
+
+
+ Compact
+
+
+ Delete
+
+
+ Settings
+
+
+ General
+
+
+ Notifications
+
+
+ Privacy
+
+
+ Local Gateway
+
+
+ MSIX installation detected
+
+
+ Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw.
+
+
+ Removes the WSL distro (OpenClawGateway), its disk image, autostart entry, and clears gateway credentials. Your MCP token is preserved. Onboarding will reset.
+
+
+ Remove Local Gateway
+
+
+ Saved
+
+
+ 🧩 Skills
+
+
+ All Agents
+
+
+ Enabled
+
+
+ Disabled
+
+
+ Loading skills...
+
+
+ No skills installed
+
+
+ Skills extend your agent's capabilities. Install skills via the CLI or ClawHub.
+
+
+ Gateway disconnected
+
+
+ Connect to a gateway to load usage data.
+
+
+ Open Connection
+
+
+ Loading daily costs...
+
+
+ No daily usage for this period
+
+
+ Test Voice Input
+
+
+ Record
+
+
+ Loading workspace files…
+
+
+ Web Chat Unavailable
+
+
+ Retry
+
+
+ Connection Status
+
+
+ STATE MACHINE
+
+
+ OPERATOR
+
+
+ Off
+
+
+ Connecting
+
+
+ Connected
+
+
+ Pairing
+
+
+ Error
+
+
+ NODE
+
+
+ Off
+
+
+ Connecting
+
+
+ Connected
+
+
+ Pairing
+
+
+ Error
+
+
+ GATEWAYS
+
+
+ CREDENTIALS
+
+
+ SETUP CODE
+
+
+ Paste a setup code to connect end-to-end.
+
+
+ Paste setup code…
+
+
+ Connect
+
+
+ Disconnect
+
+
+ DIRECT CONNECT
+
+
+ Connect with a gateway URL and shared token (admin scope).
+
+
+ ws://localhost:18790
+
+
+ ws://localhost:18790
+
+
+ Gateway URL
+
+
+ Shared gateway token
+
+
+ Token
+
+
+ Connect
+
+
+ SSH Tunnel
+
+
+ SSH User
+
+
+ user
+
+
+ SSH Host
+
+
+ 192.168.x.x
+
+
+ Remote Port
+
+
+ Local Port
+
+
+ EVENT TIMELINE
+
+
+ Copy
+
+
+ Clear
+
+
+ Diagnostics bundle
+
+
+ Review before sharing
+
+
+ Sensitive tokens, command arguments, payloads, and recordings are excluded by the bundle builder.
+
+
+ Disconnected
+
+
+ Op
+
+
+ Node
+
+
+ Advanced
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..03a60a145 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -3507,4 +3507,919 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Connection Status
+
+ Passerelle déconnectée
+
+
+ Connectez-vous à une passerelle pour charger les liaisons.
+
+
+ Ouvrir la connexion
+
+
+ Chargement des liaisons…
+
+
+ Approbations en attente
+
+
+ Non connecté
+
+
+ Connecter
+
+
+ Opérateur
+
+
+ Envoyez des commandes et consultez les sessions de cette passerelle depuis ce PC.
+
+
+ Inactif
+
+
+ Voir les sessions ›
+
+
+ Voir les instances ›
+
+
+ Mode nœud
+
+
+ Lorsqu'il est activé, ce PC s'enregistre comme nœud et offre les capacités ci-dessous aux agents via la passerelle.
+
+
+ Mode nœud désactivé
+
+
+ Copier
+
+
+ Connecter
+
+
+ Gérer les autorisations ›
+
+
+ Aidez-nous à réparer cette connexion :
+
+
+ Le tunnel SSH est arrêté.
+
+
+ Redémarrer le tunnel
+
+
+ Modifier les paramètres du tunnel
+
+
+ Collez un nouveau code de configuration de l'hôte de la passerelle.
+
+
+ Collez le code de configuration ici…
+
+
+ Appliquer
+
+
+ Exécuter sur l'hôte de la passerelle :
+
+
+ Copier
+
+
+ Connecter
+
+
+ Déconnecter
+
+
+ Passerelles enregistrées
+
+
+ Passerelles
+
+
+ Analyser
+
+
+ Ajouter une passerelle
+
+
+ Aucune passerelle enregistrée pour l'instant.
+
+
+ Découvert sur votre réseau
+
+
+ Ajouter une passerelle
+
+
+ Pour commencer — installer une passerelle locale
+
+
+ La méthode la plus rapide : configure une passerelle hébergée sur WSL sur ce PC et en fait la connexion active.
+
+
+ Installer
+
+
+ Ou connectez-vous à une existante :
+
+
+ Connexion directe
+
+
+ URL + jeton
+
+
+ Code de configuration
+
+
+ Coller la charge utile QR
+
+
+ Chaque méthode prend en charge un tunnel SSH facultatif.
+
+
+ Ou cherchez-en une sur votre réseau.
+
+
+ Analyser
+
+
+ Découvert sur votre réseau
+
+
+ Retour
+
+
+ Ajouter une passerelle
+
+
+ Connexion directe
+
+
+ Code de configuration
+
+
+ WSL local
+
+
+ URL de la passerelle
+
+
+ ws://host.local:18790
+
+
+ Jeton partagé
+
+
+ Coller le jeton partagé de la passerelle
+
+
+ Nom (facultatif)
+
+
+ Ma passerelle
+
+
+ Collez un code de configuration depuis le QR de la passerelle ou la sortie d'`openclaw qr`.
+
+
+ Collez le code de configuration ici…
+
+
+ Décoder
+
+
+ Installez une nouvelle passerelle WSL sur ce PC et faites-en la connexion active. L'assistant d'installation installera WSL si nécessaire, configurera la passerelle et appairera cet appareil automatiquement.
+
+
+ Installer la passerelle locale
+
+
+ La configuration WSL locale n'est pas disponible sur cette machine — l'assistant d'installation n'a pas pu être initialisé.
+
+
+ Utiliser un tunnel SSH (facultatif)
+
+
+ Utilisateur SSH
+
+
+ user
+
+
+ Hôte SSH
+
+
+ machine-name
+
+
+ Port distant
+
+
+ Port local
+
+
+ Enregistrer & connecter
+
+
+ Annuler
+
+
+ ⏱️ Tâches Cron
+
+
+ Actualiser
+
+
+ Nouvelle tâche
+
+
+ Passerelle déconnectée
+
+
+ Connectez-vous à une passerelle pour charger les tâches cron.
+
+
+ Ouvrir la connexion
+
+
+ Nouvelle tâche Cron
+
+
+ NOM
+
+
+ Briefing du matin
+
+
+ PLANIFICATION
+
+
+ Toutes les
+
+
+ À
+
+
+ Cron
+
+
+ PRÉRÉGLAGES RAPIDES
+
+
+ ⏰ Toutes les heures
+
+
+ 🌅 Tous les jours à 9 h
+
+
+ 🌆 Tous les jours à 18 h
+
+
+ 💼 Jours ouvrables
+
+
+ 📅 Hebdomadaire
+
+
+ EXPR. CRON
+
+
+ FUSEAU HORAIRE
+
+
+ Facultatif
+
+
+ America/New_York
+
+
+ America/Chicago
+
+
+ America/Denver
+
+
+ America/Los_Angeles
+
+
+ Europe/London
+
+
+ Europe/Berlin
+
+
+ Asia/Tokyo
+
+
+ UTC
+
+
+ TOUTES LES
+
+
+ UNITÉ
+
+
+ Min
+
+
+ Heures
+
+
+ Jours
+
+
+ EXÉCUTER À
+
+
+ Choisir une date
+
+
+ 15:30
+
+
+ Supprimer la tâche après son exécution
+
+
+ INVITE / CHARGE UTILE
+
+
+ Que doit faire l'agent ?
+
+
+ LIVRAISON
+
+
+ Silencieux (aucun)
+
+
+ M'avertir (annoncer)
+
+
+ CANAL
+
+
+ ex. webchat
+
+
+ Options avancées
+
+
+ COMPORTEMENT DE SESSION
+
+
+ Nouvelle session à chaque exécution
+
+
+ Reprendre la session principale
+
+
+ MODE DE RÉVEIL
+
+
+ Maintenant (immédiat)
+
+
+ File d'attente (attendre l'inactivité)
+
+
+ Annuler
+
+
+ Créer la tâche
+
+
+ Chargement des tâches cron…
+
+
+ Aucune tâche cron configurée
+
+
+ Les tâches cron apparaîtront ici lorsqu'elles seront configurées via la passerelle.
+
+
+ État
+
+
+ Copié
+
+
+ Aucun remplacement
+
+
+ Forcer l'interface de chat de la passerelle
+
+
+ Forcer l'interface de chat du compagnon
+
+
+ Aucun remplacement
+
+
+ Forcer l'interface de chat de la passerelle
+
+
+ Forcer l'interface de chat du compagnon
+
+
+ Actualiser
+
+
+ Copier
+
+
+ Ouvrir le fichier
+
+
+ Approbation d'appairage requise
+
+
+ Un nœud s'est connecté mais ses capacités ne s'activeront pas tant que vous n'aurez pas approuvé la demande d'appairage.
+
+
+ Examiner sur la page Connexion
+
+
+ Retour à la connexion
+
+
+ Autorisations
+
+
+ Mode nœud
+
+
+ Lorsqu'il est activé, ce PC s'enregistre comme nœud et offre les capacités ci-dessous aux agents via la passerelle.
+
+
+ Capacités
+
+
+ Activez ou désactivez les fonctionnalités individuelles que les agents peuvent demander à ce PC.
+
+
+ Intégrations
+
+
+ Action par défaut
+
+
+ Ce qui se passe lorsqu'une commande ne correspond à aucune règle ci-dessous.
+
+
+ Les motifs sont comparés de gauche à droite à la ligne de commande complète. Utilisez * comme caractère générique. Les modifications sont enregistrées automatiquement.
+
+
+ Enregistré
+
+
+ 0 règle
+
+
+ Aucune règle pour l'instant. Ajoutez un motif ci-dessous pour autoriser ou refuser des commandes spécifiques.
+
+
+ Confidentialité Windows
+
+
+ Accès caméra, microphone & écran
+
+
+ Les autorisations de confidentialité au niveau système sont gérées par Windows. Ouvrez les Paramètres de confidentialité Windows pour accorder ou révoquer l'accès à OpenClaw.
+
+
+ Le bac à sable du nœud est activé
+
+
+ Les programmes que l'agent exécute sur ce PC sont confinés.
+
+
+ Ce qui est confiné
+
+
+ Découvrez quelles commandes cette page couvre — et lesquelles nécessitent d'autres contrôles.
+
+
+ Les commandes que l'agent exécute via le nœud Windows (
+
+
+ system.run
+
+
+ ) sont confinées par les paramètres ci-dessous.
+
+
+ Les commandes que l'agent exécute directement sur la passerelle ne le sont pas. Si la passerelle est sur WSL sur ce PC, elles peuvent accéder à vos fichiers Windows, votre presse-papiers et votre réseau — utilisez les approbations d'exécution de la passerelle.
+
+
+ Bac à sable indisponible
+
+
+ En savoir plus sur le bac à sable MXC
+
+
+ Niveau de sécurité
+
+
+ Un clic pour définir tous les contrôles ci-dessous. Les dossiers personnalisés restent tels que vous les avez définis.
+
+
+ 🔒 Verrouillé
+
+
+ Pas d'Internet, pas de presse-papiers, pas de dossiers utilisateur standard.
+
+
+ 🛡 Recommandé
+
+
+ Internet activé. Lecture seule sur Documents, Téléchargements, Bureau. Lecture du presse-papiers.
+
+
+ ⚠ Non protégé
+
+
+ Internet activé. Lecture + écriture sur Documents, Téléchargements, Bureau. Lecture + écriture du presse-papiers.
+
+
+ Les attributions de dossier personnalisé sont toujours actives
+
+
+ 📁 Fichiers
+
+
+ Par défaut, l'agent ne dispose que d'un dossier de travail temporaire qu'il peut lire et écrire. Accordez l'accès aux dossiers utilisateur ci-dessous. Les clés SSH, les profils de navigateur et les paramètres d'OpenClaw lui-même sont toujours bloqués.
+
+
+ Mes documents
+
+
+ Bloqué
+
+
+ Lecture seule
+
+
+ Lecture & écriture
+
+
+ Téléchargements
+
+
+ Bloqué
+
+
+ Lecture seule
+
+
+ Lecture & écriture
+
+
+ Bureau
+
+
+ Bloqué
+
+
+ Lecture seule
+
+
+ Lecture & écriture
+
+
+ Les répertoires d'outils de votre PATH bénéficient également d'un accès en lecture seule dans le bac à sable, afin que les outils en ligne de commande comme git, node, python et dotnet soient utilisables. Les racines de lecteur ne sont jamais accordées.
+
+
+ Dossiers personnalisés
+
+
+ Les autorisations personnalisées sont ajoutées en plus des règles ci-dessus. L'ajout d'un dossier dans Documents/Téléchargements/Bureau avec un accès plus large remplacera le paramètre au niveau de la ligne pour ce chemin.
+
+
+ Bloqué
+
+
+ Lecture seule
+
+
+ Lecture & écriture
+
+
+ Aucun dossier personnalisé ajouté.
+
+
+ Ajouter un dossier
+
+
+ 📋 Presse-papiers
+
+
+ Le presse-papiers contient souvent des mots de passe, des jetons et du texte privé. L'accès en lecture peut divulguer ces données à l'agent (et sur Internet, s'il est activé).
+
+
+ Aucun (par défaut)
+
+
+ Lecture — l'agent peut voir ce que vous avez copié
+
+
+ Écriture — l'agent peut remplacer votre presse-papiers (sans pouvoir le lire)
+
+
+ Lecture & écriture
+
+
+ 📡 Réseau
+
+
+ Autoriser Internet
+
+
+ Si l'accès en lecture aux fichiers ou au presse-papiers est activé, l'agent peut envoyer ces données sur Internet.
+
+
+ Les périphériques et partages réseau locaux ne sont pas encore inclus.
+
+
+ ⏱ Limites
+
+
+ Délai d'expiration de commande : 30 s
+
+
+ Sortie capturée maximale (tronquée au-delà ; ne limite pas le disque, la mémoire ou le processeur) :
+
+
+ 1 MiB
+
+
+ 4 Mio (par défaut)
+
+
+ 16 MiB
+
+
+ 64 MiB
+
+
+ Retour à la connexion
+
+
+ Mes sessions
+
+
+ Actualiser
+
+
+ Tous
+
+
+ Passerelle déconnectée
+
+
+ Connectez-vous à une passerelle pour charger les sessions.
+
+
+ Ouvrir la connexion
+
+
+ Chargement des sessions…
+
+
+ Aucune session active
+
+
+ Réinitialiser
+
+
+ Compacter
+
+
+ Supprimer
+
+
+ Paramètres
+
+
+ Général
+
+
+ Mes notifications
+
+
+ Confidentialité
+
+
+ Passerelle locale
+
+
+ Installation MSIX détectée
+
+
+ La suppression d'OpenClaw via Paramètres Windows &#x2192; Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw.
+
+
+ Supprime la distribution WSL (OpenClawGateway), son image disque, l'entrée de démarrage automatique et efface les identifiants de la passerelle. Votre jeton MCP est conservé. L'intégration sera réinitialisée.
+
+
+ Supprimer la passerelle locale
+
+
+ Enregistré
+
+
+ 🧩 Compétences
+
+
+ Tous les agents
+
+
+ Activé
+
+
+ Désactivé
+
+
+ Chargement des compétences…
+
+
+ Aucune compétence installée
+
+
+ Les compétences étendent les capacités de votre agent. Installez des compétences via la CLI ou ClawHub.
+
+
+ Passerelle déconnectée
+
+
+ Connectez-vous à une passerelle pour charger les données d'utilisation.
+
+
+ Ouvrir la connexion
+
+
+ Chargement des coûts quotidiens…
+
+
+ Aucune utilisation quotidienne pour cette période
+
+
+ Tester l'entrée vocale
+
+
+ Enregistrer
+
+
+ Chargement des fichiers de l'espace de travail…
+
+
+ Chat Web indisponible
+
+
+ Réessayer
+
+
+ État de la connexion
+
+
+ MACHINE À ÉTATS
+
+
+ OPÉRATEUR
+
+
+ Désactivé
+
+
+ Connexion en cours
+
+
+ Connecté
+
+
+ Appairage
+
+
+ Erreur
+
+
+ NŒUD
+
+
+ Désactivé
+
+
+ Connexion en cours
+
+
+ Connecté
+
+
+ Appairage
+
+
+ Erreur
+
+
+ PASSERELLES
+
+
+ IDENTIFIANTS
+
+
+ CODE DE CONFIGURATION
+
+
+ Collez un code de configuration pour établir une connexion de bout en bout.
+
+
+ Collez le code de configuration…
+
+
+ Connecter
+
+
+ Déconnecter
+
+
+ CONNEXION DIRECTE
+
+
+ Connectez-vous avec une URL de passerelle et un jeton partagé (étendue admin).
+
+
+ ws://localhost:18790
+
+
+ ws://localhost:18790
+
+
+ URL de la passerelle
+
+
+ Jeton partagé de la passerelle
+
+
+ Jeton
+
+
+ Connecter
+
+
+ Tunnel SSH
+
+
+ Utilisateur SSH
+
+
+ user
+
+
+ Hôte SSH
+
+
+ 192.168.x.x
+
+
+ Port distant
+
+
+ Port local
+
+
+ CHRONOLOGIE DES ÉVÉNEMENTS
+
+
+ Copier
+
+
+ Effacer
+
+
+ Lot de diagnostics
+
+
+ Examiner avant de partager
+
+
+ Les jetons sensibles, les arguments de commande, les charges utiles et les enregistrements sont exclus par le générateur de lot.
+
+
+ Déconnecté
+
+
+ Op.
+
+
+ Nœud
+
+
+ Avancé
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..35c038284 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -3508,4 +3508,919 @@ Voer op uw gateway-host (Mac/Linux) uit:
Connection Status
+
+ Gateway losgekoppeld
+
+
+ Maak verbinding met een gateway om bindingen te laden.
+
+
+ Verbinding openen
+
+
+ Bindingen worden geladen…
+
+
+ Goedkeuringen wachten op u
+
+
+ Niet verbonden
+
+
+ Verbinden
+
+
+ Bediener
+
+
+ Stuur opdrachten en bekijk sessies op deze gateway vanaf deze pc.
+
+
+ Inactief
+
+
+ Sessies bekijken ›
+
+
+ Instanties bekijken ›
+
+
+ Nodemodus
+
+
+ Wanneer ingeschakeld, registreert deze pc zich als node en biedt onderstaande mogelijkheden aan agents via de gateway.
+
+
+ Nodemodus uitgeschakeld
+
+
+ Kopiëren
+
+
+ Verbinden
+
+
+ Machtigingen beheren ›
+
+
+ Help ons deze verbinding te herstellen:
+
+
+ SSH-tunnel is uitgevallen.
+
+
+ Tunnel opnieuw starten
+
+
+ Tunnelinstellingen bewerken
+
+
+ Plak een nieuwe installatiecode van de gatewayhost.
+
+
+ Plak hier de installatiecode…
+
+
+ Toepassen
+
+
+ Uitvoeren op de gatewayhost:
+
+
+ Kopiëren
+
+
+ Verbinden
+
+
+ Verbreken
+
+
+ Opgeslagen gateways
+
+
+ Gatewaylijst
+
+
+ Scannen
+
+
+ Gateway toevoegen
+
+
+ Nog geen opgeslagen gateways.
+
+
+ Ontdekt op uw netwerk
+
+
+ Een gateway toevoegen
+
+
+ Aan de slag — installeer een lokale gateway
+
+
+ De snelste manier om te starten: stelt een WSL-gehoste gateway in op deze pc en maakt deze de actieve verbinding.
+
+
+ Installeren
+
+
+ Of maak verbinding met een bestaande:
+
+
+ Rechtstreeks
+
+
+ URL en token
+
+
+ Installatiecode
+
+
+ QR-payload plakken
+
+
+ Elke methode ondersteunt een optionele SSH-tunnel.
+
+
+ Of zoek er een op uw netwerk.
+
+
+ Scannen
+
+
+ Ontdekt op uw netwerk
+
+
+ Terug
+
+
+ Een gateway toevoegen
+
+
+ Rechtstreeks
+
+
+ Installatiecode
+
+
+ Lokale WSL
+
+
+ Gateway-URL
+
+
+ ws://host.local:18790
+
+
+ Gedeelde token
+
+
+ Plak gedeelde gatewaytoken
+
+
+ Naam (optioneel)
+
+
+ Mijn gateway
+
+
+ Plak een installatiecode van de QR van de gateway of de uitvoer van `openclaw qr`.
+
+
+ Plak hier de installatiecode…
+
+
+ Decoderen
+
+
+ Installeer een nieuwe WSL-gateway op deze pc en maak die de actieve verbinding. De installatiewizard installeert WSL indien nodig, richt de gateway in en koppelt dit apparaat automatisch.
+
+
+ Lokale gateway installeren
+
+
+ Lokale WSL-installatie is op deze machine niet beschikbaar — de installatiewizard kon niet worden geïnitialiseerd.
+
+
+ SSH-tunnel gebruiken (optioneel)
+
+
+ SSH-gebruiker
+
+
+ user
+
+
+ SSH-host
+
+
+ machine-name
+
+
+ Externe poort
+
+
+ Lokale poort
+
+
+ Opslaan & verbinden
+
+
+ Annuleren
+
+
+ ⏱️ Cron-taken
+
+
+ Vernieuwen
+
+
+ Nieuwe taak
+
+
+ Gateway losgekoppeld
+
+
+ Maak verbinding met een gateway om cron-taken te laden.
+
+
+ Verbinding openen
+
+
+ Nieuwe Cron-taak
+
+
+ NAAM
+
+
+ Ochtendbriefing
+
+
+ PLANNING
+
+
+ Elke
+
+
+ Om
+
+
+ Cron
+
+
+ SNELLE VOORINSTELLINGEN
+
+
+ ⏰ Elk uur
+
+
+ 🌅 Dagelijks om 9.00 uur
+
+
+ 🌆 Dagelijks om 18.00 uur
+
+
+ 💼 Weekdagen
+
+
+ 📅 Wekelijks
+
+
+ EXPRESSIE
+
+
+ TIJDZONE
+
+
+ Optioneel
+
+
+ America/New_York
+
+
+ America/Chicago
+
+
+ America/Denver
+
+
+ America/Los_Angeles
+
+
+ Europe/London
+
+
+ Europe/Berlin
+
+
+ Asia/Tokyo
+
+
+ UTC
+
+
+ ELKE
+
+
+ EENHEID
+
+
+ Minuten
+
+
+ Uren
+
+
+ Dagen
+
+
+ UITVOEREN OM
+
+
+ Datum
+
+
+ 15:30
+
+
+ Taak verwijderen nadat deze is uitgevoerd
+
+
+ INVOER / PAYLOAD
+
+
+ Wat moet de agent doen?
+
+
+ LEVERING
+
+
+ Stil (geen)
+
+
+ Stuur mij een melding (aankondigen)
+
+
+ KANAAL
+
+
+ bijv. webchat
+
+
+ Geavanceerde opties
+
+
+ SESSIEGEDRAG
+
+
+ Nieuwe sessie bij elke uitvoering
+
+
+ Hoofdsessie hervatten
+
+
+ WAKMODUS
+
+
+ Nu (onmiddellijk)
+
+
+ Wachtrij (wachten op inactiviteit)
+
+
+ Annuleren
+
+
+ Taak maken
+
+
+ Cron-taken worden geladen…
+
+
+ Geen cron-taken geconfigureerd
+
+
+ Cron-taken verschijnen hier zodra ze via de gateway zijn geconfigureerd.
+
+
+ Toestand
+
+
+ Gekopieerd
+
+
+ Geen overschrijving
+
+
+ Forceer chat-UI van gateway
+
+
+ Forceer chat-UI van companion
+
+
+ Geen overschrijving
+
+
+ Forceer chat-UI van gateway
+
+
+ Forceer chat-UI van companion
+
+
+ Vernieuwen
+
+
+ Kopiëren
+
+
+ Bestand openen
+
+
+ Goedkeuring voor koppelen vereist
+
+
+ Een node heeft verbinding gemaakt, maar zijn mogelijkheden worden pas geactiveerd nadat u het koppelverzoek hebt goedgekeurd.
+
+
+ Bekijken op pagina Verbinding
+
+
+ Terug naar verbinding
+
+
+ Machtigingen
+
+
+ Nodemodus
+
+
+ Wanneer ingeschakeld, registreert deze pc zich als node en biedt onderstaande mogelijkheden aan agents via de gateway.
+
+
+ Mogelijkheden
+
+
+ Schakel afzonderlijke functies in of uit die agents van deze pc kunnen vragen.
+
+
+ Integraties
+
+
+ Standaardactie
+
+
+ Wat gebeurt er als een opdracht aan geen enkele regel hieronder voldoet.
+
+
+ Patronen worden van links naar rechts vergeleken met de volledige opdrachtregel. Gebruik * als jokerteken. Wijzigingen worden automatisch opgeslagen.
+
+
+ Opgeslagen
+
+
+ 0 regels
+
+
+ Nog geen regels. Voeg hieronder een patroon toe om specifieke opdrachten toe te staan of te weigeren.
+
+
+ Windows-privacy
+
+
+ Toegang tot camera, microfoon & scherm
+
+
+ Privacymachtigingen op systeemniveau worden beheerd door Windows. Open Windows-privacyinstellingen om OpenClaw toegang te verlenen of in te trekken.
+
+
+ Node-sandbox is ingeschakeld
+
+
+ Programma's die de agent op deze pc uitvoert zijn ingesloten.
+
+
+ Wat er wordt ingesloten
+
+
+ Bekijk welke opdrachten deze pagina dekt — en welke andere besturingselementen nodig hebben.
+
+
+ Opdrachten die de agent via de Windows-node uitvoert (
+
+
+ system.run
+
+
+ ) worden ingesloten door onderstaande instellingen.
+
+
+ Opdrachten die de agent rechtstreeks op de gateway uitvoert niet. Als de gateway op WSL op deze pc draait, kunnen ze uw Windows-bestanden, klembord en netwerk bereiken — gebruik de exec-goedkeuringen van de gateway.
+
+
+ Sandbox niet beschikbaar
+
+
+ Meer informatie over MXC-sandboxing
+
+
+ Beveiligingsniveau
+
+
+ Eén klik om alle besturingselementen hieronder in te stellen. Aangepaste mappen blijven zoals u ze hebt ingesteld.
+
+
+ 🔒 Vergrendeld
+
+
+ Geen internet, geen klembord, geen standaard gebruikersmappen.
+
+
+ 🛡 Aanbevolen
+
+
+ Internet aan. Alleen-lezen op Documenten, Downloads, Bureaublad. Klembord lezen.
+
+
+ ⚠ Onbeschermd
+
+
+ Internet aan. Lezen + schrijven op Documenten, Downloads, Bureaublad. Klembord lezen + schrijven.
+
+
+ Aangepaste mapmachtigingen nog steeds actief
+
+
+ 📁 Bestanden
+
+
+ Standaard heeft de agent alleen een tijdelijke werkmap die hij kan lezen en schrijven. Geef hieronder toegang tot gebruikersmappen. SSH-sleutels, browserprofielen en OpenClaw's eigen instellingen worden altijd geblokkeerd.
+
+
+ Documenten
+
+
+ Geblokkeerd
+
+
+ Alleen-lezen
+
+
+ Lezen & schrijven
+
+
+ Downloadmap
+
+
+ Geblokkeerd
+
+
+ Alleen-lezen
+
+
+ Lezen & schrijven
+
+
+ Bureaublad
+
+
+ Geblokkeerd
+
+
+ Alleen-lezen
+
+
+ Lezen & schrijven
+
+
+ Mapdirectory's op uw PATH krijgen ook alleen-lezen toegang binnen de sandbox, zodat opdrachtregelhulpprogramma's zoals git, node, python en dotnet bruikbaar zijn. Stationswortels worden nooit toegekend.
+
+
+ Aangepaste mappen
+
+
+ Aangepaste machtigingen worden toegevoegd bovenop de regels hierboven. Een map binnen Documenten/Downloads/Bureaublad toevoegen met bredere toegang overschrijft de rij-instelling voor dat pad.
+
+
+ Geblokkeerd
+
+
+ Alleen-lezen
+
+
+ Lezen & schrijven
+
+
+ Geen aangepaste mappen toegevoegd.
+
+
+ Map toevoegen
+
+
+ 📋 Klembord
+
+
+ Het klembord bevat vaak wachtwoorden, tokens en privétekst. Leestoegang kan deze lekken naar de agent (en via internet, indien ingeschakeld).
+
+
+ Geen (standaard)
+
+
+ Lezen — agent kan zien wat u hebt gekopieerd
+
+
+ Schrijven — agent kan uw klembord vervangen (kan het niet lezen)
+
+
+ Lezen & schrijven
+
+
+ 📡 Netwerk
+
+
+ Internet toestaan
+
+
+ Als bestand- of klembordleestoegang is ingeschakeld, kan de agent die gegevens via internet verzenden.
+
+
+ Lokale netwerkapparaten en gedeelde mappen zijn nog niet inbegrepen.
+
+
+ ⏱ Limieten
+
+
+ Time-out van opdracht: 30 sec
+
+
+ Maximaal vastgelegde uitvoer (afgekapt boven deze grens; beperkt geen schijf, geheugen of CPU):
+
+
+ 1 MiB
+
+
+ 4 MiB (standaard)
+
+
+ 16 MiB
+
+
+ 64 MiB
+
+
+ Terug naar verbinding
+
+
+ Sessies
+
+
+ Vernieuwen
+
+
+ Alle
+
+
+ Gateway losgekoppeld
+
+
+ Maak verbinding met een gateway om sessies te laden.
+
+
+ Verbinding openen
+
+
+ Sessies worden geladen…
+
+
+ Geen actieve sessies
+
+
+ Resetten
+
+
+ Compact maken
+
+
+ Verwijderen
+
+
+ Instellingen
+
+
+ Algemeen
+
+
+ Meldingen
+
+
+ Privacybeheer
+
+
+ Lokale gateway
+
+
+ MSIX-installatie gedetecteerd
+
+
+ OpenClaw verwijderen via Windows Instellingen &#x2192; Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw.
+
+
+ Verwijdert de WSL-distributie (OpenClawGateway), de schijfafbeelding, het item voor automatisch starten en wist de gateway-referenties. Uw MCP-token blijft behouden. Onboarding wordt gereset.
+
+
+ Lokale gateway verwijderen
+
+
+ Opgeslagen
+
+
+ 🧩 Vaardigheden
+
+
+ Alle agents
+
+
+ Ingeschakeld
+
+
+ Uitgeschakeld
+
+
+ Vaardigheden worden geladen…
+
+
+ Geen vaardigheden geïnstalleerd
+
+
+ Vaardigheden breiden de mogelijkheden van uw agent uit. Installeer vaardigheden via de CLI of ClawHub.
+
+
+ Gateway losgekoppeld
+
+
+ Maak verbinding met een gateway om gebruiksgegevens te laden.
+
+
+ Verbinding openen
+
+
+ Dagelijkse kosten worden geladen…
+
+
+ Geen dagelijks gebruik voor deze periode
+
+
+ Spraakinvoer testen
+
+
+ Opnemen
+
+
+ Werkruimtebestanden worden geladen…
+
+
+ Web-chat niet beschikbaar
+
+
+ Opnieuw proberen
+
+
+ Verbindingsstatus
+
+
+ STATUSMACHINE
+
+
+ BEDIENER
+
+
+ Uit
+
+
+ Verbinden
+
+
+ Verbonden
+
+
+ Koppelen
+
+
+ Fout
+
+
+ KNOOPPUNT
+
+
+ Uit
+
+
+ Verbinden
+
+
+ Verbonden
+
+
+ Koppelen
+
+
+ Fout
+
+
+ POORTEN
+
+
+ REFERENTIES
+
+
+ INSTALLATIECODE
+
+
+ Plak een installatiecode om end-to-end verbinding te maken.
+
+
+ Plak installatiecode…
+
+
+ Verbinden
+
+
+ Verbreken
+
+
+ DIRECT VERBINDEN
+
+
+ Maak verbinding met een gateway-URL en gedeelde token (admin-scope).
+
+
+ ws://localhost:18790
+
+
+ ws://localhost:18790
+
+
+ Gateway-URL
+
+
+ Gedeelde gatewaytoken
+
+
+ Sleutel
+
+
+ Verbinden
+
+
+ SSH-tunnel
+
+
+ SSH-gebruiker
+
+
+ user
+
+
+ SSH-host
+
+
+ 192.168.x.x
+
+
+ Externe poort
+
+
+ Lokale poort
+
+
+ GEBEURTENISTIJDLIJN
+
+
+ Kopiëren
+
+
+ Wissen
+
+
+ Diagnostiekbundel
+
+
+ Controleer voor het delen
+
+
+ Gevoelige tokens, opdrachtargumenten, payloads en opnames worden door de bundelmaker uitgesloten.
+
+
+ Niet verbonden
+
+
+ Op.
+
+
+ Knooppunt
+
+
+ Geavanceerd
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..801ee8fc5 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -3507,4 +3507,919 @@
Connection Status
+
+ 网关已断开
+
+
+ 请连接到网关以加载绑定。
+
+
+ 打开连接
+
+
+ 正在加载绑定…
+
+
+ 等待您的审批
+
+
+ 未连接
+
+
+ 连接
+
+
+ 操作员
+
+
+ 从此电脑向此网关发送命令并查看会话。
+
+
+ 未激活
+
+
+ 查看会话 ›
+
+
+ 查看实例 ›
+
+
+ 节点模式
+
+
+ 启用后,此电脑将注册为节点,并通过网关向代理提供下列功能。
+
+
+ 节点模式已禁用
+
+
+ 复制
+
+
+ 连接
+
+
+ 管理权限 ›
+
+
+ 帮助我们修复此连接:
+
+
+ SSH 隧道已停止。
+
+
+ 重启隧道
+
+
+ 编辑隧道设置
+
+
+ 粘贴来自网关主机的新设置代码。
+
+
+ 在此粘贴设置代码…
+
+
+ 应用
+
+
+ 在网关主机上运行:
+
+
+ 复制
+
+
+ 连接
+
+
+ 断开连接
+
+
+ 已保存的网关
+
+
+ 网关
+
+
+ 扫描
+
+
+ 添加网关
+
+
+ 尚无已保存的网关。
+
+
+ 在您的网络上发现
+
+
+ 添加网关
+
+
+ 开始使用 — 安装本地网关
+
+
+ 最快的入门方式:在此电脑上设置 WSL 托管的网关,并将其设为活动连接。
+
+
+ 安装
+
+
+ 或连接到现有网关:
+
+
+ 直接
+
+
+ URL + 令牌
+
+
+ 设置代码
+
+
+ 粘贴 QR 负载
+
+
+ 每种方法都支持可选的 SSH 隧道。
+
+
+ 或在您的网络上查找。
+
+
+ 扫描
+
+
+ 在您的网络上发现
+
+
+ 返回
+
+
+ 添加网关
+
+
+ 直接
+
+
+ 设置代码
+
+
+ 本地 WSL
+
+
+ 网关 URL
+
+
+ ws://host.local:18790
+
+
+ 共享令牌
+
+
+ 粘贴共享网关令牌
+
+
+ 名称(可选)
+
+
+ 我的网关
+
+
+ 粘贴来自网关的 QR 码或 `openclaw qr` 输出的设置代码。
+
+
+ 在此粘贴设置代码…
+
+
+ 解码
+
+
+ 在此电脑上安装新的 WSL 网关,并将其设为活动连接。设置向导将在需要时安装 WSL、配置网关,并自动配对此设备。
+
+
+ 安装本地网关
+
+
+ 此电脑无法进行本地 WSL 设置 — 安装向导无法初始化。
+
+
+ 使用 SSH 隧道(可选)
+
+
+ SSH 用户
+
+
+ user
+
+
+ SSH 主机
+
+
+ machine-name
+
+
+ 远程端口
+
+
+ 本地端口
+
+
+ 保存并连接
+
+
+ 取消
+
+
+ ⏱️ Cron 任务
+
+
+ 刷新
+
+
+ 新建任务
+
+
+ 网关已断开
+
+
+ 请连接到网关以加载 cron 任务。
+
+
+ 打开连接
+
+
+ 新建 Cron 任务
+
+
+ 名称
+
+
+ 晨间简报
+
+
+ 计划
+
+
+ 每
+
+
+ 于
+
+
+ Cron
+
+
+ 快速预设
+
+
+ ⏰ 每小时
+
+
+ 🌅 每天上午 9 点
+
+
+ 🌆 每天下午 6 点
+
+
+ 💼 工作日
+
+
+ 📅 每周
+
+
+ 表达式
+
+
+ 时区
+
+
+ 可选
+
+
+ America/New_York
+
+
+ America/Chicago
+
+
+ America/Denver
+
+
+ America/Los_Angeles
+
+
+ Europe/London
+
+
+ Europe/Berlin
+
+
+ Asia/Tokyo
+
+
+ UTC
+
+
+ 每
+
+
+ 单位
+
+
+ 分钟
+
+
+ 小时
+
+
+ 天
+
+
+ 运行时间
+
+
+ 日期
+
+
+ 15:30
+
+
+ 运行后删除任务
+
+
+ 提示 / 负载
+
+
+ 代理应该做什么?
+
+
+ 投递
+
+
+ 静默(无)
+
+
+ 通知我(通告)
+
+
+ 通道
+
+
+ 例如 webchat
+
+
+ 高级选项
+
+
+ 会话行为
+
+
+ 每次运行新建会话
+
+
+ 恢复主会话
+
+
+ 唤醒模式
+
+
+ 立即(即时)
+
+
+ 排队(等待空闲)
+
+
+ 取消
+
+
+ 创建任务
+
+
+ 正在加载 cron 任务…
+
+
+ 未配置 cron 任务
+
+
+ 通过网关配置 cron 任务后,它们将显示在此处。
+
+
+ 状态
+
+
+ 已复制
+
+
+ 无覆盖
+
+
+ 强制使用网关聊天界面
+
+
+ 强制使用伴侣聊天界面
+
+
+ 无覆盖
+
+
+ 强制使用网关聊天界面
+
+
+ 强制使用伴侣聊天界面
+
+
+ 刷新
+
+
+ 复制
+
+
+ 打开文件
+
+
+ 需要配对审批
+
+
+ 节点已连接,但在您批准配对请求之前,其功能不会激活。
+
+
+ 在“连接”页面查看
+
+
+ 返回连接
+
+
+ 权限
+
+
+ 节点模式
+
+
+ 启用后,此电脑将注册为节点,并通过网关向代理提供下列功能。
+
+
+ 功能
+
+
+ 切换代理可能从此电脑请求的各项功能。
+
+
+ 集成
+
+
+ 默认操作
+
+
+ 当命令与下列任何规则都不匹配时发生什么。
+
+
+ 模式按从左到右的顺序与完整命令行匹配。使用 * 作为通配符。更改将自动保存。
+
+
+ 已保存
+
+
+ 0 条规则
+
+
+ 尚无规则。在下方添加模式以允许或拒绝特定命令。
+
+
+ Windows 隐私
+
+
+ 摄像头、麦克风和屏幕访问
+
+
+ 系统级隐私权限由 Windows 管理。打开 Windows 隐私设置以授予或撤销 OpenClaw 访问权限。
+
+
+ 节点沙盒已开启
+
+
+ 代理在此电脑上运行的程序受到隔离。
+
+
+ 哪些内容受隔离
+
+
+ 查看此页面涵盖的命令 — 以及需要其他控件的命令。
+
+
+ 代理通过 Windows 节点运行的命令 (
+
+
+ system.run
+
+
+ ) 受下面的设置隔离。
+
+
+ 代理直接在网关上运行的命令不受隔离。如果网关位于此电脑的 WSL 上,它们可能会访问您的 Windows 文件、剪贴板和网络 — 请使用网关的执行审批。
+
+
+ 沙盒不可用
+
+
+ 详细了解 MXC 沙盒
+
+
+ 安全级别
+
+
+ 一键设置下面的每个控件。自定义文件夹保持您的设置。
+
+
+ 🔒 锁定
+
+
+ 无互联网、无剪贴板、无标准用户文件夹。
+
+
+ 🛡 推荐
+
+
+ 互联网开启。对“文档”、“下载”、“桌面”为只读。剪贴板可读。
+
+
+ ⚠ 不受保护
+
+
+ 互联网开启。对“文档”、“下载”、“桌面”可读写。剪贴板可读写。
+
+
+ 自定义文件夹授权仍然有效
+
+
+ 📁 文件
+
+
+ 默认情况下,代理只有一个可读写的临时工作区文件夹。在下方授予对用户文件夹的访问权限。SSH 密钥、浏览器配置文件和 OpenClaw 自身的设置始终被阻止。
+
+
+ 文档
+
+
+ 已阻止
+
+
+ 只读
+
+
+ 读取与写入
+
+
+ 下载
+
+
+ 已阻止
+
+
+ 只读
+
+
+ 读取与写入
+
+
+ 桌面
+
+
+ 已阻止
+
+
+ 只读
+
+
+ 读取与写入
+
+
+ PATH 上的工具目录在沙盒内也以只读方式授予,因此 git、node、python 和 dotnet 等命令行工具可用。从不授予驱动器根目录。
+
+
+ 自定义文件夹
+
+
+ 自定义授权将在上述规则之上添加。在“文档/下载/桌面”内添加具有更宽访问权限的文件夹,将覆盖该路径的行级设置。
+
+
+ 已阻止
+
+
+ 只读
+
+
+ 读取与写入
+
+
+ 尚未添加自定义文件夹。
+
+
+ 添加文件夹
+
+
+ 📋 剪贴板
+
+
+ 剪贴板通常包含密码、令牌和私人文本。读取权限可能将这些泄露给代理(若启用,也会泄露到互联网)。
+
+
+ 无(默认)
+
+
+ 读取 — 代理可以看到您复制的内容
+
+
+ 写入 — 代理可以替换您的剪贴板(无法读取)
+
+
+ 读取与写入
+
+
+ 📡 网络
+
+
+ 允许互联网
+
+
+ 如果启用文件或剪贴板读取权限,代理可能会通过互联网发送这些数据。
+
+
+ 本地网络设备和共享尚未包含在内。
+
+
+ ⏱ 限制
+
+
+ 命令超时:30 秒
+
+
+ 最大捕获输出(超过此值将被截断;不限制磁盘、内存或 CPU):
+
+
+ 1 MiB
+
+
+ 4 MiB(默认)
+
+
+ 16 MiB
+
+
+ 64 MiB
+
+
+ 返回连接
+
+
+ 会话
+
+
+ 刷新
+
+
+ 全部
+
+
+ 网关已断开
+
+
+ 请连接到网关以加载会话。
+
+
+ 打开连接
+
+
+ 正在加载会话…
+
+
+ 无活动会话
+
+
+ 重置
+
+
+ 压缩
+
+
+ 删除
+
+
+ 设置
+
+
+ 常规
+
+
+ 通知
+
+
+ 隐私
+
+
+ 本地网关
+
+
+ 检测到 MSIX 安装
+
+
+ 通过 Windows 设置 &#x2192; 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。
+
+
+ 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。
+
+
+ 删除本地网关
+
+
+ 已保存
+
+
+ 🧩 技能
+
+
+ 所有代理
+
+
+ 已启用
+
+
+ 已禁用
+
+
+ 正在加载技能…
+
+
+ 未安装技能
+
+
+ 技能扩展代理的能力。通过 CLI 或 ClawHub 安装技能。
+
+
+ 网关已断开
+
+
+ 请连接到网关以加载用量数据。
+
+
+ 打开连接
+
+
+ 正在加载每日费用…
+
+
+ 此期间内无每日用量
+
+
+ 测试语音输入
+
+
+ 录制
+
+
+ 正在加载工作区文件…
+
+
+ 网页聊天不可用
+
+
+ 重试
+
+
+ 连接状态
+
+
+ 状态机
+
+
+ 操作员
+
+
+ 关闭
+
+
+ 正在连接
+
+
+ 已连接
+
+
+ 正在配对
+
+
+ 错误
+
+
+ 节点
+
+
+ 关闭
+
+
+ 正在连接
+
+
+ 已连接
+
+
+ 正在配对
+
+
+ 错误
+
+
+ 网关
+
+
+ 凭据
+
+
+ 设置代码
+
+
+ 粘贴设置代码以建立端到端连接。
+
+
+ 粘贴设置代码…
+
+
+ 连接
+
+
+ 断开连接
+
+
+ 直接连接
+
+
+ 使用网关 URL 和共享令牌(管理员范围)连接。
+
+
+ ws://localhost:18790
+
+
+ ws://localhost:18790
+
+
+ 网关 URL
+
+
+ 共享网关令牌
+
+
+ 令牌
+
+
+ 连接
+
+
+ SSH 隧道
+
+
+ SSH 用户
+
+
+ user
+
+
+ SSH 主机
+
+
+ 192.168.x.x
+
+
+ 远程端口
+
+
+ 本地端口
+
+
+ 事件时间线
+
+
+ 复制
+
+
+ 清除
+
+
+ 诊断包
+
+
+ 分享前请审阅
+
+
+ 捆绑包生成器会排除敏感令牌、命令参数、负载和录制内容。
+
+
+ 已断开连接
+
+
+ 操作
+
+
+ 节点
+
+
+ 高级
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..f15c332b3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -3507,4 +3507,919 @@
Connection Status
+
+ 閘道已中斷
+
+
+ 請連接至閘道以載入繫結。
+
+
+ 開啟連線
+
+
+ 正在載入繫結…
+
+
+ 等待您的核准
+
+
+ 未連線
+
+
+ 連線
+
+
+ 操作員
+
+
+ 從這台電腦向此閘道傳送命令並檢視工作階段。
+
+
+ 未啟用
+
+
+ 檢視工作階段 ›
+
+
+ 檢視執行個體 ›
+
+
+ 節點模式
+
+
+ 啟用後,這台電腦會註冊為節點,並透過閘道向代理提供下列功能。
+
+
+ 節點模式已停用
+
+
+ 複製
+
+
+ 連線
+
+
+ 管理權限 ›
+
+
+ 協助我們修復此連線:
+
+
+ SSH 通道已停止。
+
+
+ 重新啟動通道
+
+
+ 編輯通道設定
+
+
+ 貼上來自閘道主機的新設定代碼。
+
+
+ 在此貼上設定代碼…
+
+
+ 套用
+
+
+ 在閘道主機上執行:
+
+
+ 複製
+
+
+ 連線
+
+
+ 中斷連線
+
+
+ 已儲存的閘道
+
+
+ 閘道
+
+
+ 掃描
+
+
+ 新增閘道
+
+
+ 尚無已儲存的閘道。
+
+
+ 在您的網路上探索到
+
+
+ 新增閘道
+
+
+ 開始使用 — 安裝本機閘道
+
+
+ 最快的入門方式:在這台電腦上設定由 WSL 託管的閘道,並將其設為使用中的連線。
+
+
+ 安裝
+
+
+ 或連線至現有閘道:
+
+
+ 直接
+
+
+ URL + 權杖
+
+
+ 設定代碼
+
+
+ 貼上 QR 內容
+
+
+ 每種方法都支援選用的 SSH 通道。
+
+
+ 或在您的網路上尋找。
+
+
+ 掃描
+
+
+ 在您的網路上探索到
+
+
+ 返回
+
+
+ 新增閘道
+
+
+ 直接
+
+
+ 設定代碼
+
+
+ 本機 WSL
+
+
+ 閘道 URL
+
+
+ ws://host.local:18790
+
+
+ 共用權杖
+
+
+ 貼上共用閘道權杖
+
+
+ 名稱(選用)
+
+
+ 我的閘道
+
+
+ 貼上來自閘道的 QR 碼或 `openclaw qr` 輸出的設定代碼。
+
+
+ 在此貼上設定代碼…
+
+
+ 解碼
+
+
+ 在這台電腦上安裝新的 WSL 閘道,並將其設為使用中的連線。安裝精靈會視需要安裝 WSL、佈建閘道,並自動配對這台裝置。
+
+
+ 安裝本機閘道
+
+
+ 這台電腦無法進行本機 WSL 設定 — 安裝精靈無法初始化。
+
+
+ 使用 SSH 通道(選用)
+
+
+ SSH 使用者
+
+
+ user
+
+
+ SSH 主機
+
+
+ machine-name
+
+
+ 遠端連接埠
+
+
+ 本機連接埠
+
+
+ 儲存並連線
+
+
+ 取消
+
+
+ ⏱️ Cron 工作
+
+
+ 重新整理
+
+
+ 新增工作
+
+
+ 閘道已中斷
+
+
+ 請連接至閘道以載入 cron 工作。
+
+
+ 開啟連線
+
+
+ 新增 Cron 工作
+
+
+ 名稱
+
+
+ 晨間簡報
+
+
+ 排程
+
+
+ 每
+
+
+ 於
+
+
+ Cron
+
+
+ 快速預設
+
+
+ ⏰ 每小時
+
+
+ 🌅 每天上午 9 點
+
+
+ 🌆 每天下午 6 點
+
+
+ 💼 平日
+
+
+ 📅 每週
+
+
+ 運算式
+
+
+ 時區
+
+
+ 選用
+
+
+ America/New_York
+
+
+ America/Chicago
+
+
+ America/Denver
+
+
+ America/Los_Angeles
+
+
+ Europe/London
+
+
+ Europe/Berlin
+
+
+ Asia/Tokyo
+
+
+ UTC
+
+
+ 每
+
+
+ 單位
+
+
+ 分鐘
+
+
+ 小時
+
+
+ 天
+
+
+ 執行時間
+
+
+ 日期
+
+
+ 15:30
+
+
+ 執行後刪除工作
+
+
+ 提示 / 內容
+
+
+ 代理應該做什麼?
+
+
+ 傳遞
+
+
+ 靜默(無)
+
+
+ 通知我(通告)
+
+
+ 通道
+
+
+ 例如 webchat
+
+
+ 進階選項
+
+
+ 工作階段行為
+
+
+ 每次執行建立新工作階段
+
+
+ 繼續主要工作階段
+
+
+ 喚醒模式
+
+
+ 立即(即時)
+
+
+ 佇列(等待閒置)
+
+
+ 取消
+
+
+ 建立工作
+
+
+ 正在載入 cron 工作…
+
+
+ 未設定 cron 工作
+
+
+ 透過閘道設定 cron 工作後,它們會顯示在此處。
+
+
+ 狀態
+
+
+ 已複製
+
+
+ 無覆寫
+
+
+ 強制使用閘道聊天介面
+
+
+ 強制使用伴侶聊天介面
+
+
+ 無覆寫
+
+
+ 強制使用閘道聊天介面
+
+
+ 強制使用伴侶聊天介面
+
+
+ 重新整理
+
+
+ 複製
+
+
+ 開啟檔案
+
+
+ 需要配對核准
+
+
+ 節點已連線,但在您核准配對要求之前,其功能不會啟用。
+
+
+ 在「連線」頁面檢視
+
+
+ 返回連線
+
+
+ 權限
+
+
+ 節點模式
+
+
+ 啟用後,這台電腦會註冊為節點,並透過閘道向代理提供下列功能。
+
+
+ 功能
+
+
+ 切換代理可能從這台電腦要求的各項功能。
+
+
+ 整合
+
+
+ 預設動作
+
+
+ 當命令不符合下列任何規則時會發生什麼。
+
+
+ 模式由左至右與完整命令列比對。使用 * 作為萬用字元。變更會自動儲存。
+
+
+ 已儲存
+
+
+ 0 條規則
+
+
+ 尚無規則。在下方新增模式以允許或拒絕特定命令。
+
+
+ Windows 隱私
+
+
+ 攝影機、麥克風與螢幕存取
+
+
+ 系統層級的隱私權限由 Windows 管理。開啟 Windows 隱私設定以授予或撤銷 OpenClaw 的存取權。
+
+
+ 節點沙箱已開啟
+
+
+ 代理在這台電腦上執行的程式受到隔離。
+
+
+ 哪些內容受隔離
+
+
+ 檢視此頁面涵蓋的命令 — 以及需要其他控制項的命令。
+
+
+ 代理透過 Windows 節點執行的命令 (
+
+
+ system.run
+
+
+ ) 受下方設定隔離。
+
+
+ 代理直接在閘道上執行的命令則不受隔離。如果閘道位於這台電腦的 WSL 上,它們可能會存取您的 Windows 檔案、剪貼簿和網路 — 請使用閘道的執行核准。
+
+
+ 沙箱無法使用
+
+
+ 深入了解 MXC 沙箱
+
+
+ 安全等級
+
+
+ 一鍵設定下方所有控制項。自訂資料夾會保留您的設定。
+
+
+ 🔒 鎖定
+
+
+ 無網際網路、無剪貼簿、無標準使用者資料夾。
+
+
+ 🛡 建議
+
+
+ 網際網路開啟。對「文件」、「下載」、「桌面」為唯讀。剪貼簿可讀。
+
+
+ ⚠ 不受保護
+
+
+ 網際網路開啟。對「文件」、「下載」、「桌面」可讀寫。剪貼簿可讀寫。
+
+
+ 自訂資料夾授權仍然有效
+
+
+ 📁 檔案
+
+
+ 預設情況下,代理只有一個可讀寫的暫存工作資料夾。在下方授予使用者資料夾的存取權限。SSH 金鑰、瀏覽器設定檔以及 OpenClaw 本身的設定一律會被封鎖。
+
+
+ 文件
+
+
+ 已封鎖
+
+
+ 唯讀
+
+
+ 讀取與寫入
+
+
+ 下載
+
+
+ 已封鎖
+
+
+ 唯讀
+
+
+ 讀取與寫入
+
+
+ 桌面
+
+
+ 已封鎖
+
+
+ 唯讀
+
+
+ 讀取與寫入
+
+
+ PATH 上的工具目錄在沙箱內也以唯讀方式授予,因此 git、node、python 與 dotnet 等命令列工具可使用。一律不會授予磁碟機根目錄。
+
+
+ 自訂資料夾
+
+
+ 自訂授權會疊加於上述規則之上。在「文件/下載/桌面」內新增具有更寬權限的資料夾,會覆寫該路徑的行層級設定。
+
+
+ 已封鎖
+
+
+ 唯讀
+
+
+ 讀取與寫入
+
+
+ 尚未新增自訂資料夾。
+
+
+ 新增資料夾
+
+
+ 📋 剪貼簿
+
+
+ 剪貼簿通常包含密碼、權杖與私人文字。讀取權限可能會將這些洩露給代理(若已啟用,也會洩露至網際網路)。
+
+
+ 無(預設)
+
+
+ 讀取 — 代理可以看到您複製的內容
+
+
+ 寫入 — 代理可以取代您的剪貼簿(無法讀取)
+
+
+ 讀取與寫入
+
+
+ 📡 網路
+
+
+ 允許網際網路
+
+
+ 如果啟用檔案或剪貼簿讀取權限,代理可能會透過網際網路傳送這些資料。
+
+
+ 尚未包含本機網路裝置與共用。
+
+
+ ⏱ 限制
+
+
+ 命令逾時:30 秒
+
+
+ 最大擷取輸出(超過此值會被截斷;不限制磁碟、記憶體或 CPU):
+
+
+ 1 MiB
+
+
+ 4 MiB(預設)
+
+
+ 16 MiB
+
+
+ 64 MiB
+
+
+ 返回連線
+
+
+ 工作階段
+
+
+ 重新整理
+
+
+ 全部
+
+
+ 閘道已中斷
+
+
+ 請連接至閘道以載入工作階段。
+
+
+ 開啟連線
+
+
+ 正在載入工作階段…
+
+
+ 沒有作用中的工作階段
+
+
+ 重設
+
+
+ 壓縮
+
+
+ 刪除
+
+
+ 設定
+
+
+ 一般
+
+
+ 通知
+
+
+ 隱私
+
+
+ 本機閘道
+
+
+ 偵測到 MSIX 安裝
+
+
+ 透過 Windows 設定 &#x2192; 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。
+
+
+ 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。
+
+
+ 移除本機閘道
+
+
+ 已儲存
+
+
+ 🧩 技能
+
+
+ 所有代理
+
+
+ 已啟用
+
+
+ 已停用
+
+
+ 正在載入技能…
+
+
+ 未安裝技能
+
+
+ 技能可擴充您代理的能力。透過 CLI 或 ClawHub 安裝技能。
+
+
+ 閘道已中斷
+
+
+ 請連接至閘道以載入使用量資料。
+
+
+ 開啟連線
+
+
+ 正在載入每日成本…
+
+
+ 此期間內無每日使用量
+
+
+ 測試語音輸入
+
+
+ 錄製
+
+
+ 正在載入工作區檔案…
+
+
+ 網頁聊天無法使用
+
+
+ 重試
+
+
+ 連線狀態
+
+
+ 狀態機
+
+
+ 操作員
+
+
+ 關閉
+
+
+ 正在連線
+
+
+ 已連線
+
+
+ 正在配對
+
+
+ 錯誤
+
+
+ 節點
+
+
+ 關閉
+
+
+ 正在連線
+
+
+ 已連線
+
+
+ 正在配對
+
+
+ 錯誤
+
+
+ 閘道
+
+
+ 認證
+
+
+ 設定代碼
+
+
+ 貼上設定代碼以建立端對端連線。
+
+
+ 貼上設定代碼…
+
+
+ 連線
+
+
+ 中斷連線
+
+
+ 直接連線
+
+
+ 使用閘道 URL 和共用權杖(管理員範圍)連線。
+
+
+ ws://localhost:18790
+
+
+ ws://localhost:18790
+
+
+ 閘道 URL
+
+
+ 共用閘道權杖
+
+
+ 權杖
+
+
+ 連線
+
+
+ SSH 通道
+
+
+ SSH 使用者
+
+
+ user
+
+
+ SSH 主機
+
+
+ 192.168.x.x
+
+
+ 遠端連接埠
+
+
+ 本機連接埠
+
+
+ 事件時間軸
+
+
+ 複製
+
+
+ 清除
+
+
+ 診斷套件
+
+
+ 分享前請檢閱
+
+
+ 套件產生器會排除敏感權杖、命令引數、內容與錄製。
+
+
+ 已中斷連線
+
+
+ 操作
+
+
+ 節點
+
+
+ 進階
+
diff --git a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml
index 788d88260..07f299be9 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/ChatWindow.xaml
@@ -69,11 +69,11 @@
-
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml
index 907cc1985..fc73fcbb8 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml
@@ -19,7 +19,7 @@
-
@@ -27,7 +27,7 @@
-
+
@@ -37,16 +37,16 @@
-
-
+
+
-
+
-
+
-
-
+
+
@@ -54,38 +54,38 @@
-
-
+
+
-
+
-
+
-
-
+
+
-
+
-
+
-
+
-
-
@@ -97,20 +97,20 @@
-
-
+
+
-
+
-
-
-
+
+
@@ -118,12 +118,12 @@
-
-
@@ -131,16 +131,16 @@
-
-
+
+
-
-
+
+
@@ -160,9 +160,9 @@
-
-
-
+
+
+
-
-
-
-
-
@@ -150,7 +150,7 @@
-
+
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index b4867f843..ae5d2812a 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -146,6 +146,29 @@ public class LocalizationValidationTests
"ConnectionPage_NodePairing_Subtitle.Text",
"AboutPage_MoreDiagnosticsLink.Content",
"ConnectionStatusWindow.Title",
+ // Hard-coded XAML strings resolved by issue #491 — seeded English-only across
+ // all 5 locales using the deferred-translation pattern. Translations are a
+ // follow-up tracked separately. Same precedent as the PermissionsPage and
+ // InstancesPage runtime keys above.
+ "ConnectionPage_GatewayURL.PlaceholderText",
+ "ConnectionPage_SSHHost.PlaceholderText",
+ "ConnectionPage_SSHUser.PlaceholderText",
+ "ConnectionStatusWindow_SSHHost.PlaceholderText",
+ "ConnectionStatusWindow_SSHUser.PlaceholderText",
+ "ConnectionStatusWindow_WsLocalhost18790.PlaceholderText",
+ "ConnectionStatusWindow_WsLocalhost18790.Text",
+ "CronPage_AmericaChicago.Content",
+ "CronPage_AmericaDenver.Content",
+ "CronPage_AmericaLosAngeles.Content",
+ "CronPage_AmericaNewYork.Content",
+ "CronPage_AsiaTokyo.Content",
+ "CronPage_EuropeBerlin.Content",
+ "CronPage_EuropeLondon.Content",
+ "CronPage_UTC.Content",
+ "SandboxPage_16MiB.Content",
+ "SandboxPage_1MiB.Content",
+ "SandboxPage_64MiB.Content",
+ "SandboxPage_SystemRun.Text",
};
private static readonly string[] RequiredRuntimeOnboardingKeys =
From 3113262d59d61f6baaca931502c631738a734a66 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:16:31 -0700
Subject: [PATCH 068/115] Skills page: replace hand-rolled group headers with
stock Expander
Drop the custom Button + chevron + manual collapse state for the
Enabled / Disabled sections in favor of stock WinUI Expander, which
provides the chevron, animation, and IsExpanded state natively. This
removes _enabledExpanded / _disabledExpanded fields, the click
handlers, and the ScrollViewer.ChangeView workaround, and brings the
page closer to the Fluent design system used elsewhere in the app.
Also lift the ScrollViewer to wrap the whole page so the header and
groups scroll together, matching PermissionsPage / DebugPage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 129 ++++++++----------
.../Pages/SkillsPage.xaml.cs | 20 +--
2 files changed, 57 insertions(+), 92 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
index 1984ea66f..41b129160 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
@@ -4,89 +4,72 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
-
-
-
-
+
+
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
index 5f6602483..e72a3b888 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
@@ -26,19 +26,6 @@ public SkillsPage()
{
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
};
- EnabledHeaderBtn.Click += (s, e) =>
- {
- _enabledExpanded = !_enabledExpanded;
- EnabledChevron.Glyph = _enabledExpanded ? "\uE70E" : "\uE70D";
- EnabledPanel.Visibility = _enabledExpanded ? Visibility.Visible : Visibility.Collapsed;
- if (!_enabledExpanded) SkillsScroller.ChangeView(null, 0, null, disableAnimation: false);
- };
- DisabledHeaderBtn.Click += (s, e) =>
- {
- _disabledExpanded = !_disabledExpanded;
- DisabledChevron.Glyph = _disabledExpanded ? "\uE70E" : "\uE70D";
- DisabledPanel.Visibility = _disabledExpanded ? Visibility.Visible : Visibility.Collapsed;
- };
}
public void Initialize()
@@ -198,9 +185,6 @@ public void UpdateFromGateway(JsonElement data)
RebuildCards();
}
- private bool _enabledExpanded = true;
- private bool _disabledExpanded = true;
-
private void RebuildCards()
{
LoadingState.Visibility = Visibility.Collapsed;
@@ -217,9 +201,7 @@ private void RebuildCards()
EnabledHeaderText.Text = $"Enabled ({enabled.Count})";
DisabledHeaderText.Text = $"Disabled ({disabled.Count})";
- DisabledHeaderBtn.Visibility = disabled.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
- DisabledPanel.Visibility = _disabledExpanded ? Visibility.Visible : Visibility.Collapsed;
- EnabledPanel.Visibility = _enabledExpanded ? Visibility.Visible : Visibility.Collapsed;
+ DisabledExpander.Visibility = disabled.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
var total = _allSkills.Count;
CountText.Text = total > 0 ? $"({enabled.Count}/{total} enabled)" : "";
From 4b054a072f97336f8bc01180acd74abf95b30f13 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:28:49 -0700
Subject: [PATCH 069/115] Tidy Usage page layout and recover from disconnected
gateway
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Layout:
- Drop the emoji from the page title (no emoji on WinUI surfaces per
openclaw-design skill); update en-us/fr-fr/nl-nl/zh-cn/zh-tw resw.
- Switch 3 stat cards from horizontal StackPanel to a 3-column Grid
with equal widths so they share available width cleanly.
- Replace raw FontSize/FontWeight with Fluent typography styles
(TitleLarge for the hero Total Cost, Subtitle for the small stats)
per winuxe Critical Rule #4 — also gives the page proper hierarchy.
Bug fix (page not loading / no values):
- OpenClawGatewayClient.RequestUsageAsync/CostAsync/StatusAsync silently
no-op when !IsConnectedToGateway, so navigating while the gateway
was reconnecting left the progress rings spinning forever.
- Treat (client == null || !client.IsConnectedToGateway) as
disconnected in Initialize and SelectPeriod.
- Subscribe to client.StatusChanged so the page self-heals when the
gateway comes online later (re-fires the three requests on the UI
thread). Unsubscribe in Unloaded.
- After Fail() the loading state is !IsRefreshing && !HasLoaded, which
hid loading + content + empty all at once. Surface a 'Couldn't load.
Check your gateway connection.' message in that state.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml | 36 +++--
.../Pages/UsagePage.xaml.cs | 130 +++++++++++++-----
.../Strings/en-us/Resources.resw | 2 +-
.../Strings/fr-fr/Resources.resw | 2 +-
.../Strings/nl-nl/Resources.resw | 2 +-
.../Strings/zh-cn/Resources.resw | 2 +-
.../Strings/zh-tw/Resources.resw | 2 +-
7 files changed, 128 insertions(+), 48 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
index d4c40cc93..b3980306b 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
@@ -4,11 +4,11 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
+
-
+
-
-
-
-
+
+
+
+
+
+
+
-
+
-
-
+
-
-
+
-
+
(App)Microsoft.UI.Xaml.Application.Current;
private AppState? _appState;
+ private IOperatorGatewayClient? _trackedClient;
// Default matches the XAML-selected Period7DaysItem (IsSelected="True").
private int _currentPeriodDays = 7;
private readonly AsyncListLoadingState _providerLoading = new();
private readonly AsyncListLoadingState _dailyCostLoading = new();
private DateTime _lastAppliedUsageCostUpdatedAtUtc = DateTime.MinValue;
+ private const string DailyEmptyMessage = "No daily usage for this period";
+ private const string ProviderEmptyMessage = "No providers configured";
+ private const string DisconnectedListMessage = "Couldn't load. Check your gateway connection.";
+
public UsagePage()
{
InitializeComponent();
Unloaded += (_, _) =>
{
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
+ DetachClient();
};
}
@@ -34,42 +40,97 @@ public void Initialize()
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
_appState = CurrentApp.AppState;
_appState.PropertyChanged += OnAppStateChanged;
+
var client = CurrentApp.GatewayClient;
- if (client != null)
- {
- ConnectionInfoBar.IsOpen = false;
- // Apply cached data immediately, then request fresh.
- if (_appState?.Usage != null) UpdateUsage(_appState.Usage);
- // Only apply cached cost data when its period matches the current
- // selection — otherwise the daily list briefly shows e.g. 30-day
- // data while the selector reads "7 Days".
- if (_appState?.UsageCost != null && _appState.UsageCost.Days == _currentPeriodDays)
- {
- UpdateUsageCost(_appState.UsageCost);
- _dailyCostLoading.BeginRefresh();
- }
- else
- {
- _dailyCostLoading.BeginInitialRefresh();
- }
- if (_appState?.UsageStatus != null) UpdateUsageStatus(_appState.UsageStatus);
- else _providerLoading.BeginInitialRefresh();
- UpdateDailyCostLoadingVisuals();
- UpdateProviderLoadingVisuals();
- _ = client.RequestUsageAsync();
- _ = client.RequestUsageCostAsync(_currentPeriodDays);
- _ = client.RequestUsageStatusAsync();
- }
- else
+ AttachClient(client);
+
+ // A non-null client may still be disconnected (e.g. WebSocket reconnecting).
+ // The underlying RequestUsage*/RequestUsageCost*/RequestUsageStatus calls
+ // silently no-op when !IsConnectedToGateway, so without this check the
+ // page would spin its progress rings forever — see user reports of
+ // "page not loading, no values".
+ if (client == null || !client.IsConnectedToGateway)
{
_providerLoading.Fail();
_dailyCostLoading.Fail();
ShowDisconnected();
UpdateProviderLoadingVisuals();
UpdateDailyCostLoadingVisuals();
+ return;
+ }
+
+ ConnectionInfoBar.IsOpen = false;
+ // Apply cached data immediately, then request fresh.
+ if (_appState?.Usage != null) UpdateUsage(_appState.Usage);
+ // Only apply cached cost data when its period matches the current
+ // selection — otherwise the daily list briefly shows e.g. 30-day
+ // data while the selector reads "7 Days".
+ if (_appState?.UsageCost != null && _appState.UsageCost.Days == _currentPeriodDays)
+ {
+ UpdateUsageCost(_appState.UsageCost);
+ _dailyCostLoading.BeginRefresh();
+ }
+ else
+ {
+ _dailyCostLoading.BeginInitialRefresh();
+ }
+ if (_appState?.UsageStatus != null) UpdateUsageStatus(_appState.UsageStatus);
+ else _providerLoading.BeginInitialRefresh();
+ UpdateDailyCostLoadingVisuals();
+ UpdateProviderLoadingVisuals();
+ RequestRefresh(client);
+ }
+
+ private void AttachClient(IOperatorGatewayClient? client)
+ {
+ if (ReferenceEquals(_trackedClient, client)) return;
+ DetachClient();
+ _trackedClient = client;
+ if (_trackedClient != null)
+ {
+ _trackedClient.StatusChanged += OnClientStatusChanged;
+ }
+ }
+
+ private void DetachClient()
+ {
+ if (_trackedClient != null)
+ {
+ _trackedClient.StatusChanged -= OnClientStatusChanged;
+ _trackedClient = null;
}
}
+ private void OnClientStatusChanged(object? sender, ConnectionStatus status)
+ {
+ // Recover automatically when the gateway comes online while the page
+ // is open (otherwise the user is stuck on the disconnected info bar
+ // with stale cards until they navigate away and back).
+ if (sender is not IOperatorGatewayClient client) return;
+ if (!client.IsConnectedToGateway) return;
+
+ var dispatcher = DispatcherQueue;
+ if (dispatcher == null) return;
+ dispatcher.TryEnqueue(() =>
+ {
+ if (_trackedClient != client) return;
+ ConnectionInfoBar.IsOpen = false;
+ if (!_dailyCostLoading.HasLoaded) _dailyCostLoading.BeginInitialRefresh();
+ else _dailyCostLoading.BeginRefresh();
+ if (!_providerLoading.HasLoaded) _providerLoading.BeginInitialRefresh();
+ UpdateDailyCostLoadingVisuals();
+ UpdateProviderLoadingVisuals();
+ RequestRefresh(client);
+ });
+ }
+
+ private void RequestRefresh(IOperatorGatewayClient client)
+ {
+ _ = client.RequestUsageAsync();
+ _ = client.RequestUsageCostAsync(_currentPeriodDays);
+ _ = client.RequestUsageStatusAsync();
+ }
+
private void OnOpenConnectionClick(object sender, RoutedEventArgs e)
=> ((IAppCommands)CurrentApp).Navigate("connection");
@@ -130,7 +191,6 @@ public void UpdateUsageStatus(GatewayUsageStatusInfo status)
Status = p.Error ?? "",
}).ToList();
- bool hasProviders = status.Providers.Count > 0;
_providerLoading.Complete(status.Providers.Count);
UpdateProviderLoadingVisuals();
}
@@ -152,9 +212,10 @@ private void SelectPeriod(int days)
_dailyCostLoading.BeginInitialRefresh();
UpdateDailyCostLoadingVisuals();
- if (CurrentApp.GatewayClient != null)
+ var client = CurrentApp.GatewayClient;
+ if (client != null && client.IsConnectedToGateway)
{
- _ = CurrentApp.GatewayClient.RequestUsageCostAsync(days);
+ _ = client.RequestUsageCostAsync(days);
}
else
{
@@ -168,7 +229,12 @@ private void UpdateDailyCostLoadingVisuals()
{
DailyLoadingPanel.Visibility = _dailyCostLoading.ShouldShowLoading ? Visibility.Visible : Visibility.Collapsed;
DailyListView.Visibility = _dailyCostLoading.ShouldShowContent ? Visibility.Visible : Visibility.Collapsed;
- DailyEmptyText.Visibility = _dailyCostLoading.ShouldShowEmpty ? Visibility.Visible : Visibility.Collapsed;
+ // After Fail() the state is !IsRefreshing && !HasLoaded, which leaves the
+ // card visually empty (no spinner, no rows, no message). Surface a
+ // disconnected message in that case so the page never looks frozen.
+ bool failed = !_dailyCostLoading.IsRefreshing && !_dailyCostLoading.HasLoaded;
+ DailyEmptyText.Text = failed ? DisconnectedListMessage : DailyEmptyMessage;
+ DailyEmptyText.Visibility = (_dailyCostLoading.ShouldShowEmpty || failed) ? Visibility.Visible : Visibility.Collapsed;
PeriodSelector.IsEnabled = _dailyCostLoading.CanEdit;
}
@@ -176,7 +242,9 @@ private void UpdateProviderLoadingVisuals()
{
ProviderLoadingPanel.Visibility = _providerLoading.ShouldShowLoading ? Visibility.Visible : Visibility.Collapsed;
ProviderListView.Visibility = _providerLoading.ShouldShowContent ? Visibility.Visible : Visibility.Collapsed;
- ProviderEmptyText.Visibility = _providerLoading.ShouldShowEmpty ? Visibility.Visible : Visibility.Collapsed;
+ bool failed = !_providerLoading.IsRefreshing && !_providerLoading.HasLoaded;
+ ProviderEmptyText.Text = failed ? DisconnectedListMessage : ProviderEmptyMessage;
+ ProviderEmptyText.Visibility = (_providerLoading.ShouldShowEmpty || failed) ? Visibility.Visible : Visibility.Collapsed;
}
private void ShowDisconnected()
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..698596150 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2407,7 +2407,7 @@ On your gateway host (Mac/Linux), run:
OpenClaw Menu
- 📊 Usage & Cost
+ Usage & Cost
Open in Companion app
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..3624a2202 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2359,7 +2359,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
OpenClaw Menu
- 📊 Utilisation et coût
+ Utilisation et coût
Ouvrir dans le navigateur
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..fea6ca50a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2360,7 +2360,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
OpenClaw Menu
- 📊 Gebruik en kosten
+ Gebruik en kosten
Openen in browser
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..adbd9ea2b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2359,7 +2359,7 @@
OpenClaw Menu
- 📊 使用量和成本
+ 使用量和成本
在浏览器中打开
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..1d97367f1 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2359,7 +2359,7 @@
OpenClaw Menu
- 📊 使用量與成本
+ 使用量與成本
在瀏覽器中開啟
From 222cf826ab0f1b56be0d379e312af5034f1444de Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:41:55 -0700
Subject: [PATCH 070/115] Skills page: match canonical page sizing (Padding=24,
Stretch+MaxWidth=900)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Align with PermissionsPage / ConnectionPage / DebugPage convention:
uniform 24px padding (was 24,16,24,16), HorizontalAlignment=Stretch
with MaxWidth=900 inside a Grid wrapper so the ScrollViewer measures
with a finite width and centers the column with growing side margins.
The Grid wrapper is the ConnectionPage pattern — needed here because
the Skills page's initial visible content (a small ComboBox) is
narrower than the 900 cap, which would otherwise collapse Stretch to
the child's desired width and left-shift the column.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
index 41b129160..888faf57d 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml
@@ -5,8 +5,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
+
+
@@ -70,6 +71,7 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center" TextWrapping="Wrap" MaxWidth="320"/>
-
+
+
From 379926c8f9e90771dbdb24cd24c689802b47ff6a Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:49:37 -0700
Subject: [PATCH 071/115] Skills page: use theme-aware Fluent tokens for status
pills
Replace the hand-mixed LimeGreen/amber ARGB colors on the Enabled /
Disabled badges with the canonical SystemFillColorSuccess(Background)?
brush pair (Enabled) and ControlFillColorSecondaryBrush /
TextFillColorSecondaryBrush (Disabled), matching ChannelsPage'
s badge
pattern. Fixes a low-contrast pill in light theme and brings the pill
into Fluent + High Contrast compliance.
Also bump the pill to the canonical chip metrics (CornerRadius=10,
MinHeight=20, Padding=8/2, FontSize=11) and capitalize the label.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/SkillsPage.xaml.cs | 26 +++++++++++--------
1 file changed, 15 insertions(+), 11 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
index e72a3b888..69e66be5c 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs
@@ -1,4 +1,3 @@
-using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
@@ -7,7 +6,6 @@
using System.ComponentModel;
using System.Linq;
using System.Text.Json;
-using Windows.UI;
namespace OpenClawTray.Pages;
@@ -239,18 +237,24 @@ private Grid BuildCard(SkillData s)
var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Opacity = contentOpacity };
nameRow.Children.Add(new TextBlock { Text = s.Name, FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center });
- var badgeBg = s.IsEnabled
- ? new SolidColorBrush(Color.FromArgb(40, 76, 175, 80))
- : new SolidColorBrush(Color.FromArgb(40, 230, 168, 23));
- var badgeFg = s.IsEnabled
- ? new SolidColorBrush(Colors.LimeGreen)
- : new SolidColorBrush(Color.FromArgb(255, 230, 168, 23));
+ var badgeBgKey = s.IsEnabled ? "SystemFillColorSuccessBackgroundBrush" : "ControlFillColorSecondaryBrush";
+ var badgeFgKey = s.IsEnabled ? "SystemFillColorSuccessBrush" : "TextFillColorSecondaryBrush";
var badge = new Border
{
- CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2),
- Background = badgeBg, VerticalAlignment = VerticalAlignment.Center
+ CornerRadius = new CornerRadius(10),
+ MinHeight = 20,
+ Padding = new Thickness(8, 2, 8, 2),
+ Background = (Brush)Application.Current.Resources[badgeBgKey],
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ badge.Child = new TextBlock
+ {
+ Text = s.IsEnabled ? "Enabled" : "Disabled",
+ FontSize = 11,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ Foreground = (Brush)Application.Current.Resources[badgeFgKey],
+ VerticalAlignment = VerticalAlignment.Center
};
- badge.Child = new TextBlock { Text = s.IsEnabled ? "enabled" : "disabled", FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = badgeFg, VerticalAlignment = VerticalAlignment.Center };
nameRow.Children.Add(badge);
Grid.SetRow(nameRow, 0);
Grid.SetColumn(nameRow, 0);
From 6b827e4670ff69b7bee81f9b3a186a14f779a9ca Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 23:55:14 -0700
Subject: [PATCH 072/115] Reorganize Config page UI to follow Fluent guidelines
- Remove gear emoji from page title (stripped from all 5 locale resw files)
- Replace TabView with WinUI SelectorBar (segmented control) for Editor / Raw JSON
- Move Refresh button to the right of the title (matches CronPage pattern)
- Cap content at MaxWidth=900 with Stretch alignment so Editor and Raw JSON panes are always the same width and the editor no longer resizes with selected field
- Merge tree + detail into a single card with an internal 1px vertical divider (removes the 12px gutter)
- Replace fallback emoji with FontIcon Settings glyph; replace raw FontSize with CaptionTextBlockStyle per platform rules
- Add ApplyPaneVisibility() helper to reconcile Editor / Raw JSON / NoSchema fallback in one place
- Add ConfigPage_TabViewItem_*.Text resw keys for new SelectorBarItem (removed obsolete .Header keys)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml | 176 +++++++++++-------
.../Pages/ConfigPage.xaml.cs | 52 ++++--
.../Strings/en-us/Resources.resw | 8 +-
.../Strings/fr-fr/Resources.resw | 8 +-
.../Strings/nl-nl/Resources.resw | 8 +-
.../Strings/zh-cn/Resources.resw | 8 +-
.../Strings/zh-tw/Resources.resw | 8 +-
7 files changed, 159 insertions(+), 109 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
index 6138c1046..67875d12a 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
@@ -3,95 +3,128 @@
x:Class="OpenClawTray.Pages.ConfigPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:local="using:OpenClawTray.Controls">
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
-
+
@@ -103,9 +136,14 @@
HorizontalAlignment="Center" Style="{ThemeResource AccentButtonStyle}"/>
-
-
-
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
index 46d2a89bb..aa0f72a69 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs
@@ -28,6 +28,8 @@ public sealed partial class ConfigPage : Page
private readonly Dictionary _nodeMap = new();
private readonly Dictionary _pendingChanges = new();
+ private bool _showSchemaFallback;
+
public ConfigPage()
{
InitializeComponent();
@@ -131,8 +133,8 @@ private void RenderTree()
if (_lastSchema.HasValue)
{
// Schema-driven tree
- NoSchemaPanel.Visibility = Visibility.Collapsed;
- SchemaTreeGrid.Visibility = Visibility.Visible;
+ _showSchemaFallback = false;
+ ApplyPaneVisibility();
var schema = _lastSchema.Value;
var schemaRoot = schema.TryGetProperty("schema", out var sr) ? sr : schema;
@@ -141,16 +143,11 @@ private void RenderTree()
BuildSchemaTreeNodes(ConfigTree.RootNodes, schemaRoot, configRoot, "");
ExpandAll(ConfigTree.RootNodes);
}
- else if (_lastConfig.HasValue)
- {
- // No schema — show fallback panel
- SchemaTreeGrid.Visibility = Visibility.Collapsed;
- NoSchemaPanel.Visibility = Visibility.Visible;
- }
else
{
- SchemaTreeGrid.Visibility = Visibility.Collapsed;
- NoSchemaPanel.Visibility = Visibility.Visible;
+ // No schema — show fallback panel (whether or not config loaded)
+ _showSchemaFallback = true;
+ ApplyPaneVisibility();
}
}
@@ -186,12 +183,28 @@ private static JsonElement ExtractConfigRoot(JsonElement configResponse)
private void ShowConfigRenderError()
{
- SchemaTreeGrid.Visibility = Visibility.Collapsed;
- NoSchemaPanel.Visibility = Visibility.Visible;
+ _showSchemaFallback = true;
+ ApplyPaneVisibility();
SaveButton.IsEnabled = false;
SaveStatus.Text = "Config unavailable";
}
+ ///
+ /// Reconciles which of the three Row 2 surfaces is visible:
+ /// the Editor card, the Raw JSON card, or the "no schema" fallback.
+ /// Called whenever schema state or the SelectorBar selection changes.
+ ///
+ private void ApplyPaneVisibility()
+ {
+ if (EditorPane is null || RawJsonPane is null || NoSchemaPanel is null) return;
+
+ var rawSelected = (ConfigSelector?.SelectedItem?.Tag as string) == "raw";
+
+ RawJsonPane.Visibility = rawSelected ? Visibility.Visible : Visibility.Collapsed;
+ EditorPane.Visibility = (!rawSelected && !_showSchemaFallback) ? Visibility.Visible : Visibility.Collapsed;
+ NoSchemaPanel.Visibility = (!rawSelected && _showSchemaFallback) ? Visibility.Visible : Visibility.Collapsed;
+ }
+
private void BuildSchemaTreeNodes(IList parent, JsonElement schema, JsonElement? config, string basePath)
{
if (!schema.TryGetProperty("properties", out var properties) ||
@@ -358,11 +371,9 @@ private void OnRefresh(object sender, RoutedEventArgs e)
private void UpdateRawJson()
{
- // RawJsonText lives inside the second TabViewItem, whose content WinUI
- // does not realize until the tab is first selected. Until then the
- // x:Name field is null and touching .Text would NRE on the dispatcher
- // queue — silently tearing down the app. OnConfigTabChanged calls us
- // again once the tab is realized, so deferring here is safe.
+ // RawJsonText lives inside the Raw JSON pane; it's always realized
+ // (we just toggle Visibility), but keep the null guard for safety
+ // in case this is called before InitializeComponent finishes.
if (RawJsonText is null) return;
if (_lastConfig.HasValue)
@@ -385,10 +396,11 @@ private void UpdateRawJson()
}
}
- private void OnConfigTabChanged(object sender, SelectionChangedEventArgs e)
+ private void OnConfigSelectorChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
{
- // Refresh raw JSON when switching to it
- if (ConfigTabs.SelectedIndex == 1)
+ ApplyPaneVisibility();
+
+ if ((sender.SelectedItem?.Tag as string) == "raw")
UpdateRawJson();
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..4206083fa 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1642,18 +1642,18 @@ On your gateway host (Mac/Linux), run:
The gateway is connected; the chat surface is still coming online.
- ⚙️ Config
+ Config
Refresh
-
+
Editor
Select a config section
-
+
Raw JSON
@@ -3555,4 +3555,4 @@ On your gateway host (Mac/Linux), run:
Connection Status
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..f075ea51d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -1594,18 +1594,18 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
La passerelle est connectée ; l'interface de chat est encore en cours de démarrage.
- ⚙️ Configuration
+ Configuration
Actualiser
-
+
Éditeur
Sélectionnez une section de configuration
-
+
JSON brut
@@ -3507,4 +3507,4 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Connection Status
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..5daaaec40 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -1595,18 +1595,18 @@ Voer op uw gateway-host (Mac/Linux) uit:
De gateway is verbonden; het chatoppervlak wordt nog geladen.
- ⚙️ Configuratie
+ Configuratie
Vernieuwen
-
+
Bewerker
Selecteer een configuratiesectie
-
+
Ruwe JSON
@@ -3508,4 +3508,4 @@ Voer op uw gateway-host (Mac/Linux) uit:
Connection Status
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..c239f9e8d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -1594,18 +1594,18 @@
网关已连接;聊天界面仍在上线中。
- ⚙️ 配置
+ 配置
刷新
-
+
编辑器
选择配置部分
-
+
原始 JSON
@@ -3507,4 +3507,4 @@
Connection Status
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..c5fa2e3ca 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -1594,18 +1594,18 @@
閘道已連接;聊天介面仍在上線中。
- ⚙️ 設定
+ 設定
重新整理
-
+
編輯器
選取設定區段
-
+
原始 JSON
@@ -3507,4 +3507,4 @@
Connection Status
-
+
\ No newline at end of file
From 7e88b14eeab6e166bf6206a3bb6bf9fd4bd771d7 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 00:31:27 -0700
Subject: [PATCH 073/115] Clean up Cron page UI to follow Fluent guidelines
- Remove emoji from page title, preset buttons, empty state, status badges, and toggle labels; render via FluentIconCatalog instead. Add FluentIconCatalog.Cron (Calendar, U+E787).
- Move Cron item out of Advanced submenu to top-level Gateway section, directly above Advanced, and gate its visibility on gateway-connected state alongside the other gateway pages.
- Replace per-job Enable/Disable button with a right-aligned ToggleSwitch in the card header; remove redundant action button and dead VM members.
- Use theme tokens for status colors (SystemFillColorSuccess/Critical/Attention/Neutral, LayerFillColorDefaultBrush) instead of hard-coded ARGB literals; fixes green-on-green legibility and removes saturated pill backgrounds.
- Switch input labels to sentence-case BodyStrongTextBlockStyle and other typography tokens; remove literal FontSize/FontWeight.
- Fix new-job form overlapping page content: name the row-defined inner Grid (PageRootGrid) so JobFormPanel is reparented into the correct Grid with Grid.Row=2 honored.
- Add card stroke and use LayerFillColorDefaultBrush for the opaque create/edit form surface.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Helpers/FluentIconCatalog.cs | 1 +
src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml | 137 +++++++++---------
.../Pages/CronPage.xaml.cs | 115 +++++++--------
.../Windows/HubWindow.xaml | 6 +-
.../Windows/HubWindow.xaml.cs | 1 +
5 files changed, 126 insertions(+), 134 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index e1bcdf2c0..b73175bf5 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -92,6 +92,7 @@ public static class FluentIconCatalog
public const string Reset = "\uE72C"; // Refresh (alias) — Reconfigure / start over
public const string Clear = "\uE74D"; // Delete — clear/reset a buffer
public const string Develop = "\uE943"; // Code — engineering / explorations action
+ public const string Cron = "\uE787"; // Calendar — Cron / scheduled jobs (matches HubWindow search mapping)
///
/// Builds a for the given PUA glyph using the
diff --git a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
index 03ba856ce..c62ca2d8c 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml
@@ -2,11 +2,12 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:helpers="using:OpenClawTray.Helpers">
-
+
-
+
@@ -25,32 +26,34 @@
-
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+ BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
+ BorderThickness="1"
+ CornerRadius="8" Padding="12,8">
+ Fill="{ThemeResource TextFillColorTertiaryBrush}" VerticalAlignment="Center"/>
+ Style="{StaticResource BodyTextBlockStyle}"/>
@@ -73,10 +76,12 @@
-
+
@@ -85,14 +90,12 @@
-
-
+
+
-
-
+
@@ -103,24 +106,18 @@
-
+
-
+
-
-
-
-
-
+
+
+
+
+
@@ -130,15 +127,13 @@
-
+
+ FontFamily="Consolas"/>
-
-
+
@@ -161,15 +156,13 @@
-
-
+
-
-
+
+
@@ -180,8 +173,7 @@
-
+
@@ -189,15 +181,14 @@
InputScope="TimeHour"/>
+ Margin="0,4,0,0"/>
-
+
+ AcceptsReturn="True" TextWrapping="Wrap" MinHeight="72" MaxHeight="140"/>
@@ -207,25 +198,24 @@
-
-
+
-
-
+
+
-
@@ -236,17 +226,15 @@
-
-
+
+
-
-
+
+
@@ -256,14 +244,16 @@
-
-
-
+
+
@@ -279,6 +269,7 @@
Visibility="Visible">
@@ -287,7 +278,9 @@
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs
index be9dc6e30..69ebf4ce5 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs
@@ -170,18 +170,18 @@ private void OnRemoveClick(object sender, RoutedEventArgs e)
_ = CurrentApp.GatewayClient.RemoveCronJobAsync(jobId);
}
- private void OnToggleEnabledClick(object sender, RoutedEventArgs e)
+ private void OnEnabledToggleChanged(object sender, RoutedEventArgs e)
{
- var jobId = (sender as Button)?.Tag as string;
+ if (sender is not ToggleSwitch ts) return;
+ var jobId = ts.Tag as string;
if (string.IsNullOrEmpty(jobId)) return;
if (!_cronLoading.CanEdit) return;
if (CurrentApp.GatewayClient == null) { ShowDisconnected(); return; }
var vm = _jobs.Find(j => j.Id == jobId);
- if (vm != null)
- {
- _ = CurrentApp.GatewayClient.UpdateCronJobAsync(jobId, new { enabled = !vm.IsEnabled });
- }
+ if (vm == null) return;
+ if (ts.IsOn == vm.IsEnabled) return; // no-op (e.g., programmatic init)
+ _ = CurrentApp.GatewayClient.UpdateCronJobAsync(jobId, new { enabled = ts.IsOn });
}
// --- Job creation/edit form ---
@@ -736,15 +736,13 @@ private void ParseCronList(JsonElement payload)
if (status == "ok" || status == "success")
{
vm.LastResult = "success";
- vm.ResultBadgeBackground = new SolidColorBrush(Color.FromArgb(40, 76, 175, 80));
- vm.ResultBadgeForeground = new SolidColorBrush(Colors.LimeGreen);
+ vm.ResultBadgeForeground = (SolidColorBrush)Application.Current.Resources["SystemFillColorSuccessBrush"];
vm.ResultBadgeVisibility = Visibility.Visible;
}
else if (!string.IsNullOrEmpty(status) && status != "none")
{
vm.LastResult = status;
- vm.ResultBadgeBackground = new SolidColorBrush(Color.FromArgb(40, 224, 85, 69));
- vm.ResultBadgeForeground = new SolidColorBrush(Color.FromArgb(255, 224, 85, 69));
+ vm.ResultBadgeForeground = (SolidColorBrush)Application.Current.Resources["SystemFillColorCriticalBrush"];
vm.ResultBadgeVisibility = Visibility.Visible;
}
}
@@ -753,15 +751,13 @@ private void ParseCronList(JsonElement payload)
if (okEl.ValueKind == JsonValueKind.True)
{
vm.LastResult = "success";
- vm.ResultBadgeBackground = new SolidColorBrush(Color.FromArgb(40, 76, 175, 80));
- vm.ResultBadgeForeground = new SolidColorBrush(Colors.LimeGreen);
+ vm.ResultBadgeForeground = (SolidColorBrush)Application.Current.Resources["SystemFillColorSuccessBrush"];
vm.ResultBadgeVisibility = Visibility.Visible;
}
else if (okEl.ValueKind == JsonValueKind.False)
{
vm.LastResult = "fail";
- vm.ResultBadgeBackground = new SolidColorBrush(Color.FromArgb(40, 224, 85, 69));
- vm.ResultBadgeForeground = new SolidColorBrush(Color.FromArgb(255, 224, 85, 69));
+ vm.ResultBadgeForeground = (SolidColorBrush)Application.Current.Resources["SystemFillColorCriticalBrush"];
vm.ResultBadgeVisibility = Visibility.Visible;
}
}
@@ -844,7 +840,9 @@ private void ParseCronStatus(JsonElement payload)
DispatcherQueue?.TryEnqueue(() =>
{
SchedulerStatusText.Text = enabled ? "Enabled" : "Disabled";
- SchedulerStatusIndicator.Fill = new SolidColorBrush(enabled ? Colors.LimeGreen : Colors.Gray);
+ SchedulerStatusIndicator.Fill = enabled
+ ? (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"]
+ : (Brush)Application.Current.Resources["SystemFillColorNeutralBrush"];
StorePathText.Text = storePath;
NextWakeText.Text = $"· Next wake: {nextWake}";
});
@@ -1096,10 +1094,11 @@ private void RestoreFormFromInline()
private Grid? FindParentGrid()
{
- // The page's main Grid is inside the ScrollViewer
- if (this.Content is ScrollViewer sv && sv.Content is Grid g)
- return g;
- return null;
+ // The page's main Grid (with row definitions) is named PageRootGrid;
+ // it sits inside an outer wrapper Grid inside the ScrollViewer. Returning
+ // the wrapper instead would lose the row definitions and cause the form
+ // to overlap other rows of the inner grid.
+ return PageRootGrid;
}
// --- Card building ---
@@ -1121,6 +1120,8 @@ private Border BuildJobCard(CronJobViewModel vm)
Margin = new Thickness(0, 2, 0, 0),
CornerRadius = new CornerRadius(6),
Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"],
+ BorderBrush = (Brush)Application.Current.Resources["CardStrokeColorDefaultBrush"],
+ BorderThickness = new Thickness(1),
Opacity = vm.CardOpacity
};
@@ -1129,15 +1130,15 @@ private Border BuildJobCard(CronJobViewModel vm)
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
- // Row 0: Name + badges + chevron
- var headerGrid = new Grid();
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 0: name
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 1: schedule
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 2: enabled
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 3: result
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 4: running
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 5: spacer
- headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 6: chevron
+ // Row 0: Name + badges + spacer + toggle + chevron
+ var headerGrid = new Grid { VerticalAlignment = VerticalAlignment.Center };
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 0: name
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 1: schedule
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 2: result
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 3: running
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // 4: spacer
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 5: toggle
+ headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // 6: chevron
var nameText = new TextBlock { Text = vm.Name, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
Grid.SetColumn(nameText, 0);
@@ -1147,32 +1148,22 @@ private Border BuildJobCard(CronJobViewModel vm)
{
CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2), Margin = new Thickness(8, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
- Background = (Brush)Application.Current.Resources["CardBackgroundFillColorSecondaryBrush"]
+ Background = (Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]
};
scheduleBadge.Child = new TextBlock { Text = vm.Schedule, FontSize = 10, FontFamily = new FontFamily("Consolas"), Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] };
Grid.SetColumn(scheduleBadge, 1);
headerGrid.Children.Add(scheduleBadge);
- var enabledBadge = new Border
- {
- CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2), Margin = new Thickness(4, 0, 0, 0),
- VerticalAlignment = VerticalAlignment.Center,
- Background = vm.EnabledBadgeBackground
- };
- enabledBadge.Child = new TextBlock { Text = vm.EnabledText, FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = vm.EnabledBadgeForeground };
- Grid.SetColumn(enabledBadge, 2);
- headerGrid.Children.Add(enabledBadge);
-
if (vm.ResultBadgeVisibility == Visibility.Visible)
{
var resultBadge = new Border
{
- CornerRadius = new CornerRadius(4), Padding = new Thickness(5, 2, 5, 2), Margin = new Thickness(4, 0, 0, 0),
+ CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2), Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
- Background = vm.ResultBadgeBackground
+ Background = (Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]
};
resultBadge.Child = new TextBlock { Text = vm.LastResult, FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = vm.ResultBadgeForeground };
- Grid.SetColumn(resultBadge, 3);
+ Grid.SetColumn(resultBadge, 2);
headerGrid.Children.Add(resultBadge);
}
@@ -1183,13 +1174,32 @@ private Border BuildJobCard(CronJobViewModel vm)
{
CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2), Margin = new Thickness(4, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
- Background = new SolidColorBrush(Color.FromArgb(40, 33, 150, 243))
+ Background = (Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]
};
- runningBadge.Child = new TextBlock { Text = "⏳ Running", FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = new SolidColorBrush(Color.FromArgb(255, 100, 181, 246)) };
- Grid.SetColumn(runningBadge, 4);
+ runningBadge.Child = new TextBlock { Text = "Running", FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = (Brush)Application.Current.Resources["SystemFillColorAttentionBrush"] };
+ Grid.SetColumn(runningBadge, 3);
headerGrid.Children.Add(runningBadge);
}
+ // Inline enabled/disabled toggle (right-aligned, before chevron).
+ // Stop tapped from bubbling so the card doesn't toggle expand state.
+ var enabledToggle = new ToggleSwitch
+ {
+ Tag = vm.Id,
+ IsOn = vm.IsEnabled,
+ OnContent = string.Empty,
+ OffContent = string.Empty,
+ MinWidth = 0,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(8, 0, 8, 0),
+ IsEnabled = _cronLoading.CanEdit
+ };
+ ToolTipService.SetToolTip(enabledToggle, vm.IsEnabled ? "Disable job" : "Enable job");
+ enabledToggle.Toggled += OnEnabledToggleChanged;
+ enabledToggle.Tapped += (s, ev) => { ev.Handled = true; };
+ Grid.SetColumn(enabledToggle, 5);
+ headerGrid.Children.Add(enabledToggle);
+
var chevron = new FontIcon { Glyph = "\uE70D", FontSize = 10, VerticalAlignment = VerticalAlignment.Center, Foreground = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"] };
Grid.SetColumn(chevron, 6);
headerGrid.Children.Add(chevron);
@@ -1270,8 +1280,6 @@ private StackPanel BuildDetailPanel(CronJobViewModel vm)
}
buttonsPanel.Children.Add(runNowBtn);
- buttonsPanel.Children.Add(MakeActionButton(vm.ToggleEnabledGlyph, vm.ToggleEnabledText, vm.Id, OnToggleEnabledClick));
-
var editBtn = MakeActionButton("\uE70F", "Edit", vm.Id, OnEditJobClick);
if (jobDisabled) editBtn.Opacity = 0.4;
buttonsPanel.Children.Add(editBtn);
@@ -1592,7 +1600,7 @@ private Border BuildRunEntry(JsonElement entry)
var metaParts = new List();
if (!string.IsNullOrEmpty(model)) metaParts.Add(model);
if (totalTokens > 0) metaParts.Add($"{totalTokens:N0} tokens");
- if (delivered) metaParts.Add("delivered ✓");
+ if (delivered) metaParts.Add("delivered");
else if (!string.IsNullOrEmpty(deliveryStatus)) metaParts.Add(deliveryStatus);
if (metaParts.Count > 0)
@@ -1709,16 +1717,8 @@ private class CronJobViewModel
public bool IsEnabled { get; set; } = true;
public bool IsExpanded { get; set; } = false;
public double CardOpacity => IsEnabled ? 1.0 : 0.5;
- public string EnabledText => IsEnabled ? "enabled" : "disabled";
- public SolidColorBrush EnabledBadgeBackground => IsEnabled
- ? new SolidColorBrush(Color.FromArgb(40, 76, 175, 80))
- : new SolidColorBrush(Color.FromArgb(40, 230, 168, 23));
- public SolidColorBrush EnabledBadgeForeground => IsEnabled
- ? new SolidColorBrush(Colors.LimeGreen)
- : new SolidColorBrush(Color.FromArgb(255, 230, 168, 23));
public string LastRunTime { get; set; } = "—";
public string LastResult { get; set; } = "";
- public SolidColorBrush ResultBadgeBackground { get; set; } = new(Colors.Gray);
public SolidColorBrush ResultBadgeForeground { get; set; } = new(Colors.White);
public Visibility ResultBadgeVisibility { get; set; } = Visibility.Collapsed;
public string NextRunTime { get; set; } = "—";
@@ -1738,9 +1738,6 @@ private class CronJobViewModel
public Visibility WakeModeVisibility { get; set; } = Visibility.Collapsed;
public string DeliveryText { get; set; } = "";
public Visibility DeliveryVisibility { get; set; } = Visibility.Collapsed;
- public string ToggleEnabledLabel => IsEnabled ? "⏸ Disable" : "▶ Enable";
- public string ToggleEnabledGlyph => IsEnabled ? "\uE7E8" : "\uE768";
- public string ToggleEnabledText => IsEnabled ? "Disable" : "Enable";
public string Description { get; set; } = "";
public Visibility DescriptionVisibility { get; set; } = Visibility.Collapsed;
public string DetailLine { get; set; } = "";
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
index 3d37ba3f3..9aa764adb 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
@@ -149,6 +149,9 @@
+
+
+
@@ -173,9 +176,6 @@
-
-
-
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 9c37daa0f..3a307f8a0 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -428,6 +428,7 @@ private void UpdateGatewayNavVisibility(bool connected)
NavSkills.Visibility = vis;
NavChannels.Visibility = vis;
NavInstances.Visibility = vis;
+ NavCron.Visibility = vis;
NavAdvanced.Visibility = vis;
NavGatewaySeparator.Visibility = vis;
From 89aee64058e5b132f0bc06aac14db5eeba655045 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 00:34:27 -0700
Subject: [PATCH 074/115] Tidy Agent Events page UI (Fluent / WinUI design)
- Match PermissionsPage layout (Stretch + MaxWidth=900, 24px padding)
- Promote Clear to toolbar button with Fluent Delete glyph; add subtitle caption and intro copy
- Replace ToggleButton filter row with SelectorBar (matches SessionsPage); resw keys renamed .Content -> .Text
- Replace robot emoji empty state with Segoe Fluent History glyph via new FluentIconCatalog.AgentEvents
- Use BodyTextBlockStyle/CaptionTextBlockStyle instead of raw FontSize (winuxe rule #4); 12px minimum honored
- Card uses CardStrokeColorDefaultBrush + OverlayCornerRadius 8
- Hide expand chevron and skip click-toggle for rows with no extra content (new AgentEventInfo.CanExpand)
- Add AgentEventsPage_IntroText.Text seeded English across all locales (deferred-translation precedent)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Shared/Models.cs | 19 +++
.../Helpers/FluentIconCatalog.cs | 5 +
.../Pages/AgentEventsPage.xaml | 138 +++++++++++-------
.../Pages/AgentEventsPage.xaml.cs | 23 ++-
.../Strings/en-us/Resources.resw | 24 +--
.../Strings/fr-fr/Resources.resw | 24 +--
.../Strings/nl-nl/Resources.resw | 24 +--
.../Strings/zh-cn/Resources.resw | 24 +--
.../Strings/zh-tw/Resources.resw | 24 +--
.../LocalizationValidationTests.cs | 4 +
10 files changed, 202 insertions(+), 107 deletions(-)
diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs
index 7223077af..180a407bc 100644
--- a/src/OpenClaw.Shared/Models.cs
+++ b/src/OpenClaw.Shared/Models.cs
@@ -1776,6 +1776,25 @@ public bool ShowDataJson
}
}
+ ///
+ /// True when expanding the row reveals additional content beyond
+ /// . Drives whether the chevron and
+ /// click-to-expand affordance render on the Agent Events page.
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public bool CanExpand
+ {
+ get
+ {
+ if (IsAssistantStream)
+ {
+ var full = FullAssistantText;
+ return !string.IsNullOrEmpty(full) && full != SummaryLine;
+ }
+ return ShowDataJson;
+ }
+ }
+
// UI-only state for expand/collapse (not serialized)
[System.Text.Json.Serialization.JsonIgnore]
public bool IsExpanded { get; set; }
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index e1bcdf2c0..dd3707389 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -93,6 +93,11 @@ public static class FluentIconCatalog
public const string Clear = "\uE74D"; // Delete — clear/reset a buffer
public const string Develop = "\uE943"; // Code — engineering / explorations action
+ // ── Agent Events page ──────────────────────────────────────────
+ // Activity-log metaphor; reused for the empty state on the Agent
+ // Events page (src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml).
+ public const string AgentEvents = "\uE81C"; // History — agent events feed / activity log
+
///
/// Builds a for the given PUA glyph using the
/// system-resolved SymbolThemeFontFamily so the icon honors
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
index 0dcdb1680..bc1cf0366 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
@@ -2,50 +2,78 @@
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:helpers="using:OpenClawTray.Helpers">
+
-
-
-
-
+
+
+
+
+
-
-
+
+
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
+
@@ -77,34 +107,42 @@
-
-
-
-
@@ -114,10 +152,13 @@
-
-
-
+
+
+
@@ -127,12 +168,11 @@
HorizontalAlignment="Center" TextWrapping="Wrap" MaxWidth="320"/>
-
-
-
-
-
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
index 2950658a8..1d22d64d6 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
@@ -3,7 +3,6 @@
using System.Linq;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Controls.Primitives;
using OpenClaw.Shared;
using OpenClawTray.Services;
using OpenClawTray.Windows;
@@ -121,18 +120,10 @@ public void AddEvent(AgentEventInfo evt)
}
}
- private void OnFilterClick(object sender, RoutedEventArgs e)
+ private void OnFilterSelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
{
- if (sender is not ToggleButton clicked) return;
- var tag = clicked.Tag?.ToString() ?? "all";
-
- foreach (var child in ((StackPanel)clicked.Parent).Children)
- {
- if (child is ToggleButton tb)
- tb.IsChecked = tb == clicked;
- }
-
- _activeFilter = tag;
+ var tag = sender.SelectedItem?.Tag as string;
+ _activeFilter = string.IsNullOrEmpty(tag) ? "all" : tag;
ApplyFilter();
}
@@ -190,9 +181,13 @@ private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerC
Microsoft.UI.ColorHelper.FromArgb(40, 100, 100, 100));
}
- // Update chevron glyph based on model state
+ // Update chevron glyph based on model state, and hide it
+ // entirely when there is nothing to expand.
if (headerGrid.Children.Count > 2 && headerGrid.Children[2] is FontIcon chevron)
+ {
+ chevron.Visibility = evt.CanExpand ? Visibility.Visible : Visibility.Collapsed;
chevron.Glyph = evt.IsExpanded ? "\uE70E" : "\uE70D";
+ }
}
// Row 1: summary
@@ -230,6 +225,8 @@ private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerC
private void EventsList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not AgentEventInfo evt) return;
+ // Ignore clicks for events with nothing to reveal.
+ if (!evt.CanExpand) return;
evt.IsExpanded = !evt.IsExpanded;
// Update the visual container
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..9f969c63e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1551,31 +1551,37 @@ On your gateway host (Mac/Linux), run:
Agent
+
+ All Agents
+
+
+ Real-time stream of agent activity — tool calls, responses, errors, and lifecycle events.
+
All Agents
-
+
Tool
-
+
Assistant
-
+
Error
-
+
Lifecycle
-
+
Plan
-
+
Approval
-
+
Thinking
-
+
Patch
@@ -3327,7 +3333,7 @@ On your gateway host (Mac/Linux), run:
Keep my setup
-
+
All
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..7d3ce7302 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -1503,31 +1503,37 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Agent logiciel
+
+ Tous les agents
+
+
+ Real-time stream of agent activity — tool calls, responses, errors, and lifecycle events.
+
Tous les agents
-
+
Outil
-
+
Assistant virtuel
-
+
Erreur
-
+
Cycle de vie
-
+
Planification
-
+
Approbation
-
+
Réflexion
-
+
Correctif
@@ -3279,7 +3285,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Keep my setup
-
+
Tout
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..1d3b2a347 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -1504,31 +1504,37 @@ Voer op uw gateway-host (Mac/Linux) uit:
Softwareagent
+
+ Alle agents
+
+
+ Real-time stream of agent activity — tool calls, responses, errors, and lifecycle events.
+
Alle agents
-
+
Hulpmiddel
-
+
Assistent
-
+
Fout
-
+
Levenscyclus
-
+
Planning
-
+
Goedkeuring
-
+
Denken
-
+
Correctie
@@ -3280,7 +3286,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Keep my setup
-
+
Alles
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..518fba0e6 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -1503,31 +1503,37 @@
代理
+
+ 所有代理
+
+
+ Real-time stream of agent activity — tool calls, responses, errors, and lifecycle events.
+
所有代理
-
+
工具
-
+
助手
-
+
错误
-
+
生命周期
-
+
计划
-
+
批准
-
+
思考中
-
+
补丁
@@ -3279,7 +3285,7 @@
Keep my setup
-
+
全部
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..1ced9cda1 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -1503,31 +1503,37 @@
代理程式
+
+ 所有代理程式
+
+
+ Real-time stream of agent activity — tool calls, responses, errors, and lifecycle events.
+
所有代理程式
-
+
工具
-
+
助理
-
+
錯誤
-
+
生命週期
-
+
計畫
-
+
核准
-
+
思考中
-
+
修補程式
@@ -3279,7 +3285,7 @@
Keep my setup
-
+
全部
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index b4867f843..afb9a51d9 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -146,6 +146,10 @@ public class LocalizationValidationTests
"ConnectionPage_NodePairing_Subtitle.Text",
"AboutPage_MoreDiagnosticsLink.Content",
"ConnectionStatusWindow.Title",
+ // AgentEventsPage subtitle — seeded English-only across all locales
+ // until translations land. Same precedent as the PermissionsPage
+ // runtime keys above.
+ "AgentEventsPage_IntroText.Text",
};
private static readonly string[] RequiredRuntimeOnboardingKeys =
From c7947a13c6de417c57670a47930b4015058ab60d Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 00:47:59 -0700
Subject: [PATCH 075/115] Trim redundant comments on ConfigPage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml | 8 ++------
src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs | 8 ++------
2 files changed, 4 insertions(+), 12 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
index 67875d12a..8e500fe58 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml
@@ -6,10 +6,8 @@
xmlns:controls="using:Microsoft.UI.Xaml.Controls"
xmlns:local="using:OpenClawTray.Controls">
-
+
@@ -55,8 +53,6 @@
-
- /// Reconciles which of the three Row 2 surfaces is visible:
- /// the Editor card, the Raw JSON card, or the "no schema" fallback.
- /// Called whenever schema state or the SelectorBar selection changes.
+ /// Reconciles which Row 2 surface is visible: Editor, Raw JSON, or
+ /// the "no schema" fallback.
///
private void ApplyPaneVisibility()
{
@@ -371,9 +370,6 @@ private void OnRefresh(object sender, RoutedEventArgs e)
private void UpdateRawJson()
{
- // RawJsonText lives inside the Raw JSON pane; it's always realized
- // (we just toggle Visibility), but keep the null guard for safety
- // in case this is called before InitializeComponent finishes.
if (RawJsonText is null) return;
if (_lastConfig.HasValue)
From fe861d72d04b036ad6c64ac86667b737bee1b623 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 00:49:51 -0700
Subject: [PATCH 076/115] Trim noisy comments on Agent Events page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Shared/Models.cs | 6 +----
.../Helpers/FluentIconCatalog.cs | 6 +----
.../Pages/AgentEventsPage.xaml | 24 +++++--------------
.../Pages/AgentEventsPage.xaml.cs | 22 ++---------------
.../LocalizationValidationTests.cs | 4 +---
5 files changed, 11 insertions(+), 51 deletions(-)
diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs
index 180a407bc..d704b218c 100644
--- a/src/OpenClaw.Shared/Models.cs
+++ b/src/OpenClaw.Shared/Models.cs
@@ -1776,11 +1776,7 @@ public bool ShowDataJson
}
}
- ///
- /// True when expanding the row reveals additional content beyond
- /// . Drives whether the chevron and
- /// click-to-expand affordance render on the Agent Events page.
- ///
+ /// True when expanding the row reveals content beyond .
[System.Text.Json.Serialization.JsonIgnore]
public bool CanExpand
{
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index dd3707389..e22ebb03d 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -92,11 +92,7 @@ public static class FluentIconCatalog
public const string Reset = "\uE72C"; // Refresh (alias) — Reconfigure / start over
public const string Clear = "\uE74D"; // Delete — clear/reset a buffer
public const string Develop = "\uE943"; // Code — engineering / explorations action
-
- // ── Agent Events page ──────────────────────────────────────────
- // Activity-log metaphor; reused for the empty state on the Agent
- // Events page (src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml).
- public const string AgentEvents = "\uE81C"; // History — agent events feed / activity log
+ public const string AgentEvents = "\uE81C"; // History — agent events feed
///
/// Builds a for the given PUA glyph using the
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
index bc1cf0366..ce2080bd5 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
@@ -5,18 +5,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:helpers="using:OpenClawTray.Helpers">
-
-
-
-
-
-
+
+
+
+
+
-
-
@@ -55,7 +51,6 @@
-
-
-
@@ -121,20 +114,17 @@
Foreground="{ThemeResource TextFillColorTertiaryBrush}"/>
-
-
+
-
-
-
-
2 && headerGrid.Children[2] is FontIcon chevron)
{
chevron.Visibility = evt.CanExpand ? Visibility.Visible : Visibility.Collapsed;
@@ -190,13 +187,11 @@ private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerC
}
}
- // 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;
}
@@ -207,40 +202,28 @@ private void EventsList_ContainerContentChanging(ListViewBase sender, ContainerC
}
}
- // 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;
- }
+ detailGrid.Visibility = (evt.IsExpanded && evt.ShowDataJson)
+ ? Visibility.Visible : Visibility.Collapsed;
}
}
private void EventsList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not AgentEventInfo evt) return;
- // Ignore clicks for events with nothing to reveal.
if (!evt.CanExpand) 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
@@ -249,7 +232,6 @@ private void EventsList_ItemClick(object sender, ItemClickEventArgs e)
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;
diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
index afb9a51d9..e083c2ac1 100644
--- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs
@@ -146,9 +146,7 @@ public class LocalizationValidationTests
"ConnectionPage_NodePairing_Subtitle.Text",
"AboutPage_MoreDiagnosticsLink.Content",
"ConnectionStatusWindow.Title",
- // AgentEventsPage subtitle — seeded English-only across all locales
- // until translations land. Same precedent as the PermissionsPage
- // runtime keys above.
+ // AgentEventsPage subtitle — seeded English-only until translations land.
"AgentEventsPage_IntroText.Text",
};
From ba30dc983ec6430d8071c64f910e7fff616b3e06 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 22 May 2026 13:27:12 +0000
Subject: [PATCH 077/115] test(chat): add 19 tests for untested
ChatTimelineReducer event paths
Adds coverage for event types that had no direct tests:
- ChatReasoningEvent / ChatReasoningDeltaEvent: creates and appends
to reasoning entries; TurnEnd clears ActiveReasoningId
- ChatIntentEvent: sets CurrentIntent with no timeline entry
- ChatPermissionRequestEvent: sets PendingPermission; ClearPermission
and TurnEnd both clear it
- ChatStatusEvent / AddSystem: correct kind, tone, and text
- ChatRestoredEvent: adds info-tone status entry
- ChatModelChangedEvent: adds success-tone status entry with model name
- ChatRawEvent: adds raw entry when text is non-empty; no-op on null/empty
- ChatContextChangedEvent: is a no-op (returns state unchanged)
- Unknown ChatEvent subtype: is a no-op via switch default
19 new tests; total ChatTimelineReducerTests: 41 (was 22).
All new tests pass. Pre-existing LocalGatewaySetupTests failures are
unrelated infrastructure failures on Linux.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../ChatTimelineReducerTests.cs | 240 ++++++++++++++++++
1 file changed, 240 insertions(+)
diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
index e16b41b1e..954906963 100644
--- a/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ChatTimelineReducerTests.cs
@@ -362,4 +362,244 @@ public void Error_MarksActiveToolAsInterrupted()
Assert.Null(updated.ActiveToolCallId);
Assert.False(updated.TurnActive);
}
+
+ // ── Reasoning events ──
+
+ [Fact]
+ public void Reasoning_CreatesReasoningEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatReasoningEvent("thinking..."));
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Reasoning, state.Entries[0].Kind);
+ Assert.Equal("thinking...", state.Entries[0].Text);
+ Assert.NotNull(state.ActiveReasoningId);
+ }
+
+ [Fact]
+ public void ReasoningDelta_AppendsToExistingReasoningEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatReasoningEvent("first"));
+ var updated = ChatTimelineReducer.Apply(state, new ChatReasoningDeltaEvent(" second"));
+
+ Assert.Single(updated.Entries);
+ Assert.Equal("first second", updated.Entries[0].Text);
+ }
+
+ [Fact]
+ public void Reasoning_ReplacesTextOnFullEvent()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatReasoningDeltaEvent("partial"));
+ var updated = ChatTimelineReducer.Apply(state, new ChatReasoningEvent("final"));
+
+ Assert.Single(updated.Entries);
+ Assert.Equal("final", updated.Entries[0].Text);
+ }
+
+ [Fact]
+ public void TurnEnd_ClearsActiveReasoningId()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatReasoningEvent("thinking"));
+
+ Assert.NotNull(state.ActiveReasoningId);
+
+ var updated = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
+
+ Assert.Null(updated.ActiveReasoningId);
+ }
+
+ // ── Intent events ──
+
+ [Fact]
+ public void Intent_SetsCurrentIntent()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatIntentEvent("searching files"));
+
+ Assert.Equal("searching files", state.CurrentIntent);
+ Assert.Empty(state.Entries); // no timeline entry
+ }
+
+ [Fact]
+ public void Intent_OverwritesPreviousIntent()
+ {
+ var state = ChatTimelineReducer.Apply(ChatTimelineState.Initial(), new ChatIntentEvent("first"));
+ var updated = ChatTimelineReducer.Apply(state, new ChatIntentEvent("second"));
+
+ Assert.Equal("second", updated.CurrentIntent);
+ }
+
+ // ── Permission request events ──
+
+ [Fact]
+ public void PermissionRequest_SetsPendingPermission()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatPermissionRequestEvent("req-1", "shell.exec", "bash", "run script.sh"));
+
+ Assert.NotNull(state.PendingPermission);
+ Assert.Equal("req-1", state.PendingPermission!.RequestId);
+ Assert.Equal("shell.exec", state.PendingPermission.PermissionKind);
+ Assert.Equal("bash", state.PendingPermission.ToolName);
+ Assert.Equal("run script.sh", state.PendingPermission.Detail);
+ Assert.Empty(state.Entries); // no timeline entry
+ }
+
+ [Fact]
+ public void ClearPermission_RemovesPendingPermission()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatPermissionRequestEvent("req-1", "shell.exec", "bash", "run script.sh"));
+
+ Assert.NotNull(state.PendingPermission);
+
+ var updated = ChatTimelineReducer.ClearPermission(state);
+
+ Assert.Null(updated.PendingPermission);
+ }
+
+ [Fact]
+ public void TurnEnd_ClearsPendingPermission()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatPermissionRequestEvent("req-1", "shell.exec", "bash", "run script.sh"));
+
+ var updated = ChatTimelineReducer.Apply(state, new ChatTurnEndEvent());
+
+ Assert.Null(updated.PendingPermission);
+ }
+
+ // ── Status and system events ──
+
+ [Fact]
+ public void Status_AddsStatusEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatStatusEvent("Connected", ChatTone.Success));
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Status, state.Entries[0].Kind);
+ Assert.Equal("Connected", state.Entries[0].Text);
+ Assert.Equal(ChatTone.Success, state.Entries[0].Tone);
+ }
+
+ [Fact]
+ public void AddSystem_AddsStatusEntry()
+ {
+ var state = ChatTimelineReducer.AddSystem(ChatTimelineState.Initial(), "system note");
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Status, state.Entries[0].Kind);
+ Assert.Equal("system note", state.Entries[0].Text);
+ Assert.Equal(ChatTone.Info, state.Entries[0].Tone);
+ }
+
+ [Fact]
+ public void AddSystem_WithExplicitTone_UsesTone()
+ {
+ var state = ChatTimelineReducer.AddSystem(ChatTimelineState.Initial(), "warning!", ChatTone.Warning);
+
+ Assert.Equal(ChatTone.Warning, state.Entries[0].Tone);
+ }
+
+ [Fact]
+ public void Restored_AddsInfoStatusEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatRestoredEvent("History restored"));
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Status, state.Entries[0].Kind);
+ Assert.Equal("History restored", state.Entries[0].Text);
+ Assert.Equal(ChatTone.Info, state.Entries[0].Tone);
+ }
+
+ [Fact]
+ public void ModelChanged_AddsSuccessStatusEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatModelChangedEvent("gpt-4o"));
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Status, state.Entries[0].Kind);
+ Assert.Contains("gpt-4o", state.Entries[0].Text);
+ Assert.Equal(ChatTone.Success, state.Entries[0].Tone);
+ }
+
+ // ── Raw events ──
+
+ [Fact]
+ public void Raw_WithText_AddsRawEntry()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatRawEvent("unknown.event", "raw payload"));
+
+ Assert.Single(state.Entries);
+ Assert.Equal(ChatTimelineItemKind.Raw, state.Entries[0].Kind);
+ Assert.Equal("raw payload", state.Entries[0].Text);
+ }
+
+ [Fact]
+ public void Raw_WithNullText_IsNoOp()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatRawEvent("unknown.event", null));
+
+ Assert.Empty(state.Entries);
+ }
+
+ [Fact]
+ public void Raw_WithEmptyText_IsNoOp()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatRawEvent("unknown.event", ""));
+
+ Assert.Empty(state.Entries);
+ }
+
+ // ── ContextChanged ──
+
+ [Fact]
+ public void ContextChanged_IsNoOp()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new ChatContextChangedEvent("/home/user/project", "main"));
+
+ Assert.Empty(state.Entries);
+ Assert.False(state.TurnActive);
+ }
+
+ // ── Unknown event type ──
+
+ [Fact]
+ public void UnknownEvent_IsNoOp()
+ {
+ var state = ChatTimelineReducer.Apply(
+ ChatTimelineState.Initial(),
+ new UnknownTestEvent());
+
+ Assert.Empty(state.Entries);
+ Assert.False(state.TurnActive);
+ }
+
+ private sealed record UnknownTestEvent : ChatEvent;
}
From 3179090b0adb8465397727017a7e98c41fb23963 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 09:51:18 -0700
Subject: [PATCH 078/115] Redesign per-agent Workspace page with master-detail
layout
Replace the TabView-based file picker with a unified master-detail card
(ListView on the left, content on the right) sharing one bordered surface
and aligned header row. Add a Rendered/Raw SelectorBar toggle for .md
files with an in-process minimal markdown renderer (headings h1-h6,
paragraphs, lists, fenced code, inline bold/italic/code, link labels).
Aligns the page with the openclaw-design skill:
- New FluentIconCatalog.Workspace (OpenLocal U+E8DA) constant + test
- Refresh button now binds to FluentIconCatalog.Refresh with
IsTextScaleFactorEnabled=False
- All markdown / raw / code-block typography goes through Page.Resources
Styles BasedOn system text styles (no raw FontSize/FontWeight)
- ListViewItem margin moved off the 1px off-grid value
- MaxWidth=900 matches Permissions page tokens
- InfoBar / loading / missing-file messages moved to resw across all
five locales
- Title no longer carries an emoji
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Helpers/FluentIconCatalog.cs | 7 +
.../Pages/WorkspacePage.xaml | 237 +++++++--
.../Pages/WorkspacePage.xaml.cs | 469 +++++++++++++++---
.../Strings/en-us/Resources.resw | 26 +-
.../Strings/fr-fr/Resources.resw | 26 +-
.../Strings/nl-nl/Resources.resw | 26 +-
.../Strings/zh-cn/Resources.resw | 26 +-
.../Strings/zh-tw/Resources.resw | 26 +-
.../FluentIconCatalogTests.cs | 2 +
9 files changed, 716 insertions(+), 129 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
index e1bcdf2c0..ba90987ef 100644
--- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
+++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs
@@ -93,6 +93,13 @@ public static class FluentIconCatalog
public const string Clear = "\uE74D"; // Delete — clear/reset a buffer
public const string Develop = "\uE943"; // Code — engineering / explorations action
+ // ── Agents / Workspace surface ─────────────────────────────────
+ // Workspace concept (per-agent file viewer). Reuses the Folder
+ // metaphor because the workspace literally IS a folder; aliasing
+ // keeps call sites semantically distinct.
+ // See reference/concepts/states/workspace.md.
+ public const string Workspace = "\uE8DA"; // OpenLocal (alias of Folder)
+
///
/// Builds a for the given PUA glyph using the
/// system-resolved SymbolThemeFontFamily so the icon honors
diff --git a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml
index 81a48b6b5..28eb931ab 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml
@@ -2,62 +2,195 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextWrapping="NoWrap" TextTrimming="CharacterEllipsis"/>
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
index 3ac590049..265b254f2 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs
@@ -1,10 +1,14 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Documents;
+using Microsoft.UI.Xaml.Media;
using OpenClaw.Shared;
+using OpenClawTray.Helpers;
using OpenClawTray.Services;
using System;
using System.Collections.Generic;
using System.ComponentModel;
+using System.Text;
using System.Text.Json;
namespace OpenClawTray.Pages;
@@ -13,8 +17,14 @@ public sealed partial class WorkspacePage : Page
{
private static App CurrentApp => (App)Microsoft.UI.Xaml.Application.Current;
private AppState? _appState;
- private readonly Dictionary _fileTabs = new(StringComparer.OrdinalIgnoreCase);
- private bool _tabsPopulated;
+
+ // file name (case-insensitive) → its list item
+ private readonly Dictionary _fileItems = new(StringComparer.OrdinalIgnoreCase);
+
+ // file name → raw text content (null = missing on disk, absent = not loaded yet)
+ private readonly Dictionary _fileContent = new(StringComparer.OrdinalIgnoreCase);
+
+ private bool _renderMarkdown = true;
/// Set by HubWindow before to specify the active agent scope.
public string AgentId { get; set; } = "main";
@@ -29,12 +39,21 @@ public WorkspacePage()
};
}
+ private void OnPageSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ var available = e.NewSize.Width;
+ if (double.IsNaN(available) || available <= 0) return;
+ var max = ContentRoot.MaxWidth;
+ ContentRoot.Width = double.IsNaN(max) || double.IsInfinity(max)
+ ? available
+ : Math.Min(available, max);
+ }
+
public void Initialize()
{
_appState = CurrentApp.AppState;
_appState.PropertyChanged += OnAppStateChanged;
- // Check per-agent cache first, then fall back to single-slot cache
if (_appState.TryGetCachedAgentFilesList(AgentId, out var cachedData))
{
UpdateAgentFilesList(cachedData);
@@ -43,23 +62,23 @@ public void Initialize()
var hasMatchingCache = _appState?.AgentFilesList.HasValue == true &&
string.Equals(_appState?.AgentFilesListAgentId, AgentId, StringComparison.OrdinalIgnoreCase);
- var status = CurrentApp.AppState?.Status ?? OpenClaw.Shared.ConnectionStatus.Disconnected;
- if (CurrentApp.GatewayClient != null && status == OpenClaw.Shared.ConnectionStatus.Connected && !hasMatchingCache)
+ var status = CurrentApp.AppState?.Status ?? ConnectionStatus.Disconnected;
+ if (CurrentApp.GatewayClient != null && status == ConnectionStatus.Connected && !hasMatchingCache)
{
FallbackInfoBar.IsOpen = false;
LoadingRing.IsActive = true;
LoadingPanel.Visibility = Visibility.Visible;
- ClearTabs();
+ ClearFiles();
_ = CurrentApp.GatewayClient.RequestAgentFilesListAsync(AgentId);
}
else if (hasMatchingCache)
{
UpdateAgentFilesList(_appState!.AgentFilesList!.Value);
}
- else if (CurrentApp.GatewayClient == null || status != OpenClaw.Shared.ConnectionStatus.Connected)
+ else if (CurrentApp.GatewayClient == null || status != ConnectionStatus.Connected)
{
FallbackInfoBar.IsOpen = true;
- FallbackInfoBar.Message = "Connect to gateway to view workspace files.";
+ FallbackInfoBar.Message = LocalizationHelper.GetString("WorkspacePage_DisconnectedMessage");
}
}
@@ -80,7 +99,7 @@ public void UpdateAgentFilesList(JsonElement data)
{
LoadingRing.IsActive = false;
LoadingPanel.Visibility = Visibility.Collapsed;
- ClearTabs();
+ ClearFiles();
if (data.TryGetProperty("workspace", out var workspaceEl))
{
@@ -98,26 +117,20 @@ public void UpdateAgentFilesList(JsonElement data)
bool exists = !fileEl.TryGetProperty("exists", out var existsEl) || existsEl.ValueKind != JsonValueKind.False;
if (!string.IsNullOrEmpty(name) && exists)
- {
- AddFileTab(name, size);
- }
+ AddFileItem(name, size);
}
}
- if (FileTabs.TabItems.Count == 0)
+ if (_fileItems.Count == 0)
{
FallbackInfoBar.IsOpen = true;
- FallbackInfoBar.Message = "No workspace files found for this agent.";
- FileTabs.Visibility = Visibility.Collapsed;
+ FallbackInfoBar.Message = LocalizationHelper.GetString("WorkspacePage_NoFilesMessage");
+ BodyGrid.Visibility = Visibility.Collapsed;
}
else
{
- FileTabs.Visibility = Visibility.Visible;
- FileTabs.SelectedIndex = 0;
- _tabsPopulated = true;
- // Explicitly fetch first tab content (SelectionChanged may not fire for programmatic index set)
- if (FileTabs.SelectedItem is TabViewItem firstTab && firstTab.Tag is string firstName && CurrentApp.GatewayClient != null)
- _ = CurrentApp.GatewayClient.RequestAgentFileGetAsync(AgentId, firstName);
+ BodyGrid.Visibility = Visibility.Visible;
+ FileList.SelectedIndex = 0;
}
}
@@ -129,83 +142,395 @@ public void UpdateAgentFileContent(JsonElement data)
var content = fileEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
bool missing = fileEl.TryGetProperty("missing", out var missingEl) && missingEl.ValueKind == JsonValueKind.True;
- if (string.IsNullOrEmpty(name) || !_fileTabs.TryGetValue(name, out var tab)) return;
+ if (string.IsNullOrEmpty(name) || !_fileItems.ContainsKey(name)) return;
+
+ _fileContent[name] = missing ? null : content;
- var textBlock = new TextBlock
+ if (FileList.SelectedItem is ListViewItem selected &&
+ selected.Tag is string selectedName &&
+ string.Equals(selectedName, name, StringComparison.OrdinalIgnoreCase))
{
- Text = missing ? "(file not found on disk)" : content,
- TextWrapping = TextWrapping.Wrap,
- IsTextSelectionEnabled = true,
- FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
- FontSize = 12,
- Padding = new Thickness(16)
- };
+ RenderSelectedFile();
+ }
+ }
+
+ private void AddFileItem(string fileName, long size)
+ {
+ var stack = new StackPanel { Spacing = 2 };
+ stack.Children.Add(new TextBlock
+ {
+ Text = fileName,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ TextWrapping = TextWrapping.NoWrap
+ });
+ if (size > 0)
+ {
+ stack.Children.Add(new TextBlock
+ {
+ Text = FormatSize(size),
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
+ });
+ }
+
+ var item = new ListViewItem { Content = stack, Tag = fileName };
+ _fileItems[fileName] = item;
+ FileList.Items.Add(item);
+ }
- var scrollViewer = new ScrollViewer
+ private void ClearFiles()
+ {
+ FileList.Items.Clear();
+ _fileItems.Clear();
+ _fileContent.Clear();
+ FileBodyPresenter.Content = null;
+ SelectedFileText.Text = string.Empty;
+ BodyGrid.Visibility = Visibility.Collapsed;
+ ViewModeSelector.Visibility = Visibility.Collapsed;
+ }
+
+ private void FileList_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (FileList.SelectedItem is not ListViewItem selected ||
+ selected.Tag is not string fileName)
{
- Content = textBlock,
- VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
- HorizontalAlignment = HorizontalAlignment.Stretch
+ return;
+ }
+
+ bool isMarkdown = IsMarkdown(fileName);
+ ViewModeSelector.Visibility = isMarkdown ? Visibility.Visible : Visibility.Collapsed;
+ SelectedFileText.Text = fileName;
+
+ if (_fileContent.ContainsKey(fileName))
+ {
+ RenderSelectedFile();
+ }
+ else
+ {
+ ShowLoadingBody();
+ if (CurrentApp.GatewayClient != null)
+ _ = CurrentApp.GatewayClient.RequestAgentFileGetAsync(AgentId, fileName);
+ }
+ }
+
+ private void ViewModeSelector_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
+ {
+ _renderMarkdown = ViewModeSelector.SelectedItem == ViewModeRenderedItem;
+ RenderSelectedFile();
+ }
+
+ private void ShowLoadingBody()
+ {
+ var loading = new StackPanel
+ {
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Spacing = 8
};
+ loading.Children.Add(new ProgressRing { IsActive = true, Width = 24, Height = 24 });
+ loading.Children.Add(new TextBlock
+ {
+ Text = LocalizationHelper.GetString("WorkspacePage_LoadingContent"),
+ Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
+ });
+ FileBodyPresenter.Content = loading;
+ }
- tab.Content = scrollViewer;
+ private void RenderSelectedFile()
+ {
+ if (FileList.SelectedItem is not ListViewItem selected ||
+ selected.Tag is not string fileName)
+ {
+ FileBodyPresenter.Content = null;
+ return;
+ }
- // WinUI TabView doesn't visually refresh when Content changes on the selected tab.
- // Force re-render by cycling the selection.
- if (FileTabs.SelectedItem == tab)
+ if (!_fileContent.TryGetValue(fileName, out var content))
{
- FileTabs.SelectedItem = null;
- FileTabs.SelectedItem = tab;
+ ShowLoadingBody();
+ return;
}
+
+ if (content == null)
+ {
+ FileBodyPresenter.Content = new TextBlock
+ {
+ Text = LocalizationHelper.GetString("WorkspacePage_MissingFile"),
+ Style = (Style)Application.Current.Resources["BodyTextBlockStyle"],
+ Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
+ };
+ return;
+ }
+
+ if (IsMarkdown(fileName) && _renderMarkdown)
+ {
+ FileBodyPresenter.Content = BuildMarkdownView(content);
+ }
+ else
+ {
+ FileBodyPresenter.Content = BuildRawView(content);
+ }
+ }
+
+ private UIElement BuildRawView(string content)
+ {
+ return new TextBlock
+ {
+ Text = content,
+ Style = (Style)Resources["WorkspaceCodeTextStyle"],
+ };
}
- private void AddFileTab(string fileName, long size)
+ private static bool IsMarkdown(string fileName)
+ {
+ return fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ||
+ fileName.EndsWith(".markdown", StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Minimal Markdown renderer: ATX headings, paragraphs, lists, fenced
+ // code, inline `code`, **bold**, *italic*. Links render as label only.
+ // Block styles come from Page.Resources so no raw FontSize is used.
+
+ private UIElement BuildMarkdownView(string markdown)
{
- var header = fileName;
- if (size > 0) header += $" ({FormatSize(size)})";
+ var root = new StackPanel { Spacing = 0 };
- var tab = new TabViewItem
+ var h1 = (Style)Resources["WorkspaceMarkdownH1Style"];
+ var h2 = (Style)Resources["WorkspaceMarkdownH2Style"];
+ var h3 = (Style)Resources["WorkspaceMarkdownH3Style"];
+ var para = (Style)Resources["WorkspaceMarkdownParagraphStyle"];
+ var listItem = (Style)Resources["WorkspaceMarkdownListItemStyle"];
+ var codeBlock = (Style)Resources["WorkspaceCodeBlockStyle"];
+
+ var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
+ int i = 0;
+ while (i < lines.Length)
{
- Header = header,
- IsClosable = false,
- Tag = fileName,
- Content = new StackPanel
+ var line = lines[i];
+
+ if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
{
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center,
- Children =
+ var code = new StringBuilder();
+ i++;
+ while (i < lines.Length && !lines[i].TrimStart().StartsWith("```", StringComparison.Ordinal))
{
- new ProgressRing { IsActive = true, Width = 24, Height = 24 },
- new TextBlock
- {
- Text = "Loading content…",
- Margin = new Thickness(0, 8, 0, 0),
- Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
- }
+ if (code.Length > 0) code.Append('\n');
+ code.Append(lines[i]);
+ i++;
}
+ if (i < lines.Length) i++; // skip closing ```
+ root.Children.Add(new TextBlock
+ {
+ Text = code.ToString(),
+ Style = codeBlock
+ });
+ continue;
}
- };
- _fileTabs[fileName] = tab;
- FileTabs.TabItems.Add(tab);
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ i++;
+ continue;
+ }
+
+ if (TryParseHeading(line, out var headingLevel, out var headingText))
+ {
+ var headingStyle = headingLevel switch
+ {
+ 1 => h1,
+ 2 => h2,
+ _ => h3, // h3..h6 all share BodyStrong styling
+ };
+ root.Children.Add(BuildInlineTextBlock(headingText, headingStyle));
+ i++;
+ continue;
+ }
+
+ if (IsListItem(line, out _, out _))
+ {
+ while (i < lines.Length && IsListItem(lines[i], out var marker, out var body))
+ {
+ root.Children.Add(BuildInlineTextBlock(marker + body, listItem));
+ i++;
+ }
+ continue;
+ }
+
+ // Paragraph: absorb continuation lines until a block-ending marker
+ var sb = new StringBuilder(line);
+ i++;
+ while (i < lines.Length)
+ {
+ var next = lines[i];
+ if (string.IsNullOrWhiteSpace(next)) break;
+ if (TryParseHeading(next, out _, out _)) break;
+ if (next.TrimStart().StartsWith("```", StringComparison.Ordinal)) break;
+ if (IsListItem(next, out _, out _)) break;
+ sb.Append(' ').Append(next.Trim());
+ i++;
+ }
+ root.Children.Add(BuildInlineTextBlock(sb.ToString(), para));
+ }
+
+ return root;
}
- private void ClearTabs()
+ private static TextBlock BuildInlineTextBlock(string text, Style style)
{
- FileTabs.TabItems.Clear();
- _fileTabs.Clear();
- _tabsPopulated = false;
- FileTabs.Visibility = Visibility.Collapsed;
+ var tb = new TextBlock { Style = style };
+ AppendInlineMarkdown(tb.Inlines, text);
+ return tb;
}
- private void FileTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ // ATX heading: 1–6 leading `#`, then at least one space, then the text.
+ // Optional trailing `#`s (closing sequence) are stripped.
+ private static bool TryParseHeading(string line, out int level, out string text)
{
- // Lazy load on tab select if content is still placeholder
- if (FileTabs.SelectedItem is TabViewItem tab && tab.Tag is string fileName &&
- tab.Content is StackPanel && CurrentApp.GatewayClient != null)
+ level = 0;
+ text = string.Empty;
+ int i = 0;
+ while (i < line.Length && line[i] == '#' && i < 6) i++;
+ if (i == 0 || i >= line.Length || line[i] != ' ') return false;
+ level = i;
+ var body = line[(i + 1)..].TrimEnd();
+ // Strip optional closing # # # sequence
+ int end = body.Length;
+ while (end > 0 && body[end - 1] == '#') end--;
+ if (end < body.Length && (end == 0 || body[end - 1] == ' '))
+ body = body[..end].TrimEnd();
+ text = body;
+ return true;
+ }
+
+ private static bool IsListItem(string line, out string marker, out string body)
+ {
+ marker = "";
+ body = "";
+ var trimmed = line.TrimStart();
+ if (trimmed.StartsWith("- ", StringComparison.Ordinal) ||
+ trimmed.StartsWith("* ", StringComparison.Ordinal))
{
- _ = CurrentApp.GatewayClient.RequestAgentFileGetAsync(AgentId, fileName);
+ marker = "• ";
+ body = trimmed[2..];
+ return true;
+ }
+ // numbered: digits + "." + space
+ int dot = trimmed.IndexOf('.');
+ if (dot > 0 && dot < trimmed.Length - 1 && trimmed[dot + 1] == ' ')
+ {
+ bool allDigits = true;
+ for (int k = 0; k < dot; k++)
+ if (!char.IsDigit(trimmed[k])) { allDigits = false; break; }
+ if (allDigits)
+ {
+ marker = trimmed[..(dot + 1)] + " ";
+ body = trimmed[(dot + 2)..];
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void AppendInlineMarkdown(InlineCollection inlines, string text)
+ {
+ text = StripLinks(text);
+
+ int i = 0;
+ var buf = new StringBuilder();
+ void FlushPlain()
+ {
+ if (buf.Length == 0) return;
+ inlines.Add(new Run { Text = buf.ToString() });
+ buf.Clear();
+ }
+
+ while (i < text.Length)
+ {
+ if (text[i] == '`')
+ {
+ int end = text.IndexOf('`', i + 1);
+ if (end > i)
+ {
+ FlushPlain();
+ inlines.Add(new Run
+ {
+ Text = text.Substring(i + 1, end - i - 1),
+ FontFamily = new FontFamily("Consolas")
+ });
+ i = end + 1;
+ continue;
+ }
+ }
+ if (i + 1 < text.Length && text[i] == '*' && text[i + 1] == '*')
+ {
+ int end = text.IndexOf("**", i + 2, StringComparison.Ordinal);
+ if (end > i + 1)
+ {
+ FlushPlain();
+ var bold = new Bold();
+ bold.Inlines.Add(new Run { Text = text.Substring(i + 2, end - i - 2) });
+ inlines.Add(bold);
+ i = end + 2;
+ continue;
+ }
+ }
+ // Italic: single asterisk, not part of a bold ** pair
+ if (text[i] == '*' &&
+ (i == 0 || text[i - 1] != '*') &&
+ (i + 1 >= text.Length || text[i + 1] != '*'))
+ {
+ int end = -1;
+ for (int k = i + 1; k < text.Length; k++)
+ {
+ if (text[k] == '*' && (k + 1 >= text.Length || text[k + 1] != '*'))
+ {
+ end = k;
+ break;
+ }
+ }
+ if (end > i)
+ {
+ FlushPlain();
+ var italic = new Italic();
+ italic.Inlines.Add(new Run { Text = text.Substring(i + 1, end - i - 1) });
+ inlines.Add(italic);
+ i = end + 1;
+ continue;
+ }
+ }
+
+ buf.Append(text[i]);
+ i++;
+ }
+ FlushPlain();
+ }
+
+ private static string StripLinks(string text)
+ {
+ // [label](url) → label; non-nested only.
+ var sb = new StringBuilder(text.Length);
+ int i = 0;
+ while (i < text.Length)
+ {
+ if (text[i] == '[')
+ {
+ int closeBracket = text.IndexOf(']', i + 1);
+ if (closeBracket > i && closeBracket + 1 < text.Length && text[closeBracket + 1] == '(')
+ {
+ int closeParen = text.IndexOf(')', closeBracket + 2);
+ if (closeParen > closeBracket)
+ {
+ sb.Append(text, i + 1, closeBracket - i - 1);
+ i = closeParen + 1;
+ continue;
+ }
+ }
+ }
+ sb.Append(text[i]);
+ i++;
}
+ return sb.ToString();
}
private void RefreshButton_Click(object sender, RoutedEventArgs e)
@@ -215,7 +540,7 @@ private void RefreshButton_Click(object sender, RoutedEventArgs e)
LoadingRing.IsActive = true;
LoadingPanel.Visibility = Visibility.Visible;
FallbackInfoBar.IsOpen = false;
- ClearTabs();
+ ClearFiles();
_ = CurrentApp.GatewayClient.RequestAgentFilesListAsync(AgentId);
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..426a88583 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2170,7 +2170,31 @@ On your gateway host (Mac/Linux), run:
Daily Cost
- 📂 Workspace
+ Workspace
+
+
+ Files
+
+
+ Loading workspace files…
+
+
+ Rendered
+
+
+ Raw
+
+
+ Connect to gateway to view workspace files.
+
+
+ No workspace files found for this agent.
+
+
+ Loading content…
+
+
+ (file not found on disk)
Refresh
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..34ae2dba5 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2122,7 +2122,31 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Coût quotidien
- 📂 Espace de travail
+ Espace de travail
+
+
+ Fichiers
+
+
+ Chargement des fichiers de l'espace de travail…
+
+
+ Rendu
+
+
+ Brut
+
+
+ Connectez-vous à la passerelle pour afficher les fichiers de l'espace de travail.
+
+
+ Aucun fichier d'espace de travail trouvé pour cet agent.
+
+
+ Chargement du contenu…
+
+
+ (fichier introuvable sur le disque)
Actualiser
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..438b61cde 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2123,7 +2123,31 @@ Voer op uw gateway-host (Mac/Linux) uit:
Dagelijkse kosten
- 📂 Werkruimte
+ Werkruimte
+
+
+ Bestanden
+
+
+ Werkruimtebestanden laden…
+
+
+ Weergegeven
+
+
+ Onbewerkt
+
+
+ Maak verbinding met de gateway om werkruimtebestanden te bekijken.
+
+
+ Geen werkruimtebestanden gevonden voor deze agent.
+
+
+ Inhoud laden…
+
+
+ (bestand niet gevonden op schijf)
Vernieuwen
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..bd5f51554 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2122,7 +2122,31 @@
每日成本
- 📂 工作区
+ 工作区
+
+
+ 文件
+
+
+ 正在加载工作区文件…
+
+
+ 渲染
+
+
+ 原始
+
+
+ 连接到网关以查看工作区文件。
+
+
+ 此代理未找到工作区文件。
+
+
+ 正在加载内容…
+
+
+ (磁盘上找不到文件)
刷新
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..5e6d7f265 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2122,7 +2122,31 @@
每日成本
- 📂 工作區
+ 工作區
+
+
+ 檔案
+
+
+ 正在載入工作區檔案…
+
+
+ 渲染
+
+
+ 原始
+
+
+ 連線至閘道以檢視工作區檔案。
+
+
+ 此代理沒有找到工作區檔案。
+
+
+ 正在載入內容…
+
+
+ (磁碟上找不到檔案)
重新整理
diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
index 06d15b9a6..3e3af0685 100644
--- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
+++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs
@@ -27,6 +27,8 @@ public sealed class FluentIconCatalogTests
"Brand",
// Diagnostics surface (see src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml).
"Bug", "Briefcase", "Folder", "Copy", "Document", "Refresh", "Reset", "Clear", "Develop",
+ // Workspace surface (see src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml).
+ "Workspace",
};
private static string ReadCatalogSource()
From a55ae075ad406a1cdcd51b09e8b3c47d8131f00b Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 10:38:35 -0700
Subject: [PATCH 079/115] Organize Voice & Audio page per Fluent design
guidelines
- Remove emoji from page title and Companion Voice section header
- Strip literal triangle from Preview button labels (5 locales)
- Tighten outer StackPanel spacing 16->8 to match Permissions page
- Replace raw FontSize/Opacity on inline test status with
CaptionTextBlockStyle + TextFillColorSecondaryBrush (winuxe rules)
- Drop raw FontSize on InlineTestBtnLabel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/VoiceSettingsPage.xaml | 16 +++++++++-------
.../Strings/en-us/Resources.resw | 12 ++++++------
.../Strings/fr-fr/Resources.resw | 12 ++++++------
.../Strings/nl-nl/Resources.resw | 12 ++++++------
.../Strings/zh-cn/Resources.resw | 12 ++++++------
.../Strings/zh-tw/Resources.resw | 12 ++++++------
6 files changed, 39 insertions(+), 37 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
index c44ea1627..b44502d07 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
@@ -6,9 +6,9 @@
-
+
-
+
@@ -95,7 +97,7 @@
Style="{StaticResource AccentButtonStyle}" Padding="12,8">
-
+
-
+
+ Content="Preview" Width="100" Visibility="Collapsed"/>
@@ -200,7 +202,7 @@
+ Content="Preview Voice" Width="140"/>
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 7efd1b534..251df2c8f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2452,7 +2452,7 @@ On your gateway host (Mac/Linux), run:
Windows built-in speech
- 🎙️ Voice & Audio
+ Voice & Audio
Configure speech-to-text and voice interaction settings. All speech processing runs locally on your device.
@@ -2530,7 +2530,7 @@ On your gateway host (Mac/Linux), run:
Audio feedback sounds
- 🔊 Companion Voice
+ Companion Voice
Choose the voice used when reading responses aloud.
@@ -2557,7 +2557,7 @@ On your gateway host (Mac/Linux), run:
Delete
- ▶ Preview
+ Preview
Voices download from the sherpa-onnx project's GitHub releases (~25 MB low quality, up to ~150 MB high quality). They run fully on this PC; no audio leaves your device.
@@ -2566,7 +2566,7 @@ On your gateway host (Mac/Linux), run:
Voice
- ▶ Preview Voice
+ Preview Voice
API Key
@@ -2650,7 +2650,7 @@ On your gateway host (Mac/Linux), run:
Error loading voices (see Debug log).
- ▶ Playing...
+ Playing...
Companion Voice
@@ -2755,7 +2755,7 @@ On your gateway host (Mac/Linux), run:
Download Voice
- ▶ Preview Voice
+ Preview Voice
Debug Overrides
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9939883a9..fdf4d6a9a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2404,7 +2404,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Voix intégrée à Windows
- 🎙️ Voix et audio
+ Voix et audio
Configurez la reconnaissance vocale et les paramètres d'interaction vocale. Tout le traitement vocal s'exécute localement sur votre appareil.
@@ -2482,7 +2482,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Sons de retour audio
- 🔊 Voix de Companion
+ Voix de Companion
Choisissez la voix utilisée pour lire les réponses à voix haute.
@@ -2509,7 +2509,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Supprimer
- ▶ Aperçu
+ Aperçu
Les voix sont téléchargées depuis les versions GitHub du projet sherpa-onnx (~25 Mo basse qualité, jusqu'à ~150 Mo haute qualité). Elles s'exécutent entièrement sur ce PC ; aucun son ne quitte votre appareil.
@@ -2518,7 +2518,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Voix
- ▶ Aperçu de la voix
+ Aperçu de la voix
Clé API
@@ -2602,7 +2602,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Erreur de chargement des voix (voir le journal Debug).
- ▶ Lecture...
+ Lecture...
Voix de Companion
@@ -2707,7 +2707,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Télécharger la voix
- ▶ Aperçu de la voix
+ Aperçu de la voix
Substitutions de débogage
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 3b27f4026..b8af4040d 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2405,7 +2405,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Ingebouwde Windows-spraak
- 🎙️ Spraak en audio
+ Spraak en audio
Configureer spraak-naar-tekst en spraakinteractie-instellingen. Alle spraakverwerking draait lokaal op uw apparaat.
@@ -2483,7 +2483,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Audio-feedbackgeluiden
- 🔊 Companion-stem
+ Companion-stem
Kies de stem die wordt gebruikt bij het hardop voorlezen van antwoorden.
@@ -2510,7 +2510,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Verwijderen
- ▶ Voorbeeld
+ Voorbeeld
Stemmen worden gedownload van de GitHub-releases van het sherpa-onnx-project (~25 MB lage kwaliteit, tot ~150 MB hoge kwaliteit). Ze draaien volledig op deze pc; er verlaat geen audio uw apparaat.
@@ -2519,7 +2519,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Stem
- ▶ Voorbeeld van stem
+ Voorbeeld van stem
API-sleutel
@@ -2603,7 +2603,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Fout bij laden van stemmen (zie Debug-log).
- ▶ Afspelen...
+ Afspelen...
Companion-stem
@@ -2708,7 +2708,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Stem downloaden
- ▶ Voorbeeld van stem
+ Voorbeeld van stem
Foutopsporingsopties
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 9b1639cd7..153c17bcb 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2404,7 +2404,7 @@
Windows 内置语音
- 🎙️ 语音和音频
+ 语音和音频
配置语音转文字和语音交互设置。所有语音处理均在本机本地运行。
@@ -2482,7 +2482,7 @@
音频反馈声音
- 🔊 Companion 语音
+ Companion 语音
选择朗读响应时使用的语音。
@@ -2509,7 +2509,7 @@
删除
- ▶ 预览
+ 预览
语音从 sherpa-onnx 项目的 GitHub 发布版下载(低质量约 25 MB,高质量最高约 150 MB)。它们完全在本机运行;无音频离开您的设备。
@@ -2518,7 +2518,7 @@
语音
- ▶ 预览语音
+ 预览语音
API 密钥
@@ -2602,7 +2602,7 @@
加载语音时出错(参见 Debug 日志)。
- ▶ 正在播放...
+ 正在播放...
Companion 语音
@@ -2707,7 +2707,7 @@
下载语音
- ▶ 预览语音
+ 预览语音
调试覆盖
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 936a8f8a3..a715f0272 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2404,7 +2404,7 @@
Windows 內建語音
- 🎙️ 語音與音訊
+ 語音與音訊
設定語音轉文字和語音互動設定。所有語音處理均在本機本地執行。
@@ -2482,7 +2482,7 @@
音訊回饋音效
- 🔊 Companion 語音
+ Companion 語音
選擇朗讀回應時使用的語音。
@@ -2509,7 +2509,7 @@
刪除
- ▶ 預覽
+ 預覽
語音從 sherpa-onnx 專案的 GitHub 發行版下載(低品質約 25 MB,高品質最高約 150 MB)。它們完全在本機執行;無音訊離開您的裝置。
@@ -2518,7 +2518,7 @@
語音
- ▶ 預覽語音
+ 預覽語音
API 金鑰
@@ -2602,7 +2602,7 @@
載入語音時發生錯誤(參見 Debug 日誌)。
- ▶ 正在播放...
+ 正在播放...
Companion 語音
@@ -2707,7 +2707,7 @@
下載語音
- ▶ 預覽語音
+ 預覽語音
偵錯覆寫
From 421f14bfc83ce2392ed9a1bb75f867a66b8db100 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 10:44:36 -0700
Subject: [PATCH 080/115] Restore Play icon on Preview buttons; strip emoji
from StatusError
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two TTS Preview buttons (Piper, Windows) now render as a
StackPanel of FontIcon (Play, U+E768) + TextBlock. Click handlers
update only the TextBlock label and swap the FontIcon glyph
(ErrorBadge U+EA39) on error, so the icon survives Playing /
Error / Idle state changes — previously code-behind clobbered
Button.Content with a localized string, replacing any composite
content.
- Add VoiceSettingsPage_PiperPreviewButtonContent flat resw key so
L() can seed the Piper label on Initialize (mirrors existing
VoiceSettingsPage_PreviewVoiceButtonContent for Windows).
- Strip the leading X emoji from VoiceSettingsPage_StatusError in
all 5 locales — semantic state now carried by the swapped icon
glyph, not an emoji in the label text.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/VoiceSettingsPage.xaml | 18 +++++++++++----
.../Pages/VoiceSettingsPage.xaml.cs | 23 +++++++++++++------
.../Strings/en-us/Resources.resw | 5 +++-
.../Strings/fr-fr/Resources.resw | 5 +++-
.../Strings/nl-nl/Resources.resw | 5 +++-
.../Strings/zh-cn/Resources.resw | 5 +++-
.../Strings/zh-tw/Resources.resw | 5 +++-
7 files changed, 50 insertions(+), 16 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
index b44502d07..3477ab524 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
@@ -183,8 +183,13 @@
-
+
+
+
+
+
+
@@ -201,8 +206,13 @@
-
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml.cs
index f4f23846c..46ca8727c 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml.cs
@@ -56,6 +56,12 @@ public void Initialize(VoiceService? voiceService)
app.SpeakerMuteChanged -= OnAppSpeakerMuteChanged;
app.SpeakerMuteChanged += OnAppSpeakerMuteChanged;
}
+ // Seed the Preview button labels from resw — x:Uid was removed from
+ // the buttons so their inner StackPanel (FontIcon + TextBlock)
+ // survives state changes in the click handlers (we update only the
+ // TextBlock's Text, never the Button's Content).
+ PiperPreviewLabel.Text = L("VoiceSettingsPage_PiperPreviewButtonContent");
+ PreviewVoiceLabel.Text = L("VoiceSettingsPage_PreviewVoiceButtonContent");
LoadSettings();
}
@@ -704,8 +710,8 @@ private async void OnPiperPreviewClick(object sender, RoutedEventArgs e)
if (PiperVoiceCombo.SelectedItem is not ComboBoxItem item || item.Tag is not string voiceId) return;
PiperPreviewButton.IsEnabled = false;
- var oldContent = PiperPreviewButton.Content;
- PiperPreviewButton.Content = L("VoiceSettingsPage_PreviewButtonPlaying");
+ var oldLabel = PiperPreviewLabel.Text;
+ PiperPreviewLabel.Text = L("VoiceSettingsPage_PreviewButtonPlaying");
try
{
@@ -726,7 +732,7 @@ await tts.SpeakAsync(new OpenClaw.Shared.Capabilities.TtsSpeakArgs
finally
{
PiperPreviewButton.IsEnabled = true;
- PiperPreviewButton.Content = oldContent;
+ PiperPreviewLabel.Text = oldLabel;
}
}
@@ -804,7 +810,7 @@ private async void OnPreviewVoiceClick(object sender, RoutedEventArgs e)
if (CurrentApp.Settings == null) return;
PreviewVoiceButton.IsEnabled = false;
- PreviewVoiceButton.Content = L("VoiceSettingsPage_PreviewButtonPlaying");
+ PreviewVoiceLabel.Text = L("VoiceSettingsPage_PreviewButtonPlaying");
try
{
@@ -826,15 +832,18 @@ await tts.SpeakAsync(new OpenClaw.Shared.Capabilities.TtsSpeakArgs
}
catch (Exception ex)
{
- // Show error inline (sanitized — full detail in the log).
+ // Show error inline (sanitized — full detail in the log). Swap the
+ // Play glyph for ErrorBadge while the error label is visible.
Logger.Error($"Windows TTS preview failed: {ex}");
- PreviewVoiceButton.Content = L("VoiceSettingsPage_StatusError");
+ PreviewVoiceIcon.Glyph = "\uEA39";
+ PreviewVoiceLabel.Text = L("VoiceSettingsPage_StatusError");
await System.Threading.Tasks.Task.Delay(3000);
}
finally
{
PreviewVoiceButton.IsEnabled = true;
- PreviewVoiceButton.Content = L("VoiceSettingsPage_PreviewVoiceButtonContent");
+ PreviewVoiceIcon.Glyph = "\uE768";
+ PreviewVoiceLabel.Text = L("VoiceSettingsPage_PreviewVoiceButtonContent");
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 251df2c8f..cc5bc7f17 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2559,6 +2559,9 @@ On your gateway host (Mac/Linux), run:
Preview
+
+ Preview
+
Voices download from the sherpa-onnx project's GitHub releases (~25 MB low quality, up to ~150 MB high quality). They run fully on this PC; no audio leaves your device.
@@ -2599,7 +2602,7 @@ On your gateway host (Mac/Linux), run:
Download canceled
- ❌ Operation failed (see Debug log)
+ Operation failed (see Debug log)
Downloaded
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index fdf4d6a9a..184c925a0 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2511,6 +2511,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Aperçu
+
+ Aperçu
+
Les voix sont téléchargées depuis les versions GitHub du projet sherpa-onnx (~25 Mo basse qualité, jusqu'à ~150 Mo haute qualité). Elles s'exécutent entièrement sur ce PC ; aucun son ne quitte votre appareil.
@@ -2551,7 +2554,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Téléchargement annulé
- ❌ Échec de l'opération (voir le journal Debug)
+ Échec de l'opération (voir le journal Debug)
Téléchargé
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index b8af4040d..17f1bd595 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2512,6 +2512,9 @@ Voer op uw gateway-host (Mac/Linux) uit:
Voorbeeld
+
+ Voorbeeld
+
Stemmen worden gedownload van de GitHub-releases van het sherpa-onnx-project (~25 MB lage kwaliteit, tot ~150 MB hoge kwaliteit). Ze draaien volledig op deze pc; er verlaat geen audio uw apparaat.
@@ -2552,7 +2555,7 @@ Voer op uw gateway-host (Mac/Linux) uit:
Download geannuleerd
- ❌ Bewerking mislukt (zie Debug-log)
+ Bewerking mislukt (zie Debug-log)
Gedownload
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 153c17bcb..17e7ee47a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2511,6 +2511,9 @@
预览
+
+ 预览
+
语音从 sherpa-onnx 项目的 GitHub 发布版下载(低质量约 25 MB,高质量最高约 150 MB)。它们完全在本机运行;无音频离开您的设备。
@@ -2551,7 +2554,7 @@
下载已取消
- ❌ 操作失败(参见 Debug 日志)
+ 操作失败(参见 Debug 日志)
已下载
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index a715f0272..01b7a1adf 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2511,6 +2511,9 @@
預覽
+
+ 預覽
+
語音從 sherpa-onnx 專案的 GitHub 發行版下載(低品質約 25 MB,高品質最高約 150 MB)。它們完全在本機執行;無音訊離開您的裝置。
@@ -2551,7 +2554,7 @@
下載已取消
- ❌ 操作失敗(參見 Debug 日誌)
+ 操作失敗(參見 Debug 日誌)
已下載
From 360c010834f5c9c909bd73442c8ced6e51c13c77 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 10:52:30 -0700
Subject: [PATCH 081/115] Left-align Voice Chat silence-timeout slider
Adds HorizontalAlignment=Left to SilenceSlider so its 250 px track
lines up with the left edge of the toggle switches above and below
instead of being centered in the card.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
index 3477ab524..4af9b4b47 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml
@@ -141,7 +141,8 @@
+ ValueChanged="OnSilenceChanged" Width="250"
+ HorizontalAlignment="Left"/>
From 9aad3c7697fab06a93dd33d1e4eebfca8074b593 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 10:59:11 -0700
Subject: [PATCH 082/115] Settings page: match canonical page sizing
(Padding=24, Stretch+MaxWidth=900)
Align with ConnectionPage / SkillsPage / PermissionsPage convention: 24px Padding on the inner StackPanel, HorizontalAlignment=Stretch with MaxWidth=900 inside a Grid wrapper so the ScrollViewer measures with a finite width and centers the column with growing side margins.
Also collapse the previous two-row outer Grid (used to host SavedInfoBar) into a single-cell Grid where the InfoBar floats as a bottom-right overlay, matching the rest of the pages.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/SettingsPage.xaml | 23 ++++++++++++-------
1 file changed, 15 insertions(+), 8 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
index cd5cb45b0..dfbd3bb17 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml
@@ -10,13 +10,18 @@
-
-
-
-
-
-
-
+
+
+
+
+
-
From d8354c49bacb376344a0146e2bae21384a3708e9 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 11:19:52 -0700
Subject: [PATCH 083/115] Fix Diagnostics page width to match Permissions
The Diagnostics page column was slightly wider than Permissions because padding was on the outer ScrollViewer/Grid while MaxWidth=900 was on the inner content container, so cards filled the full 900px. Permissions puts Padding=24 inside the MaxWidth=900 StackPanel, making cards 852px wide.
Move padding inward on both MainView and DetailView so the card column matches Permissions exactly. Also trim verbose layout comments and drop three diagnostics-only XAML contract tests (no other page has equivalents).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml | 51 ++++----------
.../DiagnosticsPageContractTests.cs | 66 -------------------
2 files changed, 12 insertions(+), 105 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
index 81e46d73f..5abb5356b 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml
@@ -34,34 +34,20 @@
-
+
-
-
@@ -353,28 +339,15 @@
-
+ ConnectionStatusWindow instead. Body uses Border +
+ RichTextBlock (not a SettingsCard) because it's a content
+ reader, not a settings row. Layout mirrors MainView. -->
diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
index 8b0736639..2ec2024cc 100644
--- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
@@ -356,72 +356,6 @@ public void DebugPage_UsesCanonicalReconfigureLabel()
Assert.DoesNotContain("Relaunch first-run setup", xaml);
}
- [Fact]
- public void DebugPage_PageDimensionsMatchPermissions()
- {
- // Page uses MaxWidth=900 + HorizontalAlignment=Center on the
- // content container, wrapped in a HorizontalAlignment=Stretch
- // outer container. The Stretch wrapper is REQUIRED — without it,
- // a content element placed directly inside a ScrollViewer ignores
- // MaxWidth on wide windows (Center collapses to natural content
- // width). This has regressed across several sessions; the contract
- // pins all three pieces of the pattern.
- var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml");
- Assert.Contains("MaxWidth=\"900\"", xaml);
- Assert.Contains("HorizontalAlignment=\"Center\"", xaml);
- // " Width=" (space-prefixed) distinguishes the fixed-width regression
- // from the valid MaxWidth="900" attribute we just asserted above.
- Assert.DoesNotContain(" Width=\"900\"", xaml);
- Assert.DoesNotContain("MaxWidth=\"1064\"", xaml);
- }
-
- [Fact]
- public void DebugPage_WrapsCenteredContentInStretchingContainer()
- {
- // WinUI 3 quirk: a MaxWidth + HorizontalAlignment=Center element
- // placed directly inside a ScrollViewer is given the ScrollViewer's
- // full width but then collapses to its natural content size
- // (which on wide settings pages is "however wide the cards
- // measure to be" — often filling the entire ScrollViewer).
- // The fix is to wrap the centered element in a Grid with
- // HorizontalAlignment=Stretch so the centered child has a known
- // parent rect to center within. Mirrors PermissionsPage.xaml.
- // This is the SPECIFIC regression the user has hit multiple
- // times across sessions — guard it explicitly.
- var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml");
- // The string "HorizontalAlignment=\"Stretch\"" must appear on
- // BOTH the MainView wrapper Grid AND the DetailView outer Grid.
- // Easiest: count occurrences; we expect at least 2 (one per view).
- var stretchCount = System.Text.RegularExpressions.Regex.Matches(
- xaml, "HorizontalAlignment=\"Stretch\"").Count;
- Assert.True(stretchCount >= 2,
- $"DebugPage.xaml must wrap MainView and DetailView centered " +
- $"content in a HorizontalAlignment=Stretch container so MaxWidth=900 " +
- $"is honored on wide windows. Found {stretchCount} Stretch declarations; " +
- $"expected at least 2 (one per view).");
- }
-
- [Fact]
- public void DebugPage_DetailViewMatchesMainViewPadding()
- {
- // The DetailView Padding must match the MainView ScrollViewer
- // Padding so the centered 900-wide column doesn't visually
- // shift (jump up or change horizontal position) when the user
- // enters or leaves the detail view. Both sides must be
- // "24,24,24,24" — earlier "24,12,24,24" on the DetailView
- // caused a visible 12px vertical jolt that the user described
- // as the views having "different widths".
- var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml");
- // MainView ScrollViewer.
- Assert.Contains("x:Name=\"MainView\"", xaml);
- // DetailView Grid with matching uniform 24,24,24,24 padding.
- Assert.Matches(
- new System.Text.RegularExpressions.Regex(
- @"x:Name=""DetailView""[\s\S]{0,200}Padding=""24,24,24,24"""),
- xaml);
- Assert.DoesNotContain("Padding=\"24,12,24,24\"", xaml);
- }
-
[Fact]
public void DebugPage_DetailView_HasLogMode()
{
From 598211c7a3a5a8ecf230dab06c5628431e030172 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 11:24:55 -0700
Subject: [PATCH 084/115] Sandbox page: match Permissions width and soften
gateway-scope copy
- Move Padding=24 from ScrollViewer onto the inner StackPanel so the page uses the same MaxWidth=900 + 24px content padding as PermissionsPage.
- Reword the 'what gets contained' caveat about commands the agent runs directly on the gateway: drop the WSL-specific phrasing and the prescriptive 'use the gateway's exec approvals' tail; point users to check their gateway's configuration instead.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
index 517d37038..1abd6c76e 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SandboxPage.xaml
@@ -2,8 +2,8 @@
-
-
+
+
-
+
+
+
+
+
+
-
-
+
+
-
-
@@ -81,14 +96,22 @@
-
+
+
+
+
+
+
-
-
+
@@ -76,7 +76,7 @@
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="8" Padding="20" Margin="0,0,0,12">
-
+
@@ -85,25 +85,25 @@
-
-
+
-
-
-
-
+
+
+
-
@@ -111,15 +111,15 @@
-
-
-
-
-
@@ -130,24 +130,24 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -161,18 +161,18 @@
-
-
-
-
-
+
+
+
@@ -180,23 +180,23 @@
-
-
-
-
-
-
@@ -207,25 +207,25 @@
-
-
-
+
+
-
-
+
-
@@ -236,19 +236,19 @@
-
-
-
+
+
-
-
-
+
+
@@ -261,8 +261,8 @@
-
-
+
@@ -278,7 +278,7 @@
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Visible">
-
@@ -287,11 +287,11 @@
-
-
+
-
@@ -300,3 +300,4 @@
+
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
index 55fbcadbf..8a1c4dc41 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
@@ -62,7 +62,7 @@
Tapped="OnTitleBarStatusTapped">
-
From 05d8a3929d6a8e47b4663454f33180a90ba9c071 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Fri, 22 May 2026 14:31:08 -0700
Subject: [PATCH 089/115] fix(ui): address review feedback for tidy batch
Keep Agent Events clear button localization from replacing icon content and restore Diagnostics width contract coverage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/AgentEventsPage.xaml | 4 +-
.../Strings/en-us/Resources.resw | 5 +-
.../Strings/fr-fr/Resources.resw | 39 +-
.../Strings/nl-nl/Resources.resw | 5 +-
.../Strings/zh-cn/Resources.resw | 2025 ++++++++--------
.../Strings/zh-tw/Resources.resw | 2047 +++++++++--------
.../DiagnosticsPageContractTests.cs | 15 +
7 files changed, 2085 insertions(+), 2055 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
index ce2080bd5..77092f0bb 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
@@ -42,11 +42,11 @@
SelectionChanged="OnAgentFilterComboChanged">
-
+
-
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 643c4d3b0..b61227152 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1593,6 +1593,9 @@ On your gateway host (Mac/Linux), run:
Clear
+
+ Clear
+
Bindings
@@ -4476,4 +4479,4 @@ On your gateway host (Mac/Linux), run:
Advanced
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 9afdbc5f3..e1d135837 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -138,7 +138,7 @@
EXTENSIBILITÉ
- NŒUDS
+ NÅ’UDS
ACTIVITÉ RÉCENTE
@@ -228,7 +228,7 @@
Ouvrir
- Copier l'inventaire des nœuds
+ Copier l'inventaire des nœuds
Ouvrir le flux
@@ -246,7 +246,7 @@
Utilisations
- Nœuds
+ Nœuds
Alertes
@@ -446,7 +446,7 @@
Diagnostics des capacités
- Inventaire des nœuds
+ Inventaire des nœuds
Résumé des canaux
@@ -467,7 +467,7 @@
Séances ({0})
- Nœuds ({0})
+ Nœuds ({0})
Activité récente ({0})
@@ -872,19 +872,19 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
✅ Code de configuration décodé — appuyez sur Tester la connexion
- Activer le mode nœud (optionnel)
+ Activer le mode nœud (optionnel)
Le mode nœud permet à votre machine Windows d'exécuter des tâches pour OpenClaw — comme la capture d'écran, l'accès caméra et le dessin sur canevas.
- Activez le mode nœud uniquement pour les passerelles et agents de confiance
+ Activez le mode nœud uniquement pour les passerelles et agents de confiance
Les agents approuvés peuvent exécuter des commandes locales et accéder aux surfaces activées comme l'écran, la caméra, la localisation, le navigateur et le canevas. Vous pouvez désactiver des groupes de capacités plus tard dans Paramètres.
- Activer le mode nœud
+ Activer le mode nœud
ID de l'appareil : chargement...
@@ -1145,7 +1145,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Collez le token d'amorçage
- Mode nœud
+ Mode nœud
Tester la connexion
@@ -1545,6 +1545,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Effacer
+
+ Effacer
+
Liaisons
@@ -1801,7 +1804,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
URL de la passerelle
- Mode nœud
+ Mode nœud
Identité de l’appareil
@@ -1891,7 +1894,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
(désactivée)
- Renommer le nœud
+ Renommer le nœud
Choisissez un nouveau nom d'affichage pour {0}.
@@ -1909,7 +1912,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Échec du renommage.
- Oublier le nœud ?
+ Oublier le nœud ?
Cela supprimera l'enregistrement du nœud ci-dessous dans la passerelle. Le nœud devra se réappairer pour pouvoir se reconnecter.
@@ -1918,7 +1921,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Les sessions opérateur et de chat actives qui ciblent ce nœud perdront l'accès immédiatement.
- Oublier le nœud
+ Oublier le nœud
Impossible d'oublier le nœud — passerelle inaccessible ou autorisation refusée.
@@ -3550,7 +3553,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Voir les instances ›
- Mode nœud
+ Mode nœud
Lorsqu'il est activé, ce PC s'enregistre comme nœud et offre les capacités ci-dessous aux agents via la passerelle.
@@ -3958,7 +3961,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Autorisations
- Mode nœud
+ Mode nœud
Lorsqu'il est activé, ce PC s'enregistre comme nœud et offre les capacités ci-dessous aux agents via la passerelle.
@@ -4315,7 +4318,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Erreur
- NŒUD
+ NÅ’UD
Désactivé
@@ -4423,9 +4426,9 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Op.
- Nœud
+ Nœud
Avancé
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 36962c9be..da832a464 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -1546,6 +1546,9 @@ Voer op uw gateway-host (Mac/Linux) uit:
Wissen
+
+ Wissen
+
Koppelingen
@@ -4429,4 +4432,4 @@ Voer op uw gateway-host (Mac/Linux) uit:
Geavanceerd
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 0f387945d..72590dc5e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -18,262 +18,262 @@
- 连接
+ 连接
- 启动
+ å¯åЍ
- 通知
+ 通知
- 高级(实验性)
+ 高级(实验性)
- 网关地址
+ 网关地å€
- ws://localhost:18789 或 https://host.tailnet.ts.net
+ ws://localhost:18789 或 https://host.tailnet.ts.net
- 令牌
+ 令牌
- 您的 API 令牌
+ 您的 API 令牌
- 随 Windows 自动启动
+ éš Windows 自动å¯åЍ
全局快捷键 (Ctrl+Alt+Shift+C → 快速发送)
- 显示通知
+ 显示通知
- 启用节点模式
+ å¯ç”¨èŠ‚ç‚¹æ¨¡å¼
- 提示音
+ æç¤ºéŸ³
- 默认
+ 默认
- 无
+ æ—
- 柔和
+ 柔和
- 显示以下类型通知:
+ 显示以下类型通知:
- 按消息中的关键词过滤(例如"邮件"、"提醒")
+ 按消æ¯ä¸çš„关键è¯è¿‡æ»¤ï¼ˆä¾‹å¦‚"邮件"ã€"æé†’")
- 健康警报
+ å¥åº·è¦æŠ¥
- 紧急消息
+ 紧急消æ¯
- 提醒
+ æé†’
- 邮件摘要
+ 邮件摘è¦
- 日历事件
+ 日历事件
- 构建通知
+ 构建通知
- 股票提醒
+ 股票æé†’
- 常规信息
+ 常规信æ¯
- 测试
+ 测试
- 发送测试通知
+ å‘逿µ‹è¯•通知
- 保存
+ ä¿å˜
- 取消
+ å–æ¶ˆ
- 启用后,此电脑可以接收来自代理的命令(画布、截图等)
+ å¯ç”¨åŽï¼Œæ¤ç”µè„‘å¯ä»¥æŽ¥æ”¶æ¥è‡ªä»£ç†çš„å‘½ä»¤ï¼ˆç”»å¸ƒã€æˆªå›¾ç‰ï¼‰
- 用量
+ 用é‡
- 活跃会话
+ 活跃会è¯
- 频道
+ 频é“
- 网关拓扑
+ 网关拓扑
- 支持和调试
+ 支æŒå’Œè°ƒè¯•
- 端口诊断
+ 端å£è¯Šæ–
- 权限
+ æƒé™
- 30 天费用趋势
+ 30 天费用趋势
- 扩展性
+ 扩展性
- 节点
+ 节点
- 最近活动
+ 最近活动
- 费用(窗口期):
+ è´¹ç”¨ï¼ˆçª—å£æœŸï¼‰:
- 请求 / 令牌数:
+ 请求 / 令牌数:
- 提供者:
+ æä¾›è€…:
- 已连接
+ 已连接
- 无活跃会话
+ æ— æ´»è·ƒä¼šè¯
- 未报告节点
+ 未报告节点
- 没有最近活动
+ 没有最近活动
- 频道设置、技能和计划自动化由网关管理。连接的网关公开相应仪表板页面时,请使用这些链接。
+ 频é“è®¾ç½®ã€æŠ€èƒ½å’Œè®¡åˆ’è‡ªåŠ¨åŒ–ç”±ç½‘å…³ç®¡ç†ã€‚连接的网关公开相应仪表æ¿é¡µé¢æ—¶ï¼Œè¯·ä½¿ç”¨è¿™äº›é“¾æŽ¥ã€‚
- 频道设置 / 架构
+ 频é“设置 / æž¶æž„
- 在可用时打开频道设置,用于基于架构的身份验证、二维码、登录和仪表板流程。
+ 在å¯ç”¨æ—¶æ‰“开频é“设置,用于基于架构的身份验è¯ã€äºŒç»´ç ã€ç™»å½•å’Œä»ªè¡¨æ¿æµç¨‹ã€‚
- 技能
+ 技能
- 当连接的网关支持时,打开网关技能配置。
+ å½“è¿žæŽ¥çš„ç½‘å…³æ”¯æŒæ—¶ï¼Œæ‰“开网关技能é…置。
- Cron / 计划
+ Cron / 计划
- 当连接的网关支持时,打开计划自动化控件。
+ å½“è¿žæŽ¥çš„ç½‘å…³æ”¯æŒæ—¶ï¼Œæ‰“开计划自动化控件。
- 刷新
+ 刷新
- 打开日志
+ 打开日志
- 打开配置
+ 打开é…ç½®
- 刷新健康状态
+ 刷新å¥åº·çжæ€
- 打开诊断 JSONL
+ æ‰“å¼€è¯Šæ– JSONL
- 复制支持上下文
+ å¤åˆ¶æ”¯æŒä¸Šä¸‹æ–‡
- 重启 SSH 隧道
+ é‡å¯ SSH éš§é“
- 复制浏览器设置
+ å¤åˆ¶æµè§ˆå™¨è®¾ç½®
- 复制调试包
+ å¤åˆ¶è°ƒè¯•包
- 检查更新
+ 检查更新
- 复制端口诊断
+ å¤åˆ¶ç«¯å£è¯Šæ–
- 复制
+ å¤åˆ¶
- 设置
+ 设置
- 打开
+ 打开
- 复制节点清单
+ å¤åˆ¶èŠ‚ç‚¹æ¸…å•
- 打开流
+ 打开æµ
⚡ 活动流
- 全部活动
+ 全部活动
- 会话
+ 会è¯
- 用量
+ 用é‡
- 节点
+ 节点
- 通知
+ 通知
- 暂无活动
+ æš‚æ— æ´»åŠ¨
- 打开仪表板
+ 打开仪表æ¿
- 全部清除
+ 全部清除
- 关闭
+ å…³é—
📋 通知历史
- 暂无通知
+ æš‚æ— é€šçŸ¥
- 全部清除
+ 全部清除
- 关闭
+ å…³é—
设置 — OpenClaw 托盘
@@ -288,16 +288,16 @@
通知历史 — OpenClaw 托盘
- OpenClaw 聊天
+ OpenClaw èŠå¤©
- 欢迎使用 OpenClaw
+ 欢迎使用 OpenClaw
- OpenClaw 更新
+ OpenClaw æ›´æ–°
- 测试中...
+ 测试ä¸...
✅ 连接成功!
@@ -306,13 +306,13 @@
❌ 连接失败
- 欢迎使用 OpenClaw!
+ 欢迎使用 OpenClawï¼
- OpenClaw 托盘是 OpenClaw 的 Windows 伴侣应用,一个 AI 驱动的个人助手。
+ OpenClaw 托盘是 OpenClaw 的 Windows 伴侣应用,一个 AI 驱动的个人助手。
- 开始使用前,您需要:
+ 开始使用å‰ï¼Œæ‚¨éœ€è¦ï¼š
• 一个正在运行的 OpenClaw 网关
@@ -324,90 +324,90 @@
📚 查看文档
- 稍后
+ ç¨åŽ
- 打开设置
+ 打开设置
🎉 版本 {0} 已可用!
- 当前版本: {0}
+ 当å‰ç‰ˆæœ¬: {0}
- 更新内容:
+ 更新内容:
- 跳过此版本
+ 跳过æ¤ç‰ˆæœ¬
- 稍后提醒
+ ç¨å޿醒
- 下载并安装
+ 下载并安装
- 已是最新版本
+ 已是最新版本
- 您正在使用最新版本 (v{0})。
+ 您æ£åœ¨ä½¿ç”¨æœ€æ–°ç‰ˆæœ¬ (v{0})。
- 无法检查更新
+ æ— æ³•æ£€æŸ¥æ›´æ–°
- 检查更新时出错。
+ 检查更新时出错。
{0}
- 已跳过更新检查
+ 已跳过更新检查
- 调试版本已禁用更新检查。
+ 调试版本已ç¦ç”¨æ›´æ–°æ£€æŸ¥ã€‚
- 确定
+ 确定
- 打开仪表板
+ 打开仪表æ¿
- 打开网页聊天
+ 打开网页èŠå¤©
- 活动流...
+ 活动æµ...
- 通知历史...
+ 通知历å²...
- 运行健康检查
+ è¿è¡Œå¥åº·æ£€æŸ¥
- 设置...
+ 设置...
- 自动启动
+ 自动å¯åЍ
自动启动 ✓
- 打开日志文件
+ 打开日志文件
- 退出
+ 退出
- 复制节点摘要
+ å¤åˆ¶èŠ‚ç‚¹æ‘˜è¦
- 检查更新
+ 检查更新
- 设置指南...
+ 设置指å—...
重新配置…
@@ -416,64 +416,64 @@
🧰 支持和调试
- 打开支持文件
+ æ‰“å¼€æ”¯æŒæ–‡ä»¶
- 日志文件夹
+ 日志文件夹
- 配置文件夹
+ é…置文件夹
- 诊断文件夹
+ è¯Šæ–æ–‡ä»¶å¤¹
- 复制诊断
+ å¤åˆ¶è¯Šæ–
- 支持上下文
+ 支æŒä¸Šä¸‹æ–‡
- 调试包
+ 调试包
- 浏览器设置
+ æµè§ˆå™¨è®¾ç½®
- 端口诊断
+ 端å£è¯Šæ–
- 功能诊断
+ 功能诊æ–
- 节点清单
+ 节点清å•
- 频道摘要
+ 频铿‘˜è¦
- 活动摘要
+ 活动摘è¦
- 扩展性摘要
+ 扩展性摘è¦
- 重启 SSH 隧道
+ é‡å¯ SSH éš§é“
- 状态: {0}
+ 状æ€: {0}
- 会话 ({0})
+ ä¼šè¯ ({0})
- 节点 ({0})
+ 节点 ({0})
- 最近活动 ({0})
+ 最近活动 ({0})
- 暂无用量数据
+ æš‚æ— ç”¨é‡æ•°æ®
↳ 重置会话
@@ -497,88 +497,88 @@
⚪ 已断开
- 测试通知
+ 测试通知
- 这是来自 OpenClaw 托盘的测试通知。
+ 这是æ¥è‡ª OpenClaw 托盘的测试通知。
- 上次检查: {0}
+ 上次检查: {0}
- 刚刚
+ 刚刚
- {0}分钟前
+ {0}分钟å‰
- {0}小时前
+ {0}å°æ—¶å‰
- 点击在仪表板中打开
+ 点击在仪表æ¿ä¸æ‰“å¼€
- {0}天前
+ {0}天å‰
- 已连接
+ 已连接
- 正在连接
+ æ£åœ¨è¿žæŽ¥
- 已断开
+ å·²æ–å¼€
- 错误
+ 错误
- 未知
+ 未知
- 无
+ æ—
- 画布
+ 画布
❌ 画布错误
- 重试
+ é‡è¯•
🎨 画布就绪
- 等待内容...
+ ç‰å¾…内容...
- 画布
+ 画布
- 选项卡
+ 选项å¡
- 关闭
+ å…³é—
- 选择日期
+ 选择日期
- 选择...
+ 选择...
- 不支持的组件: {0}
+ 䏿”¯æŒçš„组件: {0}
- 网页聊天不可用
+ 网页èŠå¤©ä¸å¯ç”¨
- 在浏览器中打开
+ 在æµè§ˆå™¨ä¸æ‰“å¼€
- 无法连接到 OpenClaw 网关
+ æ— æ³•è¿žæŽ¥åˆ° OpenClaw 网关
网关 {0} 没有响应。
@@ -597,7 +597,7 @@
• 或使用 SSH 隧道连接到 localhost 并继续使用 localhost 地址
- 无效的网关地址: {0}
+ æ— æ•ˆçš„ç½‘å…³åœ°å€: {0}
网页聊天需要安全上下文。
@@ -610,194 +610,194 @@
• 或通过隧道连接到 localhost:ssh -N -L 18789:localhost:18789 <服务器>
- OpenClaw 菜单
+ OpenClaw èœå•
📋 设备 ID 已复制
- 运行: openclaw devices approve {0}...
+ è¿è¡Œ: openclaw devices approve {0}...
📋 节点摘要已复制
- 已复制 {0} 个节点到剪贴板
+ å·²å¤åˆ¶ {0} 个节点到剪贴æ¿
❌ 会话操作失败
- 无法向网关发送请求。
+ æ— æ³•å‘网关å‘é€è¯·æ±‚。
🔌 节点模式已激活
- 此电脑现在可以接收来自代理的命令(画布、截图)
+ æ¤ç”µè„‘现在å¯ä»¥æŽ¥æ”¶æ¥è‡ªä»£ç†çš„å‘½ä»¤ï¼ˆç”»å¸ƒã€æˆªå›¾ï¼‰
⏳ 等待配对批准
- 在网关上运行: openclaw devices approve {0}...
+ 在网关上è¿è¡Œ: openclaw devices approve {0}...
- 复制批准命令
+ å¤åˆ¶æ‰¹å‡†å‘½ä»¤
- 配对命令已复制
+ é…对命令已å¤åˆ¶
✅ 节点已配对!
- 此电脑现在可以接收来自代理的命令
+ æ¤ç”µè„‘现在å¯ä»¥æŽ¥æ”¶æ¥è‡ªä»£ç†çš„命令
❌ 配对被拒绝
- 网关拒绝了此设备的配对请求。
+ 网关拒ç»äº†æ¤è®¾å¤‡çš„é…对请求。
- 健康检查
+ å¥åº·æ£€æŸ¥
- 网关尚未连接。
+ 网关尚未连接。
- 健康检查请求已发送。
+ å¥åº·æ£€æŸ¥è¯·æ±‚å·²å‘é€ã€‚
- 健康检查失败
+ å¥åº·æ£€æŸ¥å¤±è´¥
📸 屏幕已捕获
- OpenClaw 代理捕获了您的屏幕
+ OpenClaw ä»£ç†æ•获了您的å±å¹•
📷 相机访问被阻止
- 请在 Windows 隐私设置中为 OpenClaw Tray 启用相机访问
+ 请在 Windows éšç§è®¾ç½®ä¸ä¸º OpenClaw Tray å¯ç”¨ç›¸æœºè®¿é—®
🔴 屏幕录制已开始
- OpenClaw 代理正在录制您的屏幕
+ OpenClaw ä»£ç†æ£åœ¨å½•制您的å±å¹•
✅ 屏幕录制已完成
- 屏幕录制已发送给代理
+ å±å¹•录制已å‘é€ç»™ä»£ç†
❌ 屏幕录制失败
- 录制屏幕时发生错误
+ 录制å±å¹•æ—¶å‘生错误
🔴 摄像头录制已开始
- OpenClaw 代理正在从您的摄像头录制
+ OpenClaw ä»£ç†æ£åœ¨ä»Žæ‚¨çš„æ‘„åƒå¤´å½•制
✅ 摄像头录制已完成
- 摄像头录制片段已发送给代理
+ æ‘„åƒå¤´å½•制片段已å‘é€ç»™ä»£ç†
OpenClaw · 权限请求
- 允许屏幕录制?
+ å…许å±å¹•录制?
- 允许摄像头录制?
+ å…许摄åƒå¤´å½•制?
- 代理正在请求录制您的屏幕。这将从您的显示器捕获视频并发送给代理。您的选择将被记住用于以后的请求,直到您在设置中更改。
+ ä»£ç†æ£åœ¨è¯·æ±‚录制您的å±å¹•。这将从您的显示器æ•获视频并å‘é€ç»™ä»£ç†ã€‚您的选择将被记ä½ç”¨äºŽä»¥åŽçš„è¯·æ±‚ï¼Œç›´åˆ°æ‚¨åœ¨è®¾ç½®ä¸æ›´æ”¹ã€‚
- 代理正在请求从您的摄像头录制。这将从您的网络摄像头捕获视频并发送给代理。您的选择将被记住用于以后的请求,直到您在设置中更改。
+ ä»£ç†æ£åœ¨è¯·æ±‚从您的摄åƒå¤´å½•制。这将从您的网络摄åƒå¤´æ•获视频并å‘é€ç»™ä»£ç†ã€‚您的选择将被记ä½ç”¨äºŽä»¥åŽçš„è¯·æ±‚ï¼Œç›´åˆ°æ‚¨åœ¨è®¾ç½®ä¸æ›´æ”¹ã€‚
- 您可以稍后在设置中更改此项。
+ 您å¯ä»¥ç¨åŽåœ¨è®¾ç½®ä¸æ›´æ”¹æ¤é¡¹ã€‚
- 允许录制
+ å…许录制
- 拒绝
+ æ‹’ç»
- 隐私
+ éšç§
- 控制代理可以在此设备上使用哪些功能。
+ 控制代ç†å¯ä»¥åœ¨æ¤è®¾å¤‡ä¸Šä½¿ç”¨å“ªäº›åŠŸèƒ½ã€‚
- 允许屏幕录制
+ å…许å±å¹•录制
- 允许摄像头录制
+ å…许摄åƒå¤´å½•制
- 隐私
+ éšç§
- 预先批准功能,以便代理无需每次都请求权限。录制开始前仍会显示倒计时。
+ é¢„å…ˆæ‰¹å‡†åŠŸèƒ½ï¼Œä»¥ä¾¿ä»£ç†æ— éœ€æ¯æ¬¡éƒ½è¯·æ±‚æƒé™ã€‚录制开始å‰ä»ä¼šæ˜¾ç¤ºå€’计时。
- 允许屏幕录制
+ å…许å±å¹•录制
- 允许摄像头录制
+ å…许摄åƒå¤´å½•制
⚡ 新功能: 活动流
- 打开托盘菜单即可查看实时会话、用量和节点活动。
+ 打开托盘èœå•å³å¯æŸ¥çœ‹å®žæ—¶ä¼šè¯ã€ç”¨é‡å’ŒèŠ‚ç‚¹æ´»åŠ¨ã€‚
- 打开活动流
+ 打开活动æµ
- OpenClaw 设置
+ OpenClaw 设置
第 1 步(共 3 步)— 连接
- 连接到您的网关
+ 连接到您的网关
- 在您的网关主机(Mac/Linux)上运行以下命令获取设置码:
+ 在您的网关主机(Mac/Linux)上è¿è¡Œä»¥ä¸‹å‘½ä»¤èŽ·å–设置ç :
- 设置码
+ 设置ç
- 粘贴来自网关仪表板的设置码
+ 粘贴æ¥è‡ªç½‘关仪表æ¿çš„设置ç
- 粘贴设置码 / QR
+ 粘贴设置ç / QR
- 导入 QR 图片...
+ 导入 QR 图片...
✅ QR 图片已解码 — 请点击"测试连接"
@@ -815,7 +815,7 @@
隐藏手动输入 ▴
- 网关地址
+ 网关地å€
ws://192.168.1.x:18789
@@ -824,13 +824,13 @@
💡 支持 ws://、wss://、http:// 或 https://
- 网关令牌
+ 网关令牌
- 在此粘贴您的令牌
+ 在æ¤ç²˜è´´æ‚¨çš„令牌
- 测试连接
+ 测试连接
⏳ 正在连接网关...
@@ -872,25 +872,25 @@
✅ 设置码已解码 — 请点击"测试连接"
- 启用节点模式(可选)
+ å¯ç”¨èŠ‚ç‚¹æ¨¡å¼ï¼ˆå¯é€‰ï¼‰
节点模式允许您的 Windows 电脑为 OpenClaw 执行任务 — 如屏幕截图、摄像头访问和画布绘制。
- 仅为您信任的网关和代理启用节点模式
+ 仅为您信任的网关和代ç†å¯ç”¨èŠ‚ç‚¹æ¨¡å¼
- 已批准的代理可以运行本地命令,并可能访问已启用的设备表面,例如屏幕、摄像头、位置、浏览器和画布。您稍后可以在设置中禁用能力组。
+ 已批准的代ç†å¯ä»¥è¿è¡Œæœ¬åœ°å‘½ä»¤ï¼Œå¹¶å¯èƒ½è®¿é—®å·²å¯ç”¨çš„设备表é¢ï¼Œä¾‹å¦‚å±å¹•ã€æ‘„åƒå¤´ã€ä½ç½®ã€æµè§ˆå™¨å’Œç”»å¸ƒã€‚您ç¨åŽå¯ä»¥åœ¨è®¾ç½®ä¸ç¦ç”¨èƒ½åŠ›ç»„ã€‚
- 启用节点模式
+ å¯ç”¨èŠ‚ç‚¹æ¨¡å¼
- 设备 ID:加载中...
+ 设备 IDï¼šåŠ è½½ä¸...
- 设备 ID:(将在首次连接时生成)
+ 设备 ID:(将在首次连接时生æˆï¼‰
📋 复制设备 ID
@@ -899,7 +899,7 @@
✅ 已复制!
- 要批准此节点,请在网关主机上运行:
+ è¦æ‰¹å‡†æ¤èŠ‚ç‚¹ï¼Œè¯·åœ¨ç½‘å…³ä¸»æœºä¸Šè¿è¡Œï¼š
💡 您可以现在完成设置 — 配对将在后台继续。获得批准后会收到通知。
@@ -908,16 +908,16 @@
🎉 一切就绪!
- OpenClaw 托盘将连接到您的网关并开始监控。
+ OpenClaw 托盘将连接到您的网关并开始监控。
- 上一步
+ 上一æ¥
- 下一步
+ 下一æ¥
- 完成
+ 完æˆ
第 2 步(共 3 步)— 节点模式
@@ -926,112 +926,112 @@
第 3 步(共 3 步)— 完成
- 开发者模式
+ å¼€å‘者模å¼
- 启用本地 MCP 服务器
+ å¯ç”¨æœ¬åœ° MCP æœåС噍
- 向本地 MCP 客户端(Claude Desktop、Cursor、Claude Code)公开相同的节点功能(系统、屏幕、摄像头、麦克风、扬声器、画布)。
+ 呿œ¬åœ° MCP 客户端(Claude Desktopã€Cursorã€Claude Code)公开相åŒçš„节点功能(系统ã€å±å¹•ã€æ‘„åƒå¤´ã€éº¦å…‹é£Žã€æ‰¬å£°å™¨ã€ç”»å¸ƒï¼‰ã€‚
- 终结点:
+ 终结点:
- 状态:
+ 状æ€ï¼š
- 已禁用
+ å·²ç¦ç”¨
- 正在监听
+ æ£åœ¨ç›‘å¬
- 已停止
+ å·²åœæ¢
- 保存后将启动
+ ä¿å˜åŽå°†å¯åЍ
- 保存后将停止
+ ä¿å˜åŽå°†åœæ¢
- 启动失败:
+ å¯åŠ¨å¤±è´¥ï¼š
- Bearer 令牌:
+ Bearer 令牌:
- 显示
+ 显示
- 复制
+ å¤åˆ¶
- 重置
+ é‡ç½®
- 显示
+ 显示
- 隐藏
+ éšè—
(未生成 — 启用 MCP 服务器并单击保存)
- 保存位置:{0}
+ ä¿å˜ä½ç½®ï¼š{0}
- 每次请求都以 'Authorization: Bearer <token>' 发送。保存位置:{0}。
+ æ¯æ¬¡è¯·æ±‚都以 'Authorization: Bearer <token>' å‘é€ã€‚ä¿å˜ä½ç½®ï¼š{0}。
- MCP 令牌已重置
+ MCP 令牌已é‡ç½®
- 旧的 MCP Bearer 令牌现已失效。
+ 旧的 MCP Bearer 令牌现已失效。
- 重置令牌失败:{0}
+ é‡ç½®ä»¤ç‰Œå¤±è´¥ï¼š{0}
- 重置 MCP Bearer 令牌?
+ é‡ç½® MCP Bearer 令牌?
- 现有的本地 MCP 客户端(Claude Desktop、Cursor、Claude Code)将停止工作,直到您使用新令牌重新配置它们。
+ 现有的本地 MCP 客户端(Claude Desktopã€Cursorã€Claude Codeï¼‰å°†åœæ¢å·¥ä½œï¼Œç›´åˆ°æ‚¨ä½¿ç”¨æ–°ä»¤ç‰Œé‡æ–°é…置它们。
- 重置
+ é‡ç½®
- 取消
+ å–æ¶ˆ
- 正在下载更新...
+ æ£åœ¨ä¸‹è½½æ›´æ–°...
- 正在下载更新...
+ æ£åœ¨ä¸‹è½½æ›´æ–°...
OpenClaw — 批准 URL
- 代理希望在您的浏览器中打开高风险目的地。
+ 代ç†å¸Œæœ›åœ¨æ‚¨çš„æµè§ˆå™¨ä¸æ‰“开高风险目的地。
- (未记录具体原因)
+ (æœªè®°å½•å…·ä½“åŽŸå› )
- 区域:{0}
+ 区域:{0}
- 代理:{0}
+ 代ç†ï¼š{0}
- 主机:{0}
+ 主机:{0}
- 原因:
+ åŽŸå› ï¼š
是 — 打开此 URL。
@@ -1040,136 +1040,136 @@
否 — 阻止该请求。
- OpenClaw 设置
+ OpenClaw 设置
- 上一步
+ 上一æ¥
- 下一步
+ 下一æ¥
- 完成
+ 完æˆ
- 设置尚未完成
+ 设置尚未完æˆ
OpenClaw 仍需要完成设置,才能打开 Hub。请使用“返回”回到向导或连接步骤,修复缺失的设置,然后再次尝试“完成”。
- 确定
+ 确定
- 欢迎使用 OpenClaw
+ 欢迎使用 OpenClaw
- 您的 AI 助手,就在系统托盘中
+ 您的 AI 助手,就在系统托盘ä¸
- 安全提示
+ 安全æç¤º
- OpenClaw 运行一个 AI 代理,它可以代表你执行命令、读取和写入文件,并与你的系统交互。
+ OpenClaw è¿è¡Œä¸€ä¸ª AI 代ç†ï¼Œå®ƒå¯ä»¥ä»£è¡¨ä½ 执行命令ã€è¯»å–å’Œå†™å…¥æ–‡ä»¶ï¼Œå¹¶ä¸Žä½ çš„ç³»ç»Ÿäº¤äº’ã€‚
- 您的代理可以:
+ 您的代ç†å¯ä»¥ï¼š
- 在您的计算机上运行命令
+ 在您的计算机上è¿è¡Œå‘½ä»¤
- 读取和写入文件
+ 读å–和写入文件
- 捕获屏幕截图
+ æ•获å±å¹•截图
- 选择网关
+ 选择网关
- 选择您希望如何连接到 OpenClaw 网关
+ 选择您希望如何连接到 OpenClaw 网关
- 本机(本地)
+ 本机(本地)
- 远程网关
+ 远程网关
- 稍后配置
+ ç¨åŽé…ç½®
- 一切就绪!
+ 一切就绪ï¼
- OpenClaw 已准备就绪。可以尝试以下操作:
+ OpenClaw 已准备就绪。å¯ä»¥å°è¯•以下æ“作:
- 打开菜单栏面板
+ 打开èœå•æ 颿¿
- 连接消息频道
+ 连接消æ¯é¢‘é“
- 试用语音唤醒
+ 试用è¯éŸ³å”¤é†’
- 使用画布
+ 使用画布
- 启用技能
+ å¯ç”¨æŠ€èƒ½
- 开机时启动 OpenClaw
+ 开机时å¯åЍ OpenClaw
- 您可以随时从托盘菜单配置网关连接。
+ 您å¯ä»¥éšæ—¶ä»Žæ‰˜ç›˜èœå•é…置网关连接。
- 请确保您的远程网关正在运行且可以访问。
+ è¯·ç¡®ä¿æ‚¨çš„远程网关æ£åœ¨è¿è¡Œä¸”å¯ä»¥è®¿é—®ã€‚
- 配置码
+ é…ç½®ç
- 点击粘贴配置码
+ 点击粘贴é…ç½®ç
- 网关地址
+ 网关地å€
Token
- 粘贴引导 Token
+ 粘贴引导 Token
- 节点模式
+ 节点模å¼
- 测试连接
+ 测试连接
- 准备连接
+ 准备连接
- 您可以稍后从托盘菜单配置网关连接。
+ 您å¯ä»¥ç¨åŽä»Žæ‰˜ç›˜èœå•é…置网关连接。
- 配置码已解码
+ é…ç½®ç 已解ç
- Token 不能为空
+ Token ä¸èƒ½ä¸ºç©º
正在连接…
- 已连接到网关
+ 已连接到网关
- 设备需要审批
+ 设备需è¦å®¡æ‰¹
Token 不匹配 — 请检查您的引导 Token
@@ -1178,124 +1178,124 @@
连接超时 (15s) — 请确认网关正在运行
- 授予权限
+ 授予æƒé™
- 当 OpenClaw 可以发送通知、访问摄像头和麦克风、捕获屏幕并了解你的位置时效果最佳。请在下方授予权限。
+ 当 OpenClaw å¯ä»¥å‘é€é€šçŸ¥ã€è®¿é—®æ‘„åƒå¤´å’Œéº¦å…‹é£Žã€æ•获å±å¹•å¹¶äº†è§£ä½ çš„ä½ç½®æ—¶æ•ˆæžœæœ€ä½³ã€‚请在下方授予æƒé™ã€‚
- 刷新状态
+ 刷新状æ€
- 打开设置
+ 打开设置
正在检查权限…
- 认识您的代理
+ 认识您的代ç†
正在加载聊天…
- 配置网关
+ é…置网关
- 继续
+ ç»§ç»
- 跳过
+ 跳过
- 网关配置完成!
+ 网关é…置完æˆï¼
- 网关向导不可用
+ 网关å‘导ä¸å¯ç”¨
- 网关提供动态配置步骤,将在连接建立后执行。
+ 网关æä¾›åЍæ€é…ç½®æ¥éª¤ï¼Œå°†åœ¨è¿žæŽ¥å»ºç«‹åŽæ‰§è¡Œã€‚
- 通知
+ 通知
- 摄像头
+ æ‘„åƒå¤´
- 麦克风
+ 麦克风
- 屏幕截图
+ å±å¹•截图
- 位置(可选)
+ ä½ç½®ï¼ˆå¯é€‰ï¼‰
- 已启用
+ å·²å¯ç”¨
- 已启用(默认)
+ å·²å¯ç”¨ï¼ˆé»˜è®¤ï¼‰
- 已禁用
+ å·²ç¦ç”¨
- 在应用清单中已禁用
+ 在应用清å•ä¸å·²ç¦ç”¨
- 被策略禁用
+ 被ç–ç•¥ç¦ç”¨
已禁用 — 打开设置以启用
- 已允许
+ å·²å…许
被拒绝 — 打开设置以允许
- 被系统策略拒绝
+ 被系统ç–略拒ç»
未确定 — 打开设置
- 无法检查
+ æ— æ³•æ£€æŸ¥
- 未检测到相机
+ 未检测到相机
- 未检测到麦克风
+ 未检测到麦克风
可用 — 每次截图使用选择器
- 此设备不支持
+ æ¤è®¾å¤‡ä¸æ”¯æŒ
- 位置服务已在系统范围内禁用
+ ä½ç½®æœåŠ¡å·²åœ¨ç³»ç»ŸèŒƒå›´å†…ç¦ç”¨
- 此用户的位置已禁用
+ æ¤ç”¨æˆ·çš„ä½ç½®å·²ç¦ç”¨
- 位置服务已启用
+ ä½ç½®æœåС已å¯ç”¨
- 从系统托盘访问
+ 从系统托盘访问
设置 → 频道
- 用语音唤醒
+ 用è¯éŸ³å”¤é†’
- 视觉工作区
+ 视觉工作区
设置 → 技能
@@ -1307,52 +1307,52 @@
速率限制 — 请稍等后重试
- 在您的网关上运行:
+ 在您的网关上è¿è¡Œï¼š
- 点击复制
+ 点击å¤åˆ¶
复制失败 — 点击重试
- 已复制!
+ å·²å¤åˆ¶ï¼
连接失败 — 请检查 URL 和令牌,然后重试
- 处理此步骤时出错
+ å¤„ç†æ¤æ¥éª¤æ—¶å‡ºé”™
- 正在检测网关...
+ æ£åœ¨æ£€æµ‹ç½‘å…³...
- 已在开发端口检测到网关
+ 已在开å‘ç«¯å£æ£€æµ‹åˆ°ç½‘å…³
- 正在验证...
+ æ£åœ¨éªŒè¯...
- WSL 网关
+ WSL 网关
SSH 隧道
- 粘贴
+ 粘贴
QR
- 导入二维码图像
+ 导入二维ç 图åƒ
- 无法从此图像解码安装代码
+ æ— æ³•ä»Žæ¤å›¾åƒè§£ç 安装代ç
- OpenClaw 将创建托管 SSH 隧道,把远程主机上的网关端口转发到此电脑。
+ OpenClaw 将创建托管 SSH éš§é“,把远程主机上的网关端å£è½¬å‘到æ¤ç”µè„‘。
SSH 用户
@@ -1361,28 +1361,28 @@
SSH 主机
- 远程网关端口
+ 远程网关端å£
- 本地转发端口
+ 本地转å‘端å£
- 托管隧道:
+ 托管隧é“:
- 请在连接前输入有效的 SSH 用户。
+ 请在连接å‰è¾“入有效的 SSH 用户。
- 请在连接前输入有效的 SSH 主机(例如 mac-studio.local)。
+ 请在连接å‰è¾“入有效的 SSH 主机(例如 mac-studio.local)。
- 已检测:{0}
+ 已检测:{0}
您可以稍后在“设置”中配置网关。
- 设置 OpenClaw
+ 设置 OpenClaw
OpenClaw 允许代理在此电脑上运行命令、读写文件以及捕获屏幕截图。仅在您信任的计算机上进行设置。
@@ -1390,64 +1390,64 @@
⚠️ 本地设置将安装一个专用于 OpenClaw 的小型 WSL Linux 实例。如果您希望连接到现有或远程网关,请选择“高级设置”。
- 本地设置
+ 本地设置
- 高级设置
+ 高级设置
⚠️ 替换现有配置?
- 继续操作将断开与当前网关的连接并丢失所有设置。
+ ç»§ç»æ“作将æ–开与当å‰ç½‘关的连接并丢失所有设置。
- 替换我的设置
+ æ›¿æ¢æˆ‘的设置
- 保留我的设置
+ ä¿ç•™æˆ‘的设置
- 正在本地设置
+ æ£åœ¨æœ¬åœ°è®¾ç½®
- 正在设置您的本地 OpenClaw 网关。
+ æ£åœ¨è®¾ç½®æ‚¨çš„æœ¬åœ° OpenClaw 网关。
- 本地网关已就绪。
+ 本地网关已就绪。
- 重试
+ é‡è¯•
- 设置未完成。
+ 设置未完æˆã€‚
- 诊断:aka.ms/wsllogs
+ 诊æ–:aka.ms/wsllogs
- 正在检查系统
+ æ£åœ¨æ£€æŸ¥ç³»ç»Ÿ
- 正在安装 Ubuntu
+ æ£åœ¨å®‰è£… Ubuntu
- 正在配置实例
+ æ£åœ¨é…置实例
- 正在安装 OpenClaw
+ æ£åœ¨å®‰è£… OpenClaw
- 正在准备网关
+ æ£åœ¨å‡†å¤‡ç½‘å…³
- 正在启动网关
+ æ£åœ¨å¯åŠ¨ç½‘å…³
- 正在生成设置代码
+ æ£åœ¨ç”Ÿæˆè®¾ç½®ä»£ç
- 关于
+ 关于
OpenClaw Hub
@@ -1456,37 +1456,37 @@
.NET 10 / WinUI 3 / WinAppSDK 1.8
- 网关信息
+ 网关信æ¯
- 版本:
+ 版本:
- 模型:
+ 模型:
- 身份验证模式:
+ èº«ä»½éªŒè¯æ¨¡å¼ï¼š
- 运行时间:
+ è¿è¡Œæ—¶é—´ï¼š
- 调试
+ 调试
- 打开日志文件
+ 打开日志文件
- 打开配置文件夹
+ 打开é…置文件夹
- 复制支持上下文
+ å¤åˆ¶æ”¯æŒä¸Šä¸‹æ–‡
- 检查更新
+ 检查更新
- 链接
+ 链接
Documentation → openclaw.ai/docs
@@ -1498,172 +1498,175 @@
Dashboard → openclaw://dashboard
- 代理事件
+ 代ç†äº‹ä»¶
- 代理
+ 代ç†
- 所有代理
+ 所有代ç†
代理活动的实时流——工具调用、响应、错误和生命周期事件。
- 所有代理
+ 所有代ç†
- 工具
+ 工具
- 助手
+ 助手
- 错误
+ 错误
- 生命周期
+ 生命周期
- 计划
+ 计划
- 批准
+ 批准
- 思考中
+ æ€è€ƒä¸
- 补丁
+ è¡¥ä¸
- 还没有代理事件
+ 还没有代ç†äº‹ä»¶
- 代理运行时,实时代理事件(工具调用、响应、错误)将显示在此处。
+ 代ç†è¿è¡Œæ—¶ï¼Œå®žæ—¶ä»£ç†äº‹ä»¶ï¼ˆå·¥å…·è°ƒç”¨ã€å“应ã€é”™è¯¯ï¼‰å°†æ˜¾ç¤ºåœ¨æ¤å¤„。
- 清除
+ 清除
+
+
+ 清除
- 绑定
+ 绑定
- 消息路由规则
+ 消æ¯è·¯ç”±è§„则
- 刷新
+ 刷新
🖥️ 此计算机
- 通过网关提供给代理的设备功能。切换此计算机可以执行的操作。
+ 通过网关æä¾›ç»™ä»£ç†çš„è®¾å¤‡åŠŸèƒ½ã€‚åˆ‡æ¢æ¤è®¡ç®—机å¯ä»¥æ‰§è¡Œçš„æ“ä½œã€‚
- 本地 MCP 服务器
+ 本地 MCP æœåС噍
- 通过 HTTP 向 CLI 工具和本地集成公开功能。
+ 通过 HTTP å‘ CLI 工具和本地集æˆå…¬å¼€åŠŸèƒ½ã€‚
- 终结点:
+ 终结点:
- 复制令牌
+ å¤åˆ¶ä»¤ç‰Œ
- 复制 URL
+ å¤åˆ¶ URL
- 节点状态
+ 节点状æ€
- 节点模式已禁用
+ 节点模å¼å·²ç¦ç”¨
📡 频道
- 未连接
+ 未连接
- 未配置频道
+ 未é…置频é“
- 连接到网关以开始聊天
+ 连接到网关以开始èŠå¤©
等待聊天启动…
- 网关已连接;聊天界面仍在上线中。
+ 网关已连接;èŠå¤©ç•Œé¢ä»åœ¨ä¸Šçº¿ä¸ã€‚
⚙️ 配置
- 刷新
+ 刷新
- 编辑器
+ 编辑器
- 选择配置部分
+ 选择é…置部分
- 原始 JSON
+ 原始 JSON
- 架构不可用
+ æž¶æž„ä¸å¯ç”¨
- 无法加载网关配置架构。请使用 Web 仪表板编辑设置。
+ æ— æ³•åŠ è½½ç½‘å…³é…置架构。请使用 Web 仪表æ¿ç¼–辑设置。
- 打开仪表板
+ 打开仪表æ¿
- 保存更改
+ ä¿å˜æ›´æ”¹
🔗 连接
- 操作员客户端和节点服务共同使用的网关终结点。
+ æ“作员客户端和节点æœåС共åŒä½¿ç”¨çš„网关终结点。
- 连接错误
+ 连接错误
- 已断开连接
+ å·²æ–开连接
- 重新连接
+ 釿–°è¿žæŽ¥
操作员:—
- 连接
+ 连接
📡 可用网关
- 扫描
+ 扫æ
- 此网关需要令牌才能连接。
+ æ¤ç½‘关需è¦ä»¤ç‰Œæ‰èƒ½è¿žæŽ¥ã€‚
- 取消
+ å–æ¶ˆ
- 连接
+ 连接
- 在网关主机上运行 'openclaw qr' 获取设置代码,或在网关配置中查找令牌。
+ 在网关主机上è¿è¡Œ 'openclaw qr' 获å–设置代ç ,或在网关é…ç½®ä¸æŸ¥æ‰¾ä»¤ç‰Œã€‚
未找到网关。单击“扫描”进行搜索。
@@ -1672,22 +1675,22 @@
🔑 设置代码
- 粘贴来自 openclaw qr 的设置代码
+ 粘贴æ¥è‡ª openclaw qr 的设置代ç
- 应用
+ 应用
- 高级连接设置
+ 高级连接设置
- 网关地址
+ 网关地å€
Token
- 测试
+ 测试
SSH 隧道
@@ -1705,58 +1708,58 @@
machine-name
- 远程端口
+ 远程端å£
- 本地端口
+ 本地端å£
- 保存
+ ä¿å˜
🪪 此设备
- 设备 ID:
+ 设备 ID:
- 复制
+ å¤åˆ¶
- 在网关主机上运行此命令以批准:
+ 在网关主机上è¿è¡Œæ¤å‘½ä»¤ä»¥æ‰¹å‡†ï¼š
- 复制
+ å¤åˆ¶
📋 连接日志
- 没有最近的连接事件。
+ 没有最近的连接事件。
- 对话
+ 对è¯
- 全部
+ 全部
- 按频道
+ 按频é“
- 按状态
+ 按状æ€
- 刷新
+ 刷新
⏱️ Cron 作业
- 计划程序状态
+ 计划程åºçжæ€
- 已启用
+ å·²å¯ç”¨
Store: ~/.openclaw/cron.db
@@ -1765,22 +1768,22 @@
下次唤醒:—
- 立即运行
+ ç«‹å³è¿è¡Œ
- 上次运行:
+ 上次è¿è¡Œï¼š
- 下次运行:
+ 下次è¿è¡Œï¼š
- 未配置 Cron 作业
+ 未é…ç½® Cron 作业
🐛 调试
- 日志查看器
+ 日志查看器
⟳ 刷新
@@ -1789,34 +1792,34 @@
📋 复制日志
- 正在加载...
+ æ£åœ¨åŠ è½½...
- 连接状态
+ 连接状æ€
- 操作员状态
+ æ“作员状æ€
- 网关地址
+ 网关地å€
- 节点模式
+ 节点模å¼
- 设备标识
+ è®¾å¤‡æ ‡è¯†
- 设备 ID
+ 设备 ID
📋 复制
- 公钥
+ 公钥
- 调试操作
+ 调试æ“作
📄 打开日志文件
@@ -1831,31 +1834,31 @@
📋 复制支持上下文
- 未连接到网关
+ 未连接到网关
- 扫描网关
+ 扫æç½‘å…³
- 打开仪表板
+ 打开仪表æ¿
- 打开聊天
+ 打开èŠå¤©
- 健康检查
+ å¥åº·æ£€æŸ¥
⟳ 刷新
- 重命名
+ é‡å‘½å
- 忘记
+ 忘记
- 更多选项
+ 更多选项
Version
@@ -1864,154 +1867,154 @@
Hardware
- 网络
+ 网络
- 已批准
+ 已批准
- 已连接
+ 已连接
- 上次见到
+ 上次è§åˆ°
- 功能
+ 功能
- 权限
+ æƒé™
PATH
- 命令 ({0})
+ 命令 ({0})
- (已禁用)
+ (已ç¦ç”¨ï¼‰
- 重命名节点
+ é‡å‘½å节点
- 为 {0} 选择新的显示名称。
+ 为 {0} 选择新的显示å称。
- 显示名称
+ 显示åç§°
- 重命名
+ é‡å‘½å
- 显示名称不能为空。
+ 显示åç§°ä¸èƒ½ä¸ºç©ºã€‚
- 重命名失败。
+ é‡å‘½å失败。
- 忘记节点?
+ 忘记节点?
- 这将从网关中删除下面节点的记录。节点需要重新配对才能再次连接。
+ 这将从网关ä¸åˆ 除下é¢èŠ‚ç‚¹çš„è®°å½•ã€‚èŠ‚ç‚¹éœ€è¦é‡æ–°é…对æ‰èƒ½å†æ¬¡è¿žæŽ¥ã€‚
- 针对该节点的活动操作员和聊天会话将立即失去访问权限。
+ 针对该节点的活动æ“作员和èŠå¤©ä¼šè¯å°†ç«‹å³å¤±åŽ»è®¿é—®æƒé™ã€‚
- 忘记节点
+ 忘记节点
无法忘记节点 — 网关不可达或权限被拒绝。
- 取消
+ å–æ¶ˆ
🔐 权限
- 执行策略
+ 执行ç–ç•¥
- 控制代理可在此节点上执行哪些命令。规则会匹配完整命令行。
+ 控制代ç†å¯åœ¨æ¤èŠ‚ç‚¹ä¸Šæ‰§è¡Œå“ªäº›å‘½ä»¤ã€‚è§„åˆ™ä¼šåŒ¹é…完整命令行。
- 默认操作
+ 默认æ“作
- 拒绝
+ æ‹’ç»
- 允许
+ å…许
- 询问
+ 询问
- 规则
+ 规则
- 命令模式(例如 echo *)
+ 命令模å¼ï¼ˆä¾‹å¦‚ echo *)
- 允许
+ å…许
- 拒绝
+ æ‹’ç»
- 添加规则
+ æ·»åŠ è§„åˆ™
- 节点允许列表
+ 节点å…许列表
网关允许连接节点执行的命令。此列表为只读 — 请通过“配置”页面编辑。
- 没有可用的允许列表数据。请先连接到网关。
+ 没有å¯ç”¨çš„å…许列表数æ®ã€‚请先连接到网关。
- 系统权限
+ 系统æƒé™
- 摄像头、麦克风和屏幕捕获的系统权限。这些由 Windows 隐私设置管理。
+ æ‘„åƒå¤´ã€éº¦å…‹é£Žå’Œå±å¹•æ•获的系统æƒé™ã€‚这些由 Windows éšç§è®¾ç½®ç®¡ç†ã€‚
📷 摄像头
- 未知
+ 未知
🎤 麦克风
- 未知
+ 未知
🖥️ 屏幕捕获
- 可用
+ å¯ç”¨
- 打开 Windows 隐私设置
+ 打开 Windows éšç§è®¾ç½®
💬 会话
- 重置
+ é‡ç½®
- 压缩
+ 压缩
- 删除
+ åˆ é™¤
- 无活跃会话
+ æ— æ´»è·ƒä¼šè¯
🤖 可用模型
@@ -2020,301 +2023,301 @@
⚙️ 设置
- 此计算机的本地应用首选项。
+ æ¤è®¡ç®—机的本地应用首选项。
- 启动
+ å¯åЍ
- 随 Windows 自动启动
+ éš Windows 自动å¯åЍ
全局热键(Ctrl+Alt+Shift+C → 快速发送)
- 通知
+ 通知
- 显示通知
+ 显示通知
- 提示音
+ æç¤ºéŸ³
- 默认
+ 默认
- 无
+ æ—
- 柔和
+ 柔和
- 显示以下类型通知:
+ 显示以下类型通知:
- 健康警报
+ å¥åº·è¦æŠ¥
- 紧急消息
+ 紧急消æ¯
- 提醒
+ æé†’
- 邮件摘要
+ 邮件摘è¦
- 日历事件
+ 日历事件
- 构建通知
+ 构建通知
- 股票提醒
+ 股票æé†’
- 常规信息
+ 常规信æ¯
- 发送测试通知
+ å‘逿µ‹è¯•通知
- 取消
+ å–æ¶ˆ
🧩 技能
- 所有代理
+ 所有代ç†
- 未安装技能
+ 未安装技能
浏览技能市场 →
- 总成本
+ æ€»æˆæœ¬
- 请求
+ 请求
- 令牌
+ 令牌
284.5K
- 提供程序
+ æä¾›ç¨‹åº
- 7 天
+ 7 天
- 30 天
+ 30 天
正在加载提供程序…
- 未配置任何提供程序
+ 未é…置任何æä¾›ç¨‹åº
- 提供程序明细
+ æä¾›ç¨‹åºæ˜Žç»†
- 每日成本
+ æ¯æ—¥æˆæœ¬
📂 工作区
- 刷新
+ 刷新
- 聊天
+ èŠå¤©
- 连接到网关以开始聊天
+ 连接到网关以开始èŠå¤©
OpenClaw Windows Companion
- 已断开连接
+ å·²æ–开连接
- 主页
+ 主页
- 聊天
+ èŠå¤©
- 网关
+ 网关
- 连接
+ 连接
- 会话
+ 会è¯
- 对话
+ 对è¯
- 代理事件
+ 代ç†äº‹ä»¶
- 技能
+ 技能
- 代理
+ 代ç†
main
- 频道
+ 频é“
- 实例
+ 实例
- 绑定
+ 绑定
- 配置
+ é…ç½®
- 用量
+ 用é‡
Cron
- 此计算机
+ æ¤è®¡ç®—机
- 功能
+ 功能
- 设置
+ 设置
- 权限
+ æƒé™
- 调试
+ 调试
- 信息
+ ä¿¡æ¯
- 未知页面
+ 未知页é¢
- 重试
+ é‡è¯•
🔌 节点模式已启用
- 此电脑将作为远程计算节点运行。网关可以在此计算机上调用屏幕捕获、摄像头和系统命令。
+ æ¤ç”µè„‘将作为远程计算节点è¿è¡Œã€‚网关å¯ä»¥åœ¨æ¤è®¡ç®—机上调用å±å¹•æ•èŽ·ã€æ‘„åƒå¤´å’Œç³»ç»Ÿå‘½ä»¤ã€‚
- 屏幕捕获
+ å±å¹•æ•获
- 远程屏幕访问
+ 远程å±å¹•访问
- 摄像头
+ æ‘„åƒå¤´
- 远程摄像头访问
+ 远程摄åƒå¤´è®¿é—®
- 系统命令
+ 系统命令
- 远程命令执行
+ 远程命令执行
- Canvas 渲染
+ Canvas 渲染
- 可视工作区输出
+ å¯è§†å·¥ä½œåŒºè¾“出
- 通知
+ 通知
- 系统通知
+ 系统通知
⏳ 正在处理…
- 网关返回空响应
+ 网关返回空å“应
- 网关已断开连接
+ 网关已æ–开连接
与网关的连接已丢失。单击“下一步”跳过向导,或等待重新连接。
- 网关对 wizard.next 返回了空响应
+ 网关对 wizard.next 返回了空å“应
- 没有可用选项
+ 没有å¯ç”¨é€‰é¡¹
- 是
+ 是
- 否 / 跳过
+ å¦ / 跳过
- 正在等待...
+ æ£åœ¨ç‰å¾…...
单击“下一步”继续。
- 向导错误
+ å‘导错误
- 跳过向导
+ 跳过å‘导
- 正在连接到网关...
+ æ£åœ¨è¿žæŽ¥åˆ°ç½‘å…³...
- 请等待连接建立...
+ 请ç‰å¾…连接建立...
- 路由优先级
+ 路由优先级
- 绑定控制哪个代理处理每个频道的消息。优先级:对等 > 帐户 > 频道 > 默认。
+ 绑定控制哪个代ç†å¤„ç†æ¯ä¸ªé¢‘é“的消æ¯ã€‚ä¼˜å…ˆçº§ï¼šå¯¹ç‰ > 叿ˆ· > é¢‘é“ > 默认。
- 单代理模式
+ å•ä»£ç†æ¨¡å¼
- 所有消息都会路由到默认代理。添加绑定以进行多代理路由。
+ 所有消æ¯éƒ½ä¼šè·¯ç”±åˆ°é»˜è®¤ä»£ç†ã€‚æ·»åŠ ç»‘å®šä»¥è¿›è¡Œå¤šä»£ç†è·¯ç”±ã€‚
- 正在编辑从已连接网关获取的网关配置 (openclaw.json)
+ æ£åœ¨ç¼–辑从已连接网关获å–的网关é…ç½® (openclaw.json)
- 从树中选择一个部分以编辑其设置。
+ ä»Žæ ‘ä¸é€‰æ‹©ä¸€ä¸ªéƒ¨åˆ†ä»¥ç¼–辑其设置。
- 网关令牌
+ 网关令牌
Token
@@ -2323,25 +2326,25 @@
在此处粘贴设置代码…
- 连接到网关以查看对话。
+ 连接到网关以查看对è¯ã€‚
- 未找到会话
+ 未找到会è¯
- Cron API 尚未接入
+ Cron API 尚未接入
- 正在显示示例数据。OpenClawGatewayClient 中尚不提供 Cron 管理方法。
+ æ£åœ¨æ˜¾ç¤ºç¤ºä¾‹æ•°æ®ã€‚OpenClawGatewayClient ä¸å°šä¸æä¾› Cron ç®¡ç†æ–¹æ³•。
- 工作区文件
+ 工作区文件
OpenClaw Chat
- 键入命令...
+ 键入命令...
OpenClaw
@@ -2356,10 +2359,10 @@
OpenClaw Canvas
- 重新加载
+ 釿–°åŠ è½½
- 重新加载 Canvas
+ 釿–°åŠ è½½ Canvas
OpenClaw Menu
@@ -2368,67 +2371,67 @@
📊 使用量和成本
- 在浏览器中打开
+ 在æµè§ˆå™¨ä¸æ‰“å¼€
- 关闭
+ å…³é—
- 在浏览器中打开
+ 在æµè§ˆå™¨ä¸æ‰“å¼€
- 删除任务
+ åˆ é™¤ä»»åŠ¡
更多语音设置…
- ElevenLabs API 密钥
+ ElevenLabs API 密钥
- API 密钥使用 Windows DPAPI 加密保存。修改其他字段时如保持为空,则保留先前保存的值。
+ API 密钥使用 Windows DPAPI åŠ å¯†ä¿å˜ã€‚ä¿®æ”¹å…¶ä»–å—æ®µæ—¶å¦‚ä¿æŒä¸ºç©ºï¼Œåˆ™ä¿ç•™å…ˆå‰ä¿å˜çš„值。
- ElevenLabs 模型
+ ElevenLabs 模型
eleven_multilingual_v2
- ElevenLabs 语音 ID
+ ElevenLabs è¯éŸ³ ID
- 提供程序
+ æä¾›ç¨‹åº
ElevenLabs
- Piper(本地 ML,推荐)
+ Piper(本地 ML,推è)
- Windows 内置语音
+ Windows 内置è¯éŸ³
🎙️ 语音和音频
- 配置语音转文字和语音交互设置。所有语音处理均在本机本地运行。
+ é…ç½®è¯éŸ³è½¬æ–‡å—å’Œè¯éŸ³äº¤äº’设置。所有è¯éŸ³å¤„ç†å‡åœ¨æœ¬æœºæœ¬åœ°è¿è¡Œã€‚
- 语音转文字
+ è¯éŸ³è½¬æ–‡å—
- 通过麦克风启用语音输入。需要下载 Whisper 模型。
+ 通过麦克风å¯ç”¨è¯éŸ³è¾“入。需è¦ä¸‹è½½ Whisper 模型。
- 启用语音输入
+ å¯ç”¨è¯éŸ³è¾“å…¥
- 语音模型
+ è¯éŸ³æ¨¡åž‹
- 模型大小
+ 模型大å°
Tiny (~75 MB) — 快速,基础精度
@@ -2440,103 +2443,103 @@
Small (~466 MB) — 高精度
- 下载模型
+ 下载模型
- 语言
+ è¯è¨€
- 自动检测
+ 自动检测
- 英语
+ 英è¯
- 西班牙语
+ 西ç牙è¯
- 法语
+ 法è¯
- 德语
+ å¾·è¯
- 日语
+ æ—¥è¯
- 中文
+ 䏿–‡
- 韩语
+ 韩è¯
- 葡萄牙语
+ è‘¡è„牙è¯
- 意大利语
+ æ„大利è¯
- 语音聊天
+ è¯éŸ³èŠå¤©
- 静音超时(秒)
+ é™éŸ³è¶…æ—¶(ç§’)
- 朗读响应
+ 朗读å“应
- 音频反馈声音
+ 音频å馈声音
🔊 Companion 语音
- 选择朗读响应时使用的语音。
+ 选择朗读å“应时使用的è¯éŸ³ã€‚
- 提供商
+ æä¾›å•†
- Piper(本地神经语音)
+ Piper(本地神ç»è¯éŸ³)
- Windows(内置神经语音)
+ Windows(内置神ç»è¯éŸ³)
- ElevenLabs(云端,需要 API 密钥)
+ ElevenLabs(云端,éœ€è¦ API 密钥)
- 语音
+ è¯éŸ³
- 下载语音
+ 下载è¯éŸ³
- 删除
+ åˆ é™¤
▶ 预览
- 语音从 sherpa-onnx 项目的 GitHub 发布版下载(低质量约 25 MB,高质量最高约 150 MB)。它们完全在本机运行;无音频离开您的设备。
+ è¯éŸ³ä»Ž sherpa-onnx 项目的 GitHub å‘布版下载(低质é‡çº¦ 25 MB,é«˜è´¨é‡æœ€é«˜çº¦ 150 MB)。它们完全在本机è¿è¡Œ;æ— éŸ³é¢‘ç¦»å¼€æ‚¨çš„è®¾å¤‡ã€‚
- 语音
+ è¯éŸ³
▶ 预览语音
- API 密钥
+ API 密钥
- 语音 ID
+ è¯éŸ³ ID
- 模型(可选)
+ 模型(å¯é€‰)
- 所有语音处理均完全在您的设备上运行。不会向任何云服务发送音频数据。
+ 所有è¯éŸ³å¤„ç†å‡å®Œå…¨åœ¨æ‚¨çš„设备上è¿è¡Œã€‚ä¸ä¼šå‘任何云æœåŠ¡å‘é€éŸ³é¢‘æ•°æ®ã€‚
✅ 模型就绪
@@ -2545,25 +2548,25 @@
⬇️ 需要下载
- 重新下载
+ 釿–°ä¸‹è½½
- 正在下载...
+ æ£åœ¨ä¸‹è½½...
- 正在下载... {0}%
+ æ£åœ¨ä¸‹è½½... {0}%
- 下载已取消
+ ä¸‹è½½å·²å–æ¶ˆ
❌ 操作失败(参见 Debug 日志)
- 已下载
+ 已下载
- 语音已在此 PC 上就绪({0} MB)。
+ è¯éŸ³å·²åœ¨æ¤ PC 上就绪({0} MB)。
语音尚未下载。点击下载以获取模型(根据质量约 25–150 MB)。
@@ -2584,52 +2587,52 @@
下载完成。正在提取…
- 下载已取消。
+ ä¸‹è½½å·²å–æ¶ˆã€‚
- 下载失败(参见 Debug 日志)。
+ 下载失败(å‚è§ Debug 日志)。
- 重试下载
+ é‡è¯•下载
- 已删除语音。
+ å·²åˆ é™¤è¯éŸ³ã€‚
- 删除失败(参见 Debug 日志)。
+ åˆ é™¤å¤±è´¥(å‚è§ Debug 日志)。
- 您好!这是您的 Companion 在说话。
+ 您好!这是您的 Companion 在说è¯ã€‚
- 预览失败(参见 Debug 日志)。
+ 预览失败(å‚è§ Debug 日志)。
- 加载语音时出错(参见 Debug 日志)。
+ åŠ è½½è¯éŸ³æ—¶å‡ºé”™(å‚è§ Debug 日志)。
▶ 正在播放...
- Companion 语音
+ Companion è¯éŸ³
- 就绪
+ 就绪
- 按开始并开始说话
+ 按开始并开始说è¯
- 按开始以开始
+ 按开始以开始
- 开始聆听
+ 开始è†å¬
- 静音
+ é™éŸ³
- 语音设置
+ è¯éŸ³è®¾ç½®
🗣️ 正在聆听...
@@ -2638,148 +2641,148 @@
现在请说话 — 我在聆听
- 正在初始化...
+ æ£åœ¨åˆå§‹åŒ–...
- 正在启动
+ æ£åœ¨å¯åЍ
- 正在下载语音模型...
+ æ£åœ¨ä¸‹è½½è¯éŸ³æ¨¡åž‹...
- 正在下载模型... {0}%
+ æ£åœ¨ä¸‹è½½æ¨¡åž‹... {0}%
- 正在加载语音模型...
+ æ£åœ¨åŠ è½½è¯éŸ³æ¨¡åž‹...
- 正在启动麦克风...
+ æ£åœ¨å¯åŠ¨éº¦å…‹é£Ž...
- 正在停止...
+ æ£åœ¨åœæ¢...
- 错误
+ 错误
- 遇到错误(参见 Debug 日志)
+ é‡åˆ°é”™è¯¯(å‚è§ Debug 日志)
- 已静音
+ å·²é™éŸ³
- 停止
+ åœæ¢
- 已停止
+ å·²åœæ¢
- 正在启动...
+ æ£åœ¨å¯åЍ...
- 正在聆听
+ æ£åœ¨è†å¬
- 正在处理...
+ æ£åœ¨å¤„ç†...
- 未知
+ 未知
- 正在初始化麦克风...
+ æ£åœ¨åˆå§‹åŒ–麦克风...
- 正在转录您的语音...
+ æ£åœ¨è½¬å½•您的è¯éŸ³...
- 发生错误
+ å‘生错误
Companion Voice
- 就绪
+ 就绪
- 按开始以开始
+ 按开始以开始
- 开始聆听
+ 开始è†å¬
- 下载模型
+ 下载模型
- 下载语音
+ 下载è¯éŸ³
▶ 预览语音
- 调试覆盖
+ 调试覆盖
按聊天容器覆盖全局的“使用标准网关聊天界面”设置。应用重启后重置。
- 中心聊天选项卡 UI:
+ ä¸å¿ƒèŠå¤©é€‰é¡¹å¡ UI:
- 托盘聊天弹窗 UI:
+ 托盘èŠå¤©å¼¹çª— UI:
- 不覆盖
+ ä¸è¦†ç›–
- 强制使用网关聊天 UI
+ 强制使用网关èŠå¤© UI
- 强制使用伴侣聊天 UI
+ 强制使用伴侣èŠå¤© UI
- 不覆盖
+ ä¸è¦†ç›–
- 强制使用网关聊天 UI
+ 强制使用网关èŠå¤© UI
- 强制使用伴侣聊天 UI
+ 强制使用伴侣èŠå¤© UI
- 欢迎使用 OpenClaw
+ 欢迎使用 OpenClaw
- 今天我能帮你做什么?
+ ä»Šå¤©æˆ‘èƒ½å¸®ä½ åšä»€ä¹ˆï¼Ÿ
- 完成
+ 完æˆ
- 运行中
+ è¿è¡Œä¸
- 错误
+ 错误
- 已中断
+ 已䏿–
- 加载更早的消息
+ åŠ è½½æ›´æ—©çš„æ¶ˆæ¯
- 工具调用
+ 工具调用
- 工具输出
+ 工具输出
- 工具错误
+ 工具错误
- 工具输入
+ 工具输入
- 工具
+ 工具
工具 · {0}
@@ -2794,91 +2797,91 @@
{0} 正在思考…
- 默认
+ 默认
- 自动
+ 自动
- 最大
+ 最大
- 给助手发送消息(按 Enter 发送)
+ 给助手å‘逿¶ˆæ¯ï¼ˆæŒ‰ Enter å‘é€ï¼‰
连接中…
- 未连接
+ 未连接
Gateway update required — incompatible version
- 附加
+ 附åŠ
- 语音
+ è¯éŸ³
正在聆听…
- 取消
+ å–æ¶ˆ
- 更多
+ 更多
- 发送
+ å‘é€
- 停止
+ åœæ¢
助手正在工作…
- 复制消息
+ å¤åˆ¶æ¶ˆæ¯
- 朗读
+ 朗读
- 删除消息
+ åˆ é™¤æ¶ˆæ¯
… [已截断 {0} 字节]
- 允许
+ å…许
- 拒绝
+ æ‹’ç»
正在连接到网关…
- 在侧栏中选择一个会话以开始聊天。
+ 在侧æ ä¸é€‰æ‹©ä¸€ä¸ªä¼šè¯ä»¥å¼€å§‹èŠå¤©ã€‚
- 发送失败
+ å‘é€å¤±è´¥
- 中止失败
+ 䏿¢å¤±è´¥
- 加载历史失败
+ åŠ è½½åŽ†å²å¤±è´¥
- 助手已回复
+ 助手已回å¤
连接已断开 — 回复已中断。
- 测试语音输入
+ 测试è¯éŸ³è¾“å…¥
Toggle individual features that agents may request from this PC.
@@ -3064,7 +3067,7 @@
Resync
- 返回连接
+ 返回连接
🔗 Pending Operator/Node Pairing
@@ -3112,16 +3115,16 @@
Copy node ID
- {0}秒前
+ {0}ç§’å‰
- {0}分钟前
+ {0}分钟å‰
- {0}小时前
+ {0}å°æ—¶å‰
- {0}天前
+ {0}天å‰
OpenClaw Setup
@@ -3286,55 +3289,55 @@
Keep my setup
- 全部
+ 全部
- 频道
+ 频é“
- 刷新
+ 刷新
- 已配置
+ å·²é…ç½®
- 可用
+ å¯ç”¨
- 主页
+ 主页
- 刷新
+ 刷新
- 开发人员工具
+ å¼€å‘人员工具
- 随 Windows 启动
+ éš Windows å¯åЍ
- 快速发送热键
+ 快速å‘é€çƒé”®
- 使用网关的 Web 聊天界面
+ 使用网关的 Web èŠå¤©ç•Œé¢
- 显示通知
+ 显示通知
- 提示音
+ æç¤ºéŸ³
- 允许屏幕录制
+ å…许å±å¹•录制
- 允许摄像头录制
+ å…许摄åƒå¤´å½•制
- 语音和音频
+ è¯éŸ³å’ŒéŸ³é¢‘
- 沙盒
+ 沙盒
More diagnostics →
@@ -3514,34 +3517,34 @@
Connection Status
- 网关已断开
+ 网关已æ–å¼€
- 请连接到网关以加载绑定。
+ è¯·è¿žæŽ¥åˆ°ç½‘å…³ä»¥åŠ è½½ç»‘å®šã€‚
- 打开连接
+ 打开连接
正在加载绑定…
- 等待您的审批
+ ç‰å¾…您的审批
- 未连接
+ 未连接
- 连接
+ 连接
- 操作员
+ æ“作员
- 从此电脑向此网关发送命令并查看会话。
+ 从æ¤ç”µè„‘呿¤ç½‘å…³å‘é€å‘½ä»¤å¹¶æŸ¥çœ‹ä¼šè¯ã€‚
- 未激活
+ 未激活
查看会话 ›
@@ -3550,232 +3553,232 @@
查看实例 ›
- 节点模式
+ 节点模å¼
- 启用后,此电脑将注册为节点,并通过网关向代理提供下列功能。
+ å¯ç”¨åŽ,æ¤ç”µè„‘将注册为节点,并通过网关å‘ä»£ç†æä¾›ä¸‹åˆ—åŠŸèƒ½ã€‚
- 节点模式已禁用
+ 节点模å¼å·²ç¦ç”¨
- 复制
+ å¤åˆ¶
- 连接
+ 连接
管理权限 ›
- 帮助我们修复此连接:
+ å¸®åŠ©æˆ‘ä»¬ä¿®å¤æ¤è¿žæŽ¥:
- SSH 隧道已停止。
+ SSH éš§é“å·²åœæ¢ã€‚
- 重启隧道
+ é‡å¯éš§é“
- 编辑隧道设置
+ 编辑隧é“设置
- 粘贴来自网关主机的新设置代码。
+ 粘贴æ¥è‡ªç½‘关主机的新设置代ç 。
在此粘贴设置代码…
- 应用
+ 应用
- 在网关主机上运行:
+ 在网关主机上è¿è¡Œ:
- 复制
+ å¤åˆ¶
- 连接
+ 连接
- 断开连接
+ æ–开连接
- 已保存的网关
+ å·²ä¿å˜çš„网关
- 网关
+ 网关
- 扫描
+ 扫æ
- 添加网关
+ æ·»åŠ ç½‘å…³
- 尚无已保存的网关。
+ å°šæ— å·²ä¿å˜çš„网关。
- 在您的网络上发现
+ 在您的网络上å‘现
- 添加网关
+ æ·»åŠ ç½‘å…³
开始使用 — 安装本地网关
- 最快的入门方式:在此电脑上设置 WSL 托管的网关,并将其设为活动连接。
+ 最快的入门方å¼:在æ¤ç”µè„‘上设置 WSL 托管的网关,并将其设为活动连接。
- 安装
+ 安装
- 或连接到现有网关:
+ 或连接到现有网关:
- 直接
+ 直接
- URL + 令牌
+ URL + 令牌
- 设置代码
+ 设置代ç
- 粘贴 QR 负载
+ 粘贴 QR 负载
- 每种方法都支持可选的 SSH 隧道。
+ æ¯ç§æ–¹æ³•都支æŒå¯é€‰çš„ SSH éš§é“。
- 或在您的网络上查找。
+ 或在您的网络上查找。
- 扫描
+ 扫æ
- 在您的网络上发现
+ 在您的网络上å‘现
- 返回
+ 返回
- 添加网关
+ æ·»åŠ ç½‘å…³
- 直接
+ 直接
- 设置代码
+ 设置代ç
- 本地 WSL
+ 本地 WSL
- 网关 URL
+ 网关 URL
ws://host.local:18790
- 共享令牌
+ 共享令牌
- 粘贴共享网关令牌
+ 粘贴共享网关令牌
- 名称(可选)
+ åç§°(å¯é€‰)
- 我的网关
+ 我的网关
- 粘贴来自网关的 QR 码或 `openclaw qr` 输出的设置代码。
+ 粘贴æ¥è‡ªç½‘关的 QR ç æˆ– `openclaw qr` 输出的设置代ç 。
在此粘贴设置代码…
- 解码
+ è§£ç
- 在此电脑上安装新的 WSL 网关,并将其设为活动连接。设置向导将在需要时安装 WSL、配置网关,并自动配对此设备。
+ 在æ¤ç”µè„‘上安装新的 WSL 网关,并将其设为活动连接。设置å‘å¯¼å°†åœ¨éœ€è¦æ—¶å®‰è£… WSLã€é…置网关,并自动é…对æ¤è®¾å¤‡ã€‚
- 安装本地网关
+ 安装本地网关
此电脑无法进行本地 WSL 设置 — 安装向导无法初始化。
- 使用 SSH 隧道(可选)
+ 使用 SSH éš§é“(å¯é€‰)
- SSH 用户
+ SSH 用户
user
- SSH 主机
+ SSH 主机
machine-name
- 远程端口
+ 远程端å£
- 本地端口
+ 本地端å£
- 保存并连接
+ ä¿å˜å¹¶è¿žæŽ¥
- 取消
+ å–æ¶ˆ
⏱️ Cron 任务
- 刷新
+ 刷新
- 新建任务
+ 新建任务
- 网关已断开
+ 网关已æ–å¼€
- 请连接到网关以加载 cron 任务。
+ è¯·è¿žæŽ¥åˆ°ç½‘å…³ä»¥åŠ è½½ cron 任务。
- 打开连接
+ 打开连接
- 新建 Cron 任务
+ 新建 Cron 任务
- 名称
+ åç§°
- 晨间简报
+ 晨间简报
- 计划
+ 计划
- 每
+ æ¯
- 于
+ 于
Cron
- 快速预设
+ 快速预设
⏰ 每小时
@@ -3793,13 +3796,13 @@
📅 每周
- 表达式
+ 表达å¼
- 时区
+ 时区
- 可选
+ å¯é€‰
America/New_York
@@ -3826,220 +3829,220 @@
UTC
- 每
+ æ¯
- 单位
+ å•ä½
- 分钟
+ 分钟
- 小时
+ å°æ—¶
- 天
+ 天
- 运行时间
+ è¿è¡Œæ—¶é—´
- 日期
+ 日期
15:30
- 运行后删除任务
+ è¿è¡ŒåŽåˆ 除任务
- 提示 / 负载
+ æç¤º / è´Ÿè½½
- 代理应该做什么?
+ 代ç†åº”该åšä»€ä¹ˆ?
- 投递
+ 投递
- 静默(无)
+ é™é»˜(æ— )
- 通知我(通告)
+ 通知我(通告)
- 通道
+ 通é“
- 例如 webchat
+ 例如 webchat
- 高级选项
+ 高级选项
- 会话行为
+ 会è¯è¡Œä¸º
- 每次运行新建会话
+ æ¯æ¬¡è¿è¡Œæ–°å»ºä¼šè¯
- 恢复主会话
+ æ¢å¤ä¸»ä¼šè¯
- 唤醒模式
+ 唤醒模å¼
- 立即(即时)
+ ç«‹å³(峿—¶)
- 排队(等待空闲)
+ 排队(ç‰å¾…空闲)
- 取消
+ å–æ¶ˆ
- 创建任务
+ 创建任务
正在加载 cron 任务…
- 未配置 cron 任务
+ 未é…ç½® cron 任务
- 通过网关配置 cron 任务后,它们将显示在此处。
+ 通过网关é…ç½® cron 任务åŽ,它们将显示在æ¤å¤„。
- 状态
+ 状æ€
- 已复制
+ å·²å¤åˆ¶
- 无覆盖
+ æ— è¦†ç›–
- 强制使用网关聊天界面
+ 强制使用网关èŠå¤©ç•Œé¢
- 强制使用伴侣聊天界面
+ 强制使用伴侣èŠå¤©ç•Œé¢
- 无覆盖
+ æ— è¦†ç›–
- 强制使用网关聊天界面
+ 强制使用网关èŠå¤©ç•Œé¢
- 强制使用伴侣聊天界面
+ 强制使用伴侣èŠå¤©ç•Œé¢
- 刷新
+ 刷新
- 复制
+ å¤åˆ¶
- 打开文件
+ 打开文件
- 需要配对审批
+ 需è¦é…对审批
- 节点已连接,但在您批准配对请求之前,其功能不会激活。
+ 节点已连接,但在您批准é…对请求之å‰,其功能ä¸ä¼šæ¿€æ´»ã€‚
在“连接”页面查看
- 返回连接
+ 返回连接
- 权限
+ æƒé™
- 节点模式
+ 节点模å¼
- 启用后,此电脑将注册为节点,并通过网关向代理提供下列功能。
+ å¯ç”¨åŽ,æ¤ç”µè„‘将注册为节点,并通过网关å‘ä»£ç†æä¾›ä¸‹åˆ—åŠŸèƒ½ã€‚
- 功能
+ 功能
- 切换代理可能从此电脑请求的各项功能。
+ 切æ¢ä»£ç†å¯èƒ½ä»Žæ¤ç”µè„‘请求的å„项功能。
- 集成
+ 集æˆ
- 默认操作
+ 默认æ“作
- 当命令与下列任何规则都不匹配时发生什么。
+ 当命令与下列任何规则都ä¸åŒ¹é…æ—¶å‘生什么。
- 模式按从左到右的顺序与完整命令行匹配。使用 * 作为通配符。更改将自动保存。
+ æ¨¡å¼æŒ‰ä»Žå·¦åˆ°å³çš„顺åºä¸Žå®Œæ•´å‘½ä»¤è¡ŒåŒ¹é…。使用 * 作为通é…符。更改将自动ä¿å˜ã€‚
- 已保存
+ å·²ä¿å˜
- 0 条规则
+ 0 æ¡è§„则
- 尚无规则。在下方添加模式以允许或拒绝特定命令。
+ å°šæ— è§„åˆ™ã€‚åœ¨ä¸‹æ–¹æ·»åŠ æ¨¡å¼ä»¥å…许或拒ç»ç‰¹å®šå‘½ä»¤ã€‚
- Windows 隐私
+ Windows éšç§
- 摄像头、麦克风和屏幕访问
+ æ‘„åƒå¤´ã€éº¦å…‹é£Žå’Œå±å¹•访问
- 系统级隐私权限由 Windows 管理。打开 Windows 隐私设置以授予或撤销 OpenClaw 访问权限。
+ 系统级éšç§æƒé™ç”± Windows 管ç†ã€‚打开 Windows éšç§è®¾ç½®ä»¥æŽˆäºˆæˆ–撤销 OpenClaw 访问æƒé™ã€‚
- 节点沙盒已开启
+ 节点沙盒已开å¯
- 代理在此电脑上运行的程序受到隔离。
+ 代ç†åœ¨æ¤ç”µè„‘上è¿è¡Œçš„程åºå—到隔离。
- 哪些内容受隔离
+ 哪些内容å—隔离
查看此页面涵盖的命令 — 以及需要其他控件的命令。
- 代理通过 Windows 节点运行的命令 (
+ 代ç†é€šè¿‡ Windows 节点è¿è¡Œçš„命令 (
system.run
- ) 受下面的设置隔离。
+ ) å—下é¢çš„设置隔离。
代理直接在网关上运行的命令不受隔离。如果网关位于此电脑的 WSL 上,它们可能会访问您的 Windows 文件、剪贴板和网络 — 请使用网关的执行审批。
- 沙盒不可用
+ 沙盒ä¸å¯ç”¨
- 详细了解 MXC 沙盒
+ 详细了解 MXC 沙盒
- 安全级别
+ 安全级别
- 一键设置下面的每个控件。自定义文件夹保持您的设置。
+ 一键设置下é¢çš„æ¯ä¸ªæŽ§ä»¶ã€‚è‡ªå®šä¹‰æ–‡ä»¶å¤¹ä¿æŒæ‚¨çš„设置。
🔒 锁定
- 无互联网、无剪贴板、无标准用户文件夹。
+ æ— äº’è”ç½‘ã€æ— 剪贴æ¿ã€æ— æ ‡å‡†ç”¨æˆ·æ–‡ä»¶å¤¹ã€‚
🛡 推荐
@@ -4054,82 +4057,82 @@
互联网开启。对“文档”、“下载”、“桌面”可读写。剪贴板可读写。
- 自定义文件夹授权仍然有效
+ 自定义文件夹授æƒä»ç„¶æœ‰æ•ˆ
📁 文件
- 默认情况下,代理只有一个可读写的临时工作区文件夹。在下方授予对用户文件夹的访问权限。SSH 密钥、浏览器配置文件和 OpenClaw 自身的设置始终被阻止。
+ 默认情况下,代ç†åªæœ‰ä¸€ä¸ªå¯è¯»å†™çš„临时工作区文件夹。在下方授予对用户文件夹的访问æƒé™ã€‚SSH å¯†é’¥ã€æµè§ˆå™¨é…置文件和 OpenClaw 自身的设置始终被阻æ¢ã€‚
- 文档
+ 文档
- 已阻止
+ 已阻æ¢
- 只读
+ åªè¯»
- 读取与写入
+ 读å–与写入
- 下载
+ 下载
- 已阻止
+ 已阻æ¢
- 只读
+ åªè¯»
- 读取与写入
+ 读å–与写入
- 桌面
+ 桌é¢
- 已阻止
+ 已阻æ¢
- 只读
+ åªè¯»
- 读取与写入
+ 读å–与写入
- PATH 上的工具目录在沙盒内也以只读方式授予,因此 git、node、python 和 dotnet 等命令行工具可用。从不授予驱动器根目录。
+ PATH 上的工具目录在沙盒内也以åªè¯»æ–¹å¼æŽˆäºˆ,å› æ¤ gitã€nodeã€python å’Œ dotnet ç‰å‘½ä»¤è¡Œå·¥å…·å¯ç”¨ã€‚ä»Žä¸æŽˆäºˆé©±åŠ¨å™¨æ ¹ç›®å½•ã€‚
- 自定义文件夹
+ 自定义文件夹
自定义授权将在上述规则之上添加。在“文档/下载/桌面”内添加具有更宽访问权限的文件夹,将覆盖该路径的行级设置。
- 已阻止
+ 已阻æ¢
- 只读
+ åªè¯»
- 读取与写入
+ 读å–与写入
- 尚未添加自定义文件夹。
+ å°šæœªæ·»åŠ è‡ªå®šä¹‰æ–‡ä»¶å¤¹ã€‚
- 添加文件夹
+ æ·»åŠ æ–‡ä»¶å¤¹
📋 剪贴板
- 剪贴板通常包含密码、令牌和私人文本。读取权限可能将这些泄露给代理(若启用,也会泄露到互联网)。
+ 剪贴æ¿é€šå¸¸åŒ…å«å¯†ç ã€ä»¤ç‰Œå’Œç§äººæ–‡æœ¬ã€‚è¯»å–æƒé™å¯èƒ½å°†è¿™äº›æ³„露给代ç†(è‹¥å¯ç”¨,也会泄露到互è”网)。
- 无(默认)
+ æ— (默认)
读取 — 代理可以看到您复制的内容
@@ -4138,34 +4141,34 @@
写入 — 代理可以替换您的剪贴板(无法读取)
- 读取与写入
+ 读å–与写入
📡 网络
- 允许互联网
+ å…许互è”网
- 如果启用文件或剪贴板读取权限,代理可能会通过互联网发送这些数据。
+ 如果å¯ç”¨æ–‡ä»¶æˆ–剪贴æ¿è¯»å–æƒé™,代ç†å¯èƒ½ä¼šé€šè¿‡äº’è”网å‘é€è¿™äº›æ•°æ®ã€‚
- 本地网络设备和共享尚未包含在内。
+ 本地网络设备和共享尚未包å«åœ¨å†…。
⏱ 限制
- 命令超时:30 秒
+ 命令超时:30 秒
- 最大捕获输出(超过此值将被截断;不限制磁盘、内存或 CPU):
+ 最大æ•获输出(超过æ¤å€¼å°†è¢«æˆªæ–;ä¸é™åˆ¶ç£ç›˜ã€å†…å˜æˆ– CPU):
1 MiB
- 4 MiB(默认)
+ 4 MiB(默认)
16 MiB
@@ -4174,190 +4177,190 @@
64 MiB
- 返回连接
+ 返回连接
- 会话
+ 会è¯
- 刷新
+ 刷新
- 全部
+ 全部
- 网关已断开
+ 网关已æ–å¼€
- 请连接到网关以加载会话。
+ è¯·è¿žæŽ¥åˆ°ç½‘å…³ä»¥åŠ è½½ä¼šè¯ã€‚
- 打开连接
+ 打开连接
正在加载会话…
- 无活动会话
+ æ— æ´»åŠ¨ä¼šè¯
- 重置
+ é‡ç½®
- 压缩
+ 压缩
- 删除
+ åˆ é™¤
- 设置
+ 设置
- 常规
+ 常规
- 通知
+ 通知
- 隐私
+ éšç§
- 本地网关
+ 本地网关
- 检测到 MSIX 安装
+ 检测到 MSIX 安装
通过 Windows 设置 &#x2192; 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。
- 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。
+ åˆ é™¤ WSL å‘行版 (OpenClawGateway)ã€å…¶ç£ç›˜æ˜ åƒã€è‡ªåЍå¯åЍ项,å¹¶æ¸…é™¤ç½‘å…³å‡æ®ã€‚您的 MCP 令牌将被ä¿ç•™ã€‚引导将é‡ç½®ã€‚
- 删除本地网关
+ åˆ é™¤æœ¬åœ°ç½‘å…³
- 已保存
+ å·²ä¿å˜
🧩 技能
- 所有代理
+ 所有代ç†
- 已启用
+ å·²å¯ç”¨
- 已禁用
+ å·²ç¦ç”¨
正在加载技能…
- 未安装技能
+ 未安装技能
- 技能扩展代理的能力。通过 CLI 或 ClawHub 安装技能。
+ 技能扩展代ç†çš„能力。通过 CLI 或 ClawHub 安装技能。
- 网关已断开
+ 网关已æ–å¼€
- 请连接到网关以加载用量数据。
+ è¯·è¿žæŽ¥åˆ°ç½‘å…³ä»¥åŠ è½½ç”¨é‡æ•°æ®ã€‚
- 打开连接
+ 打开连接
正在加载每日费用…
- 此期间内无每日用量
+ æ¤æœŸé—´å†…æ— æ¯æ—¥ç”¨é‡
- 测试语音输入
+ 测试è¯éŸ³è¾“å…¥
- 录制
+ 录制
正在加载工作区文件…
- 网页聊天不可用
+ 网页èŠå¤©ä¸å¯ç”¨
- 重试
+ é‡è¯•
- 连接状态
+ 连接状æ€
- 状态机
+ çŠ¶æ€æœº
- 操作员
+ æ“作员
- 关闭
+ å…³é—
- 正在连接
+ æ£åœ¨è¿žæŽ¥
- 已连接
+ 已连接
- 正在配对
+ æ£åœ¨é…对
- 错误
+ 错误
- 节点
+ 节点
- 关闭
+ å…³é—
- 正在连接
+ æ£åœ¨è¿žæŽ¥
- 已连接
+ 已连接
- 正在配对
+ æ£åœ¨é…对
- 错误
+ 错误
- 网关
+ 网关
- 凭据
+ 凿®
- 设置代码
+ 设置代ç
- 粘贴设置代码以建立端到端连接。
+ 粘贴设置代ç 以建立端到端连接。
粘贴设置代码…
- 连接
+ 连接
- 断开连接
+ æ–开连接
- 直接连接
+ 直接连接
- 使用网关 URL 和共享令牌(管理员范围)连接。
+ 使用网关 URL 和共享令牌(管ç†å‘˜èŒƒå›´)连接。
ws://localhost:18790
@@ -4366,66 +4369,66 @@
ws://localhost:18790
- 网关 URL
+ 网关 URL
- 共享网关令牌
+ 共享网关令牌
- 令牌
+ 令牌
- 连接
+ 连接
- SSH 隧道
+ SSH éš§é“
- SSH 用户
+ SSH 用户
user
- SSH 主机
+ SSH 主机
192.168.x.x
- 远程端口
+ 远程端å£
- 本地端口
+ 本地端å£
- 事件时间线
+ 事件时间线
- 复制
+ å¤åˆ¶
- 清除
+ 清除
- 诊断包
+ 诊æ–包
- 分享前请审阅
+ 分享å‰è¯·å®¡é˜…
- 捆绑包生成器会排除敏感令牌、命令参数、负载和录制内容。
+ æ†ç»‘包生æˆå™¨ä¼šæŽ’é™¤æ•æ„Ÿä»¤ç‰Œã€å‘½ä»¤å‚æ•°ã€è´Ÿè½½å’Œå½•制内容。
- 已断开连接
+ å·²æ–开连接
- 操作
+ æ“作
- 节点
+ 节点
- 高级
+ 高级
-
+
\ No newline at end of file
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 4de1f60f1..da994b2fa 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -18,262 +18,262 @@
- 連線
+ 連線
- 啟動
+ 啟動
- 通知
+ 通知
- 高級(實驗性)
+ 高級(實驗性)
- 閘道器地址
+ é–˜é“器地å€
- ws://localhost:18789 或 https://host.tailnet.ts.net
+ ws://localhost:18789 或 https://host.tailnet.ts.net
- 權杖
+ 權æ–
- 您的 API Token
+ 您的 API Token
- 開機自啟動
+ 開機自啟動
全域性快捷鍵 (Ctrl+Alt+Shift+C → 快速發送)
- 顯示通知
+ 顯示通知
- 啟用節點模式
+ 啟用節點模å¼
- 提示音
+ æç¤ºéŸ³
- 預設
+ é è¨
- 無
+ ç„¡
- 柔和
+ 柔和
- 顯示以下型別通知:
+ 顯示以下型別通知:
- 按訊息中的關鍵詞過濾(例如"郵件"、"提醒")
+ 按訊æ¯ä¸çš„é—œéµè©žéŽæ¿¾ï¼ˆä¾‹å¦‚"郵件"ã€"æé†’")
- 健康警報
+ å¥åº·è¦å ±
- 緊急訊息
+ 緊急訊æ¯
- 提醒
+ æé†’
- 郵件摘要
+ 郵件摘è¦
- 日曆事件
+ 日曆事件
- 構建通知
+ 構建通知
- 股票提醒
+ 股票æé†’
- 常規資訊
+ 常è¦è³‡è¨Š
- 測試
+ 測試
- 發送測試通知
+ ç™¼é€æ¸¬è©¦é€šçŸ¥
- 儲存
+ 儲å˜
- 取消
+ å–æ¶ˆ
- 啟用後,此電腦可以接收來自代理的命令(Canvas、截圖等)
+ 啟用後,æ¤é›»è…¦å¯ä»¥æŽ¥æ”¶ä¾†è‡ªä»£ç†çš„命令(Canvasã€æˆªåœ–ç‰ï¼‰
- 用量
+ 用é‡
- 活躍會話
+ æ´»èºæœƒè©±
- 頻道
+ é »é“
- 閘道拓撲
+ é–˜é“æ‹“æ’²
- 支援和偵錯
+ 支æ´å’ŒåµéŒ¯
- 連接埠診斷
+ é€£æŽ¥åŸ è¨ºæ–·
- 權限
+ 權é™
- 30 天費用趨勢
+ 30 天費用趨勢
- 擴充性
+ 擴充性
- 節點
+ 節點
- 最近活動
+ 最近活動
- 費用(視窗期):
+ 費用(視窗期):
- 請求 / Token數:
+ 請求 / Token數:
- 提供者:
+ æä¾›è€…:
- 已連線
+ 已連線
- 無活躍會話
+ ç„¡æ´»èºæœƒè©±
- 未回報節點
+ æœªå›žå ±ç¯€é»ž
- 沒有最近活動
+ 沒有最近活動
- 頻道設定、技能和排程自動化由閘道管理。連線的閘道公開相符儀表板頁面時,請使用這些連結。
+ é »é“è¨å®šã€æŠ€èƒ½å’ŒæŽ’程自動化由閘é“管ç†ã€‚連線的閘é“公開相符儀表æ¿é 颿™‚,請使用這些連çµã€‚
- 頻道設定 / 結構描述
+ é »é“è¨å®š / çµæ§‹æè¿°
- 在可用時開啟頻道設定,用於結構描述導向的驗證、QR、登入和儀表板流程。
+ 在å¯ç”¨æ™‚é–‹å•Ÿé »é“è¨å®šï¼Œç”¨æ–¼çµæ§‹æè¿°å°Žå‘的驗è‰ã€QRã€ç™»å…¥å’Œå„€è¡¨æ¿æµç¨‹ã€‚
- 技能
+ 技能
- 當連線的閘道支援時,開啟閘道技能設定。
+ ç•¶é€£ç·šçš„é–˜é“æ”¯æ´æ™‚ï¼Œé–‹å•Ÿé–˜é“æŠ€èƒ½è¨å®šã€‚
- Cron / 排程
+ Cron / 排程
- 當連線的閘道支援時,開啟排程自動化控制項。
+ ç•¶é€£ç·šçš„é–˜é“æ”¯æ´æ™‚ï¼Œé–‹å•ŸæŽ’ç¨‹è‡ªå‹•åŒ–æŽ§åˆ¶é …ã€‚
- 重新整理
+ 釿–°æ•´ç†
- 開啟記錄
+ 開啟記錄
- 開啟設定
+ 開啟è¨å®š
- 重新整理健康狀態
+ 釿–°æ•´ç†å¥åº·ç‹€æ…‹
- 開啟診斷 JSONL
+ 開啟診斷 JSONL
- 複製支援內容
+ 複製支æ´å…§å®¹
- 重新啟動 SSH 通道
+ 釿–°å•Ÿå‹• SSH 通é“
- 複製瀏覽器設定
+ 複製ç€è¦½å™¨è¨å®š
- 複製偵錯套件
+ 複製åµéŒ¯å¥—ä»¶
- 檢查更新
+ 檢查更新
- 複製連接埠診斷
+ è¤‡è£½é€£æŽ¥åŸ è¨ºæ–·
- 複製
+ 複製
- 設定
+ è¨å®š
- 開啟
+ 開啟
- 複製節點清單
+ 複製節點清單
- 開啟串流
+ 開啟串æµ
⚡ 串流活動
- 全部活動
+ 全部活動
- 會話
+ 會話
- 用量
+ 用é‡
- 節點
+ 節點
- 通知
+ 通知
- 暫無活動
+ 暫無活動
- 打開儀表板
+ 打開儀表æ¿
- 全部清除
+ 全部清除
- 關閉
+ 關閉
📋 通知歷史
- 暫無通知
+ 暫無通知
- 全部清除
+ 全部清除
- 關閉
+ 關閉
設定 — OpenClaw 設定
@@ -288,16 +288,16 @@
通知歷史 — OpenClaw 設定
- OpenClaw 聊天
+ OpenClaw èŠå¤©
- 歡迎使用 OpenClaw
+ æ¡è¿Žä½¿ç”¨ OpenClaw
- OpenClaw 更新
+ OpenClaw æ›´æ–°
- 測試中...
+ 測試ä¸...
✅ 連線成功!
@@ -306,13 +306,13 @@
❌ 連線失敗
- 歡迎使用 OpenClaw!
+ æ¡è¿Žä½¿ç”¨ OpenClawï¼
- OpenClaw 設定是 OpenClaw 的 Windows 伴侶應用,一個 AI 驅動的個人助手。
+ OpenClaw è¨å®šæ˜¯ OpenClaw çš„ Windows 伴侶應用,一個 AI 驅動的個人助手。
- 開始使用前,您需要:
+ 開始使用å‰ï¼Œæ‚¨éœ€è¦ï¼š
• 一個正在執行的 OpenClaw 閘道器
@@ -324,90 +324,90 @@
📚 檢視文件
- 稍後
+ ç¨å¾Œ
- 打開設定
+ 打開è¨å®š
🎉 版本 {0} 已可用!
- 目前版本: {0}
+ ç›®å‰ç‰ˆæœ¬: {0}
- 更新內容:
+ 更新內容:
- 跳過此版本
+ è·³éŽæ¤ç‰ˆæœ¬
- 稍後提醒
+ ç¨å¾Œæé†’
- 下載並安裝
+ 下載並安è£
- 已是最新版本
+ 已是最新版本
- 您正在使用最新版本 (v{0})。
+ 您æ£åœ¨ä½¿ç”¨æœ€æ–°ç‰ˆæœ¬ (v{0})。
- 無法檢查更新
+ 無法檢查更新
- 檢查更新時發生錯誤。
+ 檢查更新時發生錯誤。
{0}
- 已略過更新檢查
+ å·²ç•¥éŽæ›´æ–°æª¢æŸ¥
- 偵錯版本已停用更新檢查。
+ åµéŒ¯ç‰ˆæœ¬å·²åœç”¨æ›´æ–°æª¢æŸ¥ã€‚
- 確定
+ 確定
- 打開儀表板
+ 打開儀表æ¿
- 打開網頁聊天
+ 打開網é èŠå¤©
- 串流活動...
+ ä¸²æµæ´»å‹•...
- 通知歷史...
+ 通知æ·å²...
- 執行健康檢查
+ 執行å¥åº·æª¢æŸ¥
- 設定...
+ è¨å®š...
- 自啟動
+ 自啟動
自啟動 ✓
- 打開日誌檔案
+ 打開日誌檔案
- 退出
+ 退出
- 複製節點摘要
+ 複製節點摘è¦
- 檢查更新
+ 檢查更新
- 設定指南...
+ è¨å®šæŒ‡å—...
重新設定…
@@ -416,64 +416,64 @@
🧰 支援和偵錯
- 開啟支援檔案
+ é–‹å•Ÿæ”¯æ´æª”案
- 記錄資料夾
+ 記錄資料夾
- 設定資料夾
+ è¨å®šè³‡æ–™å¤¾
- 診斷資料夾
+ 診斷資料夾
- 複製診斷
+ 複製診斷
- 支援內容
+ 支æ´å…§å®¹
- 偵錯套件
+ åµéŒ¯å¥—ä»¶
- 瀏覽器設定
+ ç€è¦½å™¨è¨å®š
- 連接埠診斷
+ é€£æŽ¥åŸ è¨ºæ–·
- 功能診斷
+ 功能診斷
- 節點清單
+ 節點清單
- 頻道摘要
+ é »é“æ‘˜è¦
- 活動摘要
+ 活動摘è¦
- 擴充性摘要
+ 擴充性摘è¦
- 重新啟動 SSH 通道
+ 釿–°å•Ÿå‹• SSH 通é“
- 狀態: {0}
+ 狀態: {0}
- 會話 ({0})
+ 會話 ({0})
- 節點 ({0})
+ 節點 ({0})
- 最近活動 ({0})
+ 最近活動 ({0})
- 暫無用量數據
+ æš«ç„¡ç”¨é‡æ•¸æ“š
↳ 重置會話
@@ -497,88 +497,88 @@
⚪ 中斷連線
- 測試通知
+ 測試通知
- 這是來自 OpenClaw 設定的測試通知。
+ 這是來自 OpenClaw è¨å®šçš„æ¸¬è©¦é€šçŸ¥ã€‚
- 上次檢查: {0}
+ 上次檢查: {0}
- 剛剛
+ 剛剛
- {0}分鐘前
+ {0}分é˜å‰
- {0}小時前
+ {0}å°æ™‚å‰
- 點選在儀表板中打開
+ 點é¸åœ¨å„€è¡¨æ¿ä¸æ‰“é–‹
- {0}天前
+ {0}天å‰
- 已連線
+ 已連線
- 正在連線
+ æ£åœ¨é€£ç·š
- 中斷連線
+ 䏿–·é€£ç·š
- 錯誤
+ 錯誤
- 未知
+ 未知
- 無
+ ç„¡
- 畫布
+ 畫布
❌ Canvas錯誤
- 重試
+ é‡è©¦
🎨 Canvas就緒
- 等待內容...
+ ç‰å¾…內容...
- 畫布
+ 畫布
- 索引標籤
+ 索引標籤
- 關閉
+ 關閉
- 選擇日期
+ 鏿“‡æ—¥æœŸ
- 選擇...
+ 鏿“‡...
- 不支援的元件: {0}
+ 䏿”¯æ´çš„元件: {0}
- 網頁聊天不可用
+ ç¶²é èŠå¤©ä¸å¯ç”¨
- 在瀏覽器中打開
+ 在ç€è¦½å™¨ä¸æ‰“é–‹
- 無法連線到 OpenClaw 閘道器
+ 無法連線到 OpenClaw é–˜é“器
閘道器 {0} 沒有響應。
@@ -597,7 +597,7 @@
• 或使用 SSH 隧道連線到 localhost 並繼續使用 localhost 地址
- 無效的閘道器地址: {0}
+ 無效的閘é“器地å€: {0}
網頁聊天需要安全上下文。
@@ -610,194 +610,194 @@
• 或通過隧道連線到 localhost:ssh -N -L 18789:localhost:18789 <伺服器>
- OpenClaw 選項
+ OpenClaw é¸é …
📋 裝置 ID 已複製
- 執行: openclaw devices approve {0}...
+ 執行: openclaw devices approve {0}...
📋 節點摘要已複製
- 已複製 {0} 個節點到剪貼簿
+ 已複製 {0} 個節點到剪貼簿
❌ 會話操作失敗
- 無法向閘道器發送請求。
+ 無法å‘é–˜é“器發é€è«‹æ±‚。
🔌 節點模式已啟用
- 此電腦現在可以接收來自代理的命令(Canvas、截圖)
+ æ¤é›»è…¦ç¾åœ¨å¯ä»¥æŽ¥æ”¶ä¾†è‡ªä»£ç†çš„命令(Canvasã€æˆªåœ–)
⏳ 等待配對批準
- 在閘道器上執行: openclaw devices approve {0}...
+ 在閘é“器上執行: openclaw devices approve {0}...
- 複製核准命令
+ è¤‡è£½æ ¸å‡†å‘½ä»¤
- 配對命令已複製
+ é…å°å‘½ä»¤å·²è¤‡è£½
✅ 節點已配對!
- 此電腦現在可以接收來自代理的命令
+ æ¤é›»è…¦ç¾åœ¨å¯ä»¥æŽ¥æ”¶ä¾†è‡ªä»£ç†çš„命令
❌ 節點配對遭拒
- 閘道器拒絕了此裝置的配對請求。
+ é–˜é“器拒絕了æ¤è£ç½®çš„é…å°è«‹æ±‚。
- 健康檢查
+ å¥åº·æª¢æŸ¥
- 閘道器尚未連線。
+ é–˜é“器尚未連線。
- 健康檢查請求已發送。
+ å¥åº·æª¢æŸ¥è«‹æ±‚已發é€ã€‚
- 健康檢查失敗
+ å¥åº·æª¢æŸ¥å¤±æ•—
📸 螢幕已擷取
- OpenClaw 代理擷取了您的螢幕
+ OpenClaw ä»£ç†æ“·å–了您的螢幕
📷 相機訪問被阻止
- 請在 Windows 隱私設定中為 OpenClaw Tray 啟用相機訪問
+ 請在 Windows éš±ç§è¨å®šä¸ç‚º OpenClaw Tray 啟用相機訪å•
🔴 螢幕錄製已開始
- OpenClaw 代理正在錄製您的螢幕
+ OpenClaw ä»£ç†æ£åœ¨éŒ„製您的螢幕
✅ 螢幕錄製已完成
- 螢幕錄製已傳送給代理
+ 螢幕錄製已傳é€çµ¦ä»£ç†
❌ 螢幕錄製失敗
- 錄製螢幕時發生錯誤
+ 錄製螢幕時發生錯誤
🔴 攝影機錄製已開始
- OpenClaw 代理正在從您的攝影機錄製
+ OpenClaw ä»£ç†æ£åœ¨å¾žæ‚¨çš„æ”å½±æ©ŸéŒ„è£½
✅ 攝影機錄製已完成
- 攝影機錄製片段已傳送給代理
+ æ”影機錄製片段已傳é€çµ¦ä»£ç†
OpenClaw · 權限請求
- 允許螢幕錄製?
+ å…許螢幕錄製?
- 允許攝影機錄製?
+ å…許æ”影機錄製?
- 代理正在請求錄製您的螢幕。這將從您的顯示器擷取視訊並傳送給代理。您的選擇將被記住用於以後的請求,直到您在設定中變更。
+ ä»£ç†æ£åœ¨è«‹æ±‚錄製您的螢幕。這將從您的顯示器擷å–視訊並傳é€çµ¦ä»£ç†ã€‚æ‚¨çš„é¸æ“‡å°‡è¢«è¨˜ä½ç”¨æ–¼ä»¥å¾Œçš„請求,直到您在è¨å®šä¸è®Šæ›´ã€‚
- 代理正在請求從您的攝影機錄製。這將從您的網路攝影機擷取視訊並傳送給代理。您的選擇將被記住用於以後的請求,直到您在設定中變更。
+ ä»£ç†æ£åœ¨è«‹æ±‚從您的æ”影機錄製。這將從您的網路æ”影機擷å–視訊並傳é€çµ¦ä»£ç†ã€‚æ‚¨çš„é¸æ“‡å°‡è¢«è¨˜ä½ç”¨æ–¼ä»¥å¾Œçš„請求,直到您在è¨å®šä¸è®Šæ›´ã€‚
- 您可以稍後在設定中變更此項。
+ 您å¯ä»¥ç¨å¾Œåœ¨è¨å®šä¸è®Šæ›´æ¤é …。
- 允許錄製
+ å…許錄製
- 拒絕
+ 拒絕
- 隱私
+ éš±ç§
- 控制代理可以在此裝置上使用哪些功能。
+ 控制代ç†å¯ä»¥åœ¨æ¤è£ç½®ä¸Šä½¿ç”¨å“ªäº›åŠŸèƒ½ã€‚
- 允許螢幕錄製
+ å…許螢幕錄製
- 允許攝影機錄製
+ å…許æ”影機錄製
- 隱私
+ éš±ç§
- 預先核准功能,以便代理無需每次都請求權限。錄製開始前仍會顯示倒數計時。
+ é å…ˆæ ¸å‡†åŠŸèƒ½ï¼Œä»¥ä¾¿ä»£ç†ç„¡éœ€æ¯æ¬¡éƒ½è«‹æ±‚權é™ã€‚錄製開始å‰ä»æœƒé¡¯ç¤ºå€’數計時。
- 允許螢幕錄製
+ å…許螢幕錄製
- 允許攝影機錄製
+ å…許æ”影機錄製
⚡ 新功能: 串流活動
- 打開設定選項即可檢視實時會話、用量和節點活動。
+ 打開è¨å®šé¸é …å³å¯æª¢è¦–實時會話ã€ç”¨é‡å’Œç¯€é»žæ´»å‹•。
- 打開串流活動
+ æ‰“é–‹ä¸²æµæ´»å‹•
- OpenClaw 設定
+ OpenClaw è¨å®š
第 1 步(共 3 步)— 連線
- 連線到您的閘道器
+ 連線到您的閘é“器
- 在您的閘道器主機(Mac/Linux)上執行以下命令取得設定碼:
+ 在您的閘é“器主機(Mac/Linux)上執行以下命令å–å¾—è¨å®šç¢¼ï¼š
- 設定碼
+ è¨å®šç¢¼
- 貼上來自閘道器儀表板的設定碼
+ 貼上來自閘é“器儀表æ¿çš„è¨å®šç¢¼
- 貼上設定碼 / QR
+ 貼上è¨å®šç¢¼ / QR
- 匯入 QR 圖片...
+ 匯入 QR 圖片...
✅ QR 圖片已解碼 — 請按「測試連線」
@@ -815,7 +815,7 @@
隱藏手動輸入 ▴
- 閘道器網址
+ é–˜é“器網å€
ws://192.168.1.x:18789
@@ -824,13 +824,13 @@
💡 支援 ws://、wss://、http:// 或 https://
- 閘道器權杖
+ é–˜é“器權æ–
- 在此貼上您的權杖
+ 在æ¤è²¼ä¸Šæ‚¨çš„æ¬Šæ–
- 測試連線
+ 測試連線
⏳ 正在連線到閘道器...
@@ -872,25 +872,25 @@
✅ 設定碼已解碼 — 請點擊「測試連線」
- 啟用節點模式(選用)
+ 啟用節點模å¼ï¼ˆé¸ç”¨ï¼‰
節點模式允許您的 Windows 電腦為 OpenClaw 執行任務 — 如螢幕擷取、攝影機存取和畫布繪製。
- 只為您信任的閘道和代理程式啟用節點模式
+ åªç‚ºæ‚¨ä¿¡ä»»çš„é–˜é“和代ç†ç¨‹å¼å•Ÿç”¨ç¯€é»žæ¨¡å¼
- 已核准的代理程式可以執行本機命令,並可能存取已啟用的裝置介面,例如螢幕、攝影機、位置、瀏覽器和畫布。您稍後可以在設定中停用能力群組。
+ å·²æ ¸å‡†çš„ä»£ç†ç¨‹å¼å¯ä»¥åŸ·è¡Œæœ¬æ©Ÿå‘½ä»¤ï¼Œä¸¦å¯èƒ½å˜å–已啟用的è£ç½®ä»‹é¢ï¼Œä¾‹å¦‚èž¢å¹•ã€æ”影機ã€ä½ç½®ã€ç€è¦½å™¨å’Œç•«å¸ƒã€‚您ç¨å¾Œå¯ä»¥åœ¨è¨å®šä¸åœç”¨èƒ½åŠ›ç¾¤çµ„ã€‚
- 啟用節點模式
+ 啟用節點模å¼
- 裝置 ID:載入中...
+ è£ç½® ID:載入ä¸...
- 裝置 ID:(將在首次連線時產生)
+ è£ç½® ID:(將在首次連線時產生)
📋 複製裝置 ID
@@ -899,7 +899,7 @@
✅ 已複製!
- 要核准此節點,請在閘道器主機上執行:
+ è¦æ ¸å‡†æ¤ç¯€é»žï¼Œè«‹åœ¨é–˜é“器主機上執行:
💡 您可以現在完成設定 — 配對將在背景繼續。獲得核准後會收到通知。
@@ -908,16 +908,16 @@
🎉 一切就緒!
- OpenClaw 系統匣將連線到您的閘道器並開始監控。
+ OpenClaw 系統匣將連線到您的閘é“器並開始監控。
- 上一步
+ 上一æ¥
- 下一步
+ 下一æ¥
- 完成
+ 完æˆ
第 2 步(共 3 步)— 節點模式
@@ -926,112 +926,112 @@
第 3 步(共 3 步)— 完成
- 開發人員模式
+ 開發人員模å¼
- 啟用本機 MCP 伺服器
+ 啟用本機 MCP 伺æœå™¨
- 向本機 MCP 用戶端(Claude Desktop、Cursor、Claude Code)公開相同的節點功能(系統、螢幕、攝影機、麥克風、喇叭、畫布)。
+ 呿œ¬æ©Ÿ MCP 用戶端(Claude Desktopã€Cursorã€Claude Code)公開相åŒçš„節點功能(系統ã€èž¢å¹•ã€æ”影機ã€éº¥å…‹é¢¨ã€å–‡åã€ç•«å¸ƒï¼‰ã€‚
- 端點:
+ 端點:
- 狀態:
+ 狀態:
- 已停用
+ å·²åœç”¨
- 監聽中
+ 監è½ä¸
- 已停止
+ å·²åœæ¢
- 儲存後將啟動
+ 儲å˜å¾Œå°‡å•Ÿå‹•
- 儲存後將停止
+ 儲å˜å¾Œå°‡åœæ¢
- 啟動失敗:
+ 啟動失敗:
- Bearer 權杖:
+ Bearer 權æ–:
- 顯示
+ 顯示
- 複製
+ 複製
- 重設
+ é‡è¨
- 顯示
+ 顯示
- 隱藏
+ éš±è—
(尚未產生 — 啟用 MCP 伺服器並按儲存)
- 儲存於 {0}
+ å„²å˜æ–¼ {0}
- 每次請求皆以 'Authorization: Bearer <token>' 傳送。儲存於 {0}。
+ æ¯æ¬¡è«‹æ±‚皆以 'Authorization: Bearer <token>' 傳é€ã€‚å„²å˜æ–¼ {0}。
- MCP 權杖已重設
+ MCP 權æ–å·²é‡è¨
- 舊的 MCP Bearer 權杖現已失效。
+ 舊的 MCP Bearer 權æ–ç¾å·²å¤±æ•ˆã€‚
- 重設權杖失敗:{0}
+ é‡è¨æ¬Šæ–失敗:{0}
- 重設 MCP Bearer 權杖?
+ é‡è¨ MCP Bearer 權æ–?
- 現有的本機 MCP 用戶端(Claude Desktop、Cursor、Claude Code)將停止運作,直到您使用新權杖重新設定它們。
+ ç¾æœ‰çš„æœ¬æ©Ÿ MCP 用戶端(Claude Desktopã€Cursorã€Claude Codeï¼‰å°‡åœæ¢é‹ä½œï¼Œç›´åˆ°æ‚¨ä½¿ç”¨æ–°æ¬Šæ–釿–°è¨å®šå®ƒå€‘。
- 重設
+ é‡è¨
- 取消
+ å–æ¶ˆ
- 正在下載更新...
+ æ£åœ¨ä¸‹è¼‰æ›´æ–°...
- 正在下載更新...
+ æ£åœ¨ä¸‹è¼‰æ›´æ–°...
OpenClaw — 核准 URL
- 代理想在您的瀏覽器中開啟高風險目標。
+ ä»£ç†æƒ³åœ¨æ‚¨çš„ç€è¦½å™¨ä¸é–‹å•Ÿé«˜é¢¨éšªç›®æ¨™ã€‚
- (未記錄特定原因)
+ (æœªè¨˜éŒ„ç‰¹å®šåŽŸå› )
- 區域:{0}
+ å€åŸŸï¼š{0}
- 代理:{0}
+ 代ç†ï¼š{0}
- 主機:{0}
+ 主機:{0}
- 原因:
+ åŽŸå› ï¼š
是 — 開啟此 URL。
@@ -1040,136 +1040,136 @@
否 — 封鎖此項。
- OpenClaw 設定
+ OpenClaw è¨å®š
- 上一步
+ 上一æ¥
- 下一步
+ 下一æ¥
- 完成
+ 完æˆ
- 設定尚未完成
+ è¨å®šå°šæœªå®Œæˆ
- OpenClaw 仍需要完成設定,才能開啟 Hub。請使用「返回」回到精靈或連線步驟,修正缺少的設定,然後再次嘗試「完成」。
+ OpenClaw ä»éœ€è¦å®Œæˆè¨å®šï¼Œæ‰èƒ½é–‹å•Ÿ Hub。請使用「返回ã€å›žåˆ°ç²¾éˆæˆ–連線æ¥é©Ÿï¼Œä¿®æ£ç¼ºå°‘çš„è¨å®šï¼Œç„¶å¾Œå†æ¬¡å˜—試「完æˆã€ã€‚
- 確定
+ 確定
- 歡迎使用 OpenClaw
+ æ¡è¿Žä½¿ç”¨ OpenClaw
- 您的 AI 助手,就在系統匣中
+ 您的 AI 助手,就在系統匣ä¸
- 安全性通知
+ 安全性通知
- OpenClaw 會執行 AI 代理程式,可代表你執行命令、讀寫檔案,並與你的系統互動。
+ OpenClaw 會執行 AI 代ç†ç¨‹å¼ï¼Œå¯ä»£è¡¨ä½ 執行命令ã€è®€å¯«æª”æ¡ˆï¼Œä¸¦èˆ‡ä½ çš„ç³»çµ±äº’å‹•ã€‚
- 您的代理程式可以:
+ 您的代ç†ç¨‹å¼å¯ä»¥ï¼š
- 在您的電腦上執行命令
+ 在您的電腦上執行命令
- 讀取和寫入檔案
+ 讀å–和寫入檔案
- 擷取螢幕截圖
+ æ“·å–螢幕截圖
- 選擇閘道
+ 鏿“‡é–˜é“
- 選擇您要如何連線到 OpenClaw 閘道
+ 鏿“‡æ‚¨è¦å¦‚何連線到 OpenClaw é–˜é“
- 本機(本地)
+ 本機(本地)
- 遠端閘道
+ é 端閘é“
- 稍後設定
+ ç¨å¾Œè¨å®š
- 一切就緒!
+ 一切就緒ï¼
- OpenClaw 已準備就緒。以下是可以嘗試的項目:
+ OpenClaw 已準備就緒。以下是å¯ä»¥å˜—è©¦çš„é …ç›®ï¼š
- 開啟功能表列面板
+ é–‹å•ŸåŠŸèƒ½è¡¨åˆ—é¢æ¿
- 連接訊息頻道
+ 連接訊æ¯é »é“
- 試用語音喚醒
+ 試用語音喚醒
- 使用畫布
+ 使用畫布
- 啟用技能
+ 啟用技能
- 開機時啟動 OpenClaw
+ 開機時啟動 OpenClaw
- 您可以隨時從系統匣選單設定閘道連線。
+ 您å¯ä»¥éš¨æ™‚從系統匣é¸å–®è¨å®šé–˜é“連線。
- 請確認您的遠端閘道正在執行且可以存取。
+ è«‹ç¢ºèªæ‚¨çš„é ç«¯é–˜é“æ£åœ¨åŸ·è¡Œä¸”å¯ä»¥å˜å–。
- 設定碼
+ è¨å®šç¢¼
- 點擊貼上設定碼
+ 點擊貼上è¨å®šç¢¼
- 閘道器網址
+ é–˜é“器網å€
Token
- 貼上引導 Token
+ 貼上引導 Token
- 節點模式
+ 節點模å¼
- 測試連線
+ 測試連線
- 準備連線
+ 準備連線
- 您可以稍後從系統匣選單設定閘道連線。
+ 您å¯ä»¥ç¨å¾Œå¾žç³»çµ±åŒ£é¸å–®è¨å®šé–˜é“連線。
- 設定碼已解碼
+ è¨å®šç¢¼å·²è§£ç¢¼
- Token 不能為空
+ Token ä¸èƒ½ç‚ºç©º
正在連線…
- 已連線到閘道
+ 已連線到閘é“
- 裝置需要核准
+ è£ç½®éœ€è¦æ ¸å‡†
Token 不符 — 請檢查您的引導 Token
@@ -1178,124 +1178,124 @@
連線逾時 (15s) — 請確認閘道正在執行
- 授予權限
+ 授予權é™
- 當 OpenClaw 可以傳送通知、存取相機和麥克風、擷取螢幕並知道你的位置時效果最佳。請在下方授與權限。
+ ç•¶ OpenClaw å¯ä»¥å‚³é€é€šçŸ¥ã€å˜å–ç›¸æ©Ÿå’Œéº¥å…‹é¢¨ã€æ“·å–螢幕並知é“ä½ çš„ä½ç½®æ™‚效果最佳。請在下方授與權é™ã€‚
- 重新整理狀態
+ 釿–°æ•´ç†ç‹€æ…‹
- 開啟設定
+ 開啟è¨å®š
正在檢查權限…
- 認識您的代理程式
+ èªè˜æ‚¨çš„代ç†ç¨‹å¼
正在載入聊天…
- 設定閘道
+ è¨å®šé–˜é“
- 繼續
+ 繼續
- 略過
+ ç•¥éŽ
- 閘道設定完成!
+ é–˜é“è¨å®šå®Œæˆï¼
- 閘道精靈無法使用
+ é–˜é“ç²¾éˆç„¡æ³•使用
- 閘道提供動態設定步驟,將在連線建立後執行。
+ é–˜é“æä¾›å‹•æ…‹è¨å®šæ¥é©Ÿï¼Œå°‡åœ¨é€£ç·šå»ºç«‹å¾ŒåŸ·è¡Œã€‚
- 通知
+ 通知
- 相機
+ 相機
- 麥克風
+ 麥克風
- 螢幕擷取
+ 螢幕擷å–
- 位置(選用)
+ ä½ç½®ï¼ˆé¸ç”¨ï¼‰
- 已啟用
+ 已啟用
- 已啟用(預設)
+ 已啟用(é è¨ï¼‰
- 已停用
+ å·²åœç”¨
- 在應用程式資訊清單中已停用
+ 在應用程å¼è³‡è¨Šæ¸…å–®ä¸å·²åœç”¨
- 被原則停用
+ 被原則åœç”¨
已停用 — 開啟設定以啟用
- 已允許
+ å·²å…許
被拒絕 — 開啟設定以允許
- 被系統原則拒絕
+ 被系統原則拒絕
未確定 — 開啟設定
- 無法檢查
+ 無法檢查
- 未偵測到相機
+ æœªåµæ¸¬åˆ°ç›¸æ©Ÿ
- 未偵測到麥克風
+ æœªåµæ¸¬åˆ°éº¥å…‹é¢¨
可用 — 每次擷取使用選擇器
- 此裝置不支援
+ æ¤è£ç½®ä¸æ”¯æ´
- 定位服務已在系統範圍停用
+ å®šä½æœå‹™å·²åœ¨ç³»çµ±ç¯„åœåœç”¨
- 此使用者的位置已停用
+ æ¤ä½¿ç”¨è€…çš„ä½ç½®å·²åœç”¨
- 定位服務已啟用
+ å®šä½æœå‹™å·²å•Ÿç”¨
- 從系統匣存取
+ 從系統匣å˜å–
設定 → 頻道
- 用語音喚醒
+ 用語音喚醒
- 視覺工作區
+ 視覺工作å€
設定 → 技能
@@ -1307,52 +1307,52 @@
速率限制 — 請稍候再試
- 在您的閘道上執行:
+ 在您的閘é“上執行:
- 點擊複製
+ 點擊複製
複製失敗 — 點擊重試
- 已複製!
+ 已複製ï¼
連線失敗 — 請檢查 URL 和權杖,然後重試
- 處理此步驟時發生錯誤
+ è™•ç†æ¤æ¥é©Ÿæ™‚發生錯誤
- 正在偵測閘道...
+ æ£åœ¨åµæ¸¬é–˜é“...
- 已在開發連接埠偵測到閘道
+ å·²åœ¨é–‹ç™¼é€£æŽ¥åŸ åµæ¸¬åˆ°é–˜é“
- 正在驗證...
+ æ£åœ¨é©—è‰...
- WSL 閘道
+ WSL é–˜é“
SSH ΘÇÜΘüô
- 貼上
+ 貼上
QR
- 匯入 QR 影像
+ 匯入 QR å½±åƒ
- 無法從此影像解碼設定代碼
+ 無法從æ¤å½±åƒè§£ç¢¼è¨å®šä»£ç¢¼
- OpenClaw 將建立受控 SSH 通道,將遠端主機上的閘道連接埠轉送到此電腦。
+ OpenClaw å°‡å»ºç«‹å—æŽ§ SSH 通é“,將é 端主機上的閘é“é€£æŽ¥åŸ è½‰é€åˆ°æ¤é›»è…¦ã€‚
SSH Σ╜┐τö¿ΦÇà
@@ -1361,28 +1361,28 @@
SSH 主機
- 遠端閘道連接埠
+ é 端閘é“連接åŸ
- 本地轉送連接埠
+ 本地轉é€é€£æŽ¥åŸ
- 受控通道:
+ å—æŽ§é€šé“:
- 連線前請輸入有效的 SSH 使用者。
+ 連線å‰è«‹è¼¸å…¥æœ‰æ•ˆçš„ SSH 使用者。
- 連線前請輸入有效的 SSH 主機(例如 mac-studio.local)。
+ 連線å‰è«‹è¼¸å…¥æœ‰æ•ˆçš„ SSH 主機(例如 mac-studio.local)。
- 已偵測:{0}
+ 已嵿¸¬ï¼š{0}
- 您可以稍後在「設定」中設定閘道。
+ 您å¯ä»¥ç¨å¾Œåœ¨ã€Œè¨å®šã€ä¸è¨å®šé–˜é“。
- 設定 OpenClaw
+ è¨å®š OpenClaw
OpenClaw 允許代理在此電腦上執行命令、讀寫檔案以及擷取螢幕畫面。僅在您信任的電腦上進行設定。
@@ -1390,64 +1390,64 @@
⚠️ 本機設定將安裝一個專用於 OpenClaw 的小型 WSL Linux 執行個體。如果您希望連線到現有或遠端閘道,請選擇「進階設定」。
- 本機設定
+ 本機è¨å®š
- 進階設定
+ 進階è¨å®š
⚠️ 取代現有設定?
- 繼續將會中斷與目前閘道的連線並遺失所有設定。
+ ç¹¼çºŒå°‡æœƒä¸æ–·èˆ‡ç›®å‰é–˜é“的連線並éºå¤±æ‰€æœ‰è¨å®šã€‚
- 取代我的設定
+ å–代我的è¨å®š
- 保留我的設定
+ ä¿ç•™æˆ‘çš„è¨å®š
- 正在本機設定
+ æ£åœ¨æœ¬æ©Ÿè¨å®š
- 正在設定您的本機 OpenClaw 閘道。
+ æ£åœ¨è¨å®šæ‚¨çš„æœ¬æ©Ÿ OpenClaw é–˜é“。
- 本機閘道已就緒。
+ 本機閘é“已就緒。
- 重試
+ é‡è©¦
- 設定未完成。
+ è¨å®šæœªå®Œæˆã€‚
- 診斷:aka.ms/wsllogs
+ 診斷:aka.ms/wsllogs
- 正在檢查系統
+ æ£åœ¨æª¢æŸ¥ç³»çµ±
- 正在安裝 Ubuntu
+ æ£åœ¨å®‰è£ Ubuntu
- 正在設定執行個體
+ æ£åœ¨è¨å®šåŸ·è¡Œå€‹é«”
- 正在安裝 OpenClaw
+ æ£åœ¨å®‰è£ OpenClaw
- 正在準備閘道
+ æ£åœ¨æº–備閘é“
- 正在啟動閘道
+ æ£åœ¨å•Ÿå‹•é–˜é“
- 正在產生設定碼
+ æ£åœ¨ç”¢ç”Ÿè¨å®šç¢¼
- 關於
+ 關於
OpenClaw Hub
@@ -1456,37 +1456,37 @@
.NET 10 / WinUI 3 / WinAppSDK 1.8
- 閘道資訊
+ é–˜é“資訊
- 版本:
+ 版本:
- 模型:
+ 模型:
- 驗證模式:
+ é©—è‰æ¨¡å¼ï¼š
- 執行時間:
+ 執行時間:
- 偵錯
+ åµéŒ¯
- 開啟記錄檔
+ 開啟記錄檔
- 開啟設定資料夾
+ 開啟è¨å®šè³‡æ–™å¤¾
- 複製支援內容
+ 複製支æ´å…§å®¹
- 檢查更新
+ 檢查更新
- 連結
+ 連çµ
Documentation → openclaw.ai/docs
@@ -1498,196 +1498,199 @@
Dashboard → openclaw://dashboard
- 代理程式事件
+ 代ç†ç¨‹å¼äº‹ä»¶
- 代理程式
+ 代ç†ç¨‹å¼
- 所有代理程式
+ 所有代ç†ç¨‹å¼
代理程式活動的即時串流——工具呼叫、回應、錯誤和生命週期事件。
- 所有代理程式
+ 所有代ç†ç¨‹å¼
- 工具
+ 工具
- 助理
+ 助ç†
- 錯誤
+ 錯誤
- 生命週期
+ 生命週期
- 計畫
+ 計畫
- 核准
+ æ ¸å‡†
- 思考中
+ æ€è€ƒä¸
- 修補程式
+ 修補程å¼
- 尚無代理程式事件
+ 尚無代ç†ç¨‹å¼äº‹ä»¶
- 代理程式執行時,即時代理程式事件 (工具呼叫、回應、錯誤) 會顯示在這裡。
+ 代ç†ç¨‹å¼åŸ·è¡Œæ™‚ï¼Œå³æ™‚代ç†ç¨‹å¼äº‹ä»¶ (工具呼å«ã€å›žæ‡‰ã€éŒ¯èª¤) 會顯示在這裡。
- 清除
+ 清除
+
+
+ 清除
- 繫結
+ 繫çµ
- 訊息路由規則
+ 訊æ¯è·¯ç”±è¦å‰‡
- 重新整理
+ 釿–°æ•´ç†
🖥️ 這部電腦
- 透過閘道提供給代理程式的裝置功能。切換這部電腦可以執行的動作。
+ é€éŽé–˜é“æä¾›çµ¦ä»£ç†ç¨‹å¼çš„è£ç½®åŠŸèƒ½ã€‚åˆ‡æ›é€™éƒ¨é›»è…¦å¯ä»¥åŸ·è¡Œçš„動作。
- 本機 MCP 伺服器
+ 本機 MCP 伺æœå™¨
- 透過 HTTP 向 CLI 工具和本機整合公開功能。
+ é€éŽ HTTP å‘ CLI 工具和本機整åˆå…¬é–‹åŠŸèƒ½ã€‚
- 端點:
+ 端點:
- 複製權杖
+ 複製權æ–
- 複製 URL
+ 複製 URL
- 節點狀態
+ 節點狀態
- 節點模式已停用
+ 節點模å¼å·²åœç”¨
📡 頻道
- 未連線
+ 未連線
- 未設定頻道
+ 未è¨å®šé »é“
- 連線到閘道以開始聊天
+ 連線到閘é“以開始èŠå¤©
等待聊天啟動…
- 閘道已連接;聊天介面仍在上線中。
+ é–˜é“已連接;èŠå¤©ä»‹é¢ä»åœ¨ä¸Šç·šä¸ã€‚
⚙️ 設定
- 重新整理
+ 釿–°æ•´ç†
- 編輯器
+ 編輯器
- 選取設定區段
+ é¸å–è¨å®šå€æ®µ
- 原始 JSON
+ 原始 JSON
- 結構描述無法使用
+ çµæ§‹æè¿°ç„¡æ³•使用
- 無法載入閘道設定結構描述。請使用網頁儀表板編輯設定。
+ 無法載入閘é“è¨å®šçµæ§‹æè¿°ã€‚請使用網é 儀表æ¿ç·¨è¼¯è¨å®šã€‚
- 開啟儀表板
+ 開啟儀表æ¿
- 儲存變更
+ 儲å˜è®Šæ›´
🔗 連線
- 操作員用戶端和節點服務共同使用的閘道端點。
+ æ“作員用戶端和節點æœå‹™å…±åŒä½¿ç”¨çš„é–˜é“端點。
- 連線錯誤
+ 連線錯誤
- 已中斷連線
+ 已䏿–·é€£ç·š
- 重新連線
+ 釿–°é€£ç·š
操作員:—
- 連線
+ 連線
📡 可用閘道
- 掃描
+ 掃æ
- 此閘道需要權杖才能連線。
+ æ¤é–˜é“éœ€è¦æ¬Šæ–æ‰èƒ½é€£ç·šã€‚
- 取消
+ å–æ¶ˆ
- 連線
+ 連線
- 在閘道主機上執行 'openclaw qr' 取得設定碼,或在閘道設定中尋找權杖。
+ 在閘é“主機上執行 'openclaw qr' å–å¾—è¨å®šç¢¼ï¼Œæˆ–在閘é“è¨å®šä¸å°‹æ‰¾æ¬Šæ–。
- 找不到閘道。按一下「掃描」進行搜尋。
+ 找ä¸åˆ°é–˜é“。按一下「掃æã€é€²è¡Œæœå°‹ã€‚
🔑 設定碼
- 貼上來自 openclaw qr 的設定碼
+ 貼上來自 openclaw qr çš„è¨å®šç¢¼
- 套用
+ 套用
- 進階連線設定
+ 進階連線è¨å®š
- 閘道器網址
+ é–˜é“器網å€
Token
- 測試
+ 測試
SSH ΘÇÜΘüô
@@ -1705,58 +1708,58 @@
machine-name
- 遠端連接埠
+ é 端連接åŸ
- 本機連接埠
+ 本機連接åŸ
- 儲存
+ 儲å˜
🪪 此裝置
- 裝置 ID:
+ è£ç½® ID:
- 複製
+ 複製
- 在閘道主機上執行此命令以核准:
+ 在閘é“主機上執行æ¤å‘½ä»¤ä»¥æ ¸å‡†ï¼š
- 複製
+ 複製
📋 連線記錄
- 沒有最近的連線事件。
+ 沒有最近的連線事件。
- 交談
+ 交談
- 全部
+ 全部
- 依頻道
+ ä¾é »é“
- 依狀態
+ ä¾ç‹€æ…‹
- 重新整理
+ 釿–°æ•´ç†
⏱️ Cron 工作
- 排程器狀態
+ 排程器狀態
- 已啟用
+ 已啟用
Store: ~/.openclaw/cron.db
@@ -1765,22 +1768,22 @@
下次喚醒:—
- 立即執行
+ ç«‹å³åŸ·è¡Œ
- 上次執行:
+ 上次執行:
- 下次執行:
+ 下次執行:
- 未設定 Cron 工作
+ 未è¨å®š Cron 工作
🐛 偵錯
- 記錄檢視器
+ 記錄檢視器
⟳ 重新整理
@@ -1789,34 +1792,34 @@
📋 複製記錄
- 正在載入...
+ æ£åœ¨è¼‰å…¥...
- 連線狀態
+ 連線狀態
- 操作員狀態
+ æ“作員狀態
- 閘道器網址
+ é–˜é“器網å€
- 節點模式
+ 節點模å¼
- 裝置身分識別
+ è£ç½®èº«åˆ†è˜åˆ¥
- 裝置 ID
+ è£ç½® ID
📋 複製
- 公開金鑰
+ 公開金鑰
- 偵錯動作
+ åµéŒ¯å‹•作
📄 開啟記錄檔
@@ -1831,31 +1834,31 @@
📋 複製支援內容
- 未連線到閘道
+ 未連線到閘é“
- 掃描閘道
+ 掃æé–˜é“
- 開啟儀表板
+ 開啟儀表æ¿
- 開啟聊天
+ 開啟èŠå¤©
- 健康檢查
+ å¥åº·æª¢æŸ¥
⟳ 重新整理
- 重新命名
+ 釿–°å‘½å
- 忘記
+ 忘記
- 更多選項
+ 更多é¸é …
Version
@@ -1864,154 +1867,154 @@
Hardware
- 網路
+ 網路
- 已核准
+ å·²æ ¸å‡†
- 已連線
+ 已連線
- 上次看到
+ 上次看到
- 功能
+ 功能
- 權限
+ 權é™
PATH
- 命令 ({0})
+ 命令 ({0})
- (已停用)
+ (已åœç”¨ï¼‰
- 重新命名節點
+ 釿–°å‘½å節點
- 為 {0} 選擇新的顯示名稱。
+ 為 {0} 鏿“‡æ–°çš„顯示å稱。
- 顯示名稱
+ 顯示å稱
- 重新命名
+ 釿–°å‘½å
- 顯示名稱不能為空。
+ 顯示å稱ä¸èƒ½ç‚ºç©ºã€‚
- 重新命名失敗。
+ 釿–°å‘½å失敗。
- 忘記節點?
+ 忘記節點?
- 這將從閘道中刪除下面節點的記錄。節點需要重新配對才能再次連線。
+ 這將從閘é“ä¸åˆªé™¤ä¸‹é¢ç¯€é»žçš„記錄。節點需è¦é‡æ–°é…å°æ‰èƒ½å†æ¬¡é€£ç·šã€‚
- 針對該節點的作用中操作員和聊天工作階段將立即失去存取權。
+ é‡å°è©²ç¯€é»žçš„ä½œç”¨ä¸æ“作員和èŠå¤©å·¥ä½œéšŽæ®µå°‡ç«‹å³å¤±åŽ»å˜å–權。
- 忘記節點
+ 忘記節點
無法忘記節點 — 閘道無法連線或權限遭拒。
- 取消
+ å–æ¶ˆ
🔐 權限
- 執行原則
+ 執行原則
- 控制代理程式可在此節點上執行哪些命令。規則會比對完整命令列。
+ 控制代ç†ç¨‹å¼å¯åœ¨æ¤ç¯€é»žä¸ŠåŸ·è¡Œå“ªäº›å‘½ä»¤ã€‚è¦å‰‡æœƒæ¯”å°å®Œæ•´å‘½ä»¤åˆ—。
- 預設動作
+ é è¨å‹•作
- 拒絕
+ 拒絕
- 允許
+ å…許
- 詢問
+ è©¢å•
- 規則
+ è¦å‰‡
- 命令模式 (例如 echo *)
+ å‘½ä»¤æ¨¡å¼ (例如 echo *)
- 允許
+ å…許
- 拒絕
+ 拒絕
- 新增規則
+ 新增è¦å‰‡
- 節點允許清單
+ 節點å…許清單
閘道允許已連線節點執行的命令。此清單為唯讀 — 請透過「設定」頁面編輯。
- 沒有可用的允許清單資料。請先連線到閘道。
+ 沒有å¯ç”¨çš„å…許清單資料。請先連線到閘é“。
- 系統權限
+ 系統權é™
- 相機、麥克風和螢幕擷取的系統權限。這些由 Windows 隱私權設定管理。
+ 相機ã€éº¥å…‹é¢¨å’Œèž¢å¹•æ“·å–的系統權é™ã€‚這些由 Windows éš±ç§æ¬Šè¨å®šç®¡ç†ã€‚
📷 相機
- 未知
+ 未知
🎤 麥克風
- 未知
+ 未知
🖥️ 螢幕擷取
- 可用
+ å¯ç”¨
- 開啟 Windows 隱私權設定
+ 開啟 Windows éš±ç§æ¬Šè¨å®š
💬 工作階段
- 重設
+ é‡è¨
- 壓縮
+ 壓縮
- 刪除
+ 刪除
- 無活躍會話
+ ç„¡æ´»èºæœƒè©±
🤖 可用模型
@@ -2020,301 +2023,301 @@
⚙️ 設定
- 這部電腦的本機應用程式偏好設定。
+ 這部電腦的本機應用程å¼å好è¨å®šã€‚
- 啟動
+ 啟動
- 開機自啟動
+ 開機自啟動
全域快速鍵 (Ctrl+Alt+Shift+C → 快速傳送)
- 通知
+ 通知
- 顯示通知
+ 顯示通知
- 提示音
+ æç¤ºéŸ³
- 預設
+ é è¨
- 無
+ ç„¡
- 柔和
+ 柔和
- 顯示以下型別通知:
+ 顯示以下型別通知:
- 健康警報
+ å¥åº·è¦å ±
- 緊急訊息
+ 緊急訊æ¯
- 提醒
+ æé†’
- 郵件摘要
+ 郵件摘è¦
- 日曆事件
+ 日曆事件
- 構建通知
+ 構建通知
- 股票提醒
+ 股票æé†’
- 常規資訊
+ 常è¦è³‡è¨Š
- 發送測試通知
+ ç™¼é€æ¸¬è©¦é€šçŸ¥
- 取消
+ å–æ¶ˆ
🧩 技能
- 所有代理程式
+ 所有代ç†ç¨‹å¼
- 未安裝技能
+ æœªå®‰è£æŠ€èƒ½
瀏覽技能市集 →
- 總成本
+ ç¸½æˆæœ¬
- 要求
+ è¦æ±‚
- 權杖
+ 權æ–
284.5K
- 提供者
+ æä¾›è€…
- 7 天
+ 7 天
- 30 天
+ 30 天
正在載入提供者…
- 未設定任何提供者
+ 未è¨å®šä»»ä½•æä¾›è€…
- 提供者明細
+ æä¾›è€…明細
- 每日成本
+ æ¯æ—¥æˆæœ¬
📂 工作區
- 重新整理
+ 釿–°æ•´ç†
- 聊天
+ èŠå¤©
- 連線到閘道以開始聊天
+ 連線到閘é“以開始èŠå¤©
OpenClaw Windows Companion
- 已中斷連線
+ 已䏿–·é€£ç·š
- 首頁
+ 首é
- 聊天
+ èŠå¤©
- 閘道
+ é–˜é“
- 連線
+ 連線
- 工作階段
+ 工作階段
- 對話
+ å°è©±
- 代理程式事件
+ 代ç†ç¨‹å¼äº‹ä»¶
- 技能
+ 技能
- 代理程式
+ 代ç†ç¨‹å¼
main
- 頻道
+ é »é“
- 實例
+ 實例
- 繫結
+ 繫çµ
- 設定
+ è¨å®š
- 用量
+ 用é‡
Cron
- 這部電腦
+ 這部電腦
- 功能
+ 功能
- 設定
+ è¨å®š
- 權限
+ 權é™
- 偵錯
+ åµéŒ¯
- 資訊
+ 資訊
- 未知頁面
+ 未知é é¢
- 重試
+ é‡è©¦
🔌 節點模式已啟用
- 此電腦將作為遠端計算節點運作。閘道可以在這部電腦上叫用螢幕擷取、相機和系統命令。
+ æ¤é›»è…¦å°‡ä½œç‚ºé 端計算節點é‹ä½œã€‚é–˜é“å¯ä»¥åœ¨é€™éƒ¨é›»è…¦ä¸Šå«ç”¨èž¢å¹•æ“·å–ã€ç›¸æ©Ÿå’Œç³»çµ±å‘½ä»¤ã€‚
- 螢幕擷取
+ 螢幕擷å–
- 遠端螢幕存取
+ é 端螢幕å˜å–
- 相機
+ 相機
- 遠端相機存取
+ é 端相機å˜å–
- 系統命令
+ 系統命令
- 遠端命令執行
+ é 端命令執行
- Canvas 算繪
+ Canvas 算繪
- 視覺工作區輸出
+ 視覺工作å€è¼¸å‡º
- 通知
+ 通知
- 系統通知
+ 系統通知
⏳ 正在處理…
- 閘道傳回空回應
+ é–˜é“傳回空回應
- 閘道已中斷連線
+ é–˜é“已䏿–·é€£ç·š
- 與閘道的連線已中斷。按一下「下一步」略過精靈,或等待重新連線。
+ 與閘é“çš„é€£ç·šå·²ä¸æ–·ã€‚按一下「下一æ¥ã€ç•¥éŽç²¾éˆï¼Œæˆ–ç‰å¾…釿–°é€£ç·šã€‚
- 閘道針對 wizard.next 傳回空回應
+ é–˜é“é‡å° wizard.next 傳回空回應
- 沒有可用的選項
+ 沒有å¯ç”¨çš„é¸é …
- 是
+ 是
- 否 / 略過
+ å¦ / ç•¥éŽ
- 正在等待...
+ æ£åœ¨ç‰å¾…...
- 按一下「下一步」繼續。
+ 按一下「下一æ¥ã€ç¹¼çºŒã€‚
- 精靈錯誤
+ ç²¾éˆéŒ¯èª¤
- 略過精靈
+ ç•¥éŽç²¾éˆ
- 正在連線到閘道...
+ æ£åœ¨é€£ç·šåˆ°é–˜é“...
- 請等待連線建立...
+ è«‹ç‰å¾…連線建立...
- 路由優先順序
+ è·¯ç”±å„ªå…ˆé †åº
- 繫結會控制哪個代理程式處理各頻道的訊息。優先順序:對等 > 帳戶 > 頻道 > 預設。
+ ç¹«çµæœƒæŽ§åˆ¶å“ªå€‹ä»£ç†ç¨‹å¼è™•ç†å„é »é“的訊æ¯ã€‚å„ªå…ˆé †åºï¼šå°ç‰ > 帳戶 > é »é“ > é è¨ã€‚
- 單一代理程式模式
+ 單一代ç†ç¨‹å¼æ¨¡å¼
- 所有訊息都會路由至預設代理程式。新增繫結以進行多代理程式路由。
+ 所有訊æ¯éƒ½æœƒè·¯ç”±è‡³é è¨ä»£ç†ç¨‹å¼ã€‚新增繫çµä»¥é€²è¡Œå¤šä»£ç†ç¨‹å¼è·¯ç”±ã€‚
- 正在編輯從已連線閘道擷取的閘道設定 (openclaw.json)
+ æ£åœ¨ç·¨è¼¯å¾žå·²é€£ç·šé–˜é“æ“·å–的閘é“è¨å®š (openclaw.json)
- 從樹狀目錄選取區段以編輯其設定。
+ 從樹狀目錄é¸å–倿®µä»¥ç·¨è¼¯å…¶è¨å®šã€‚
- 閘道權杖
+ é–˜é“æ¬Šæ–
Token
@@ -2323,25 +2326,25 @@
在此貼上設定碼…
- 連線到閘道以檢視交談。
+ 連線到閘é“以檢視交談。
- 找不到工作階段
+ 找ä¸åˆ°å·¥ä½œéšŽæ®µ
- Cron API 尚未接線
+ Cron API 尚未接線
- 正在顯示範例資料。OpenClawGatewayClient 中尚未提供 Cron 管理方法。
+ æ£åœ¨é¡¯ç¤ºç¯„例資料。OpenClawGatewayClient ä¸å°šæœªæä¾› Cron ç®¡ç†æ–¹æ³•。
- 工作區檔案
+ å·¥ä½œå€æª”案
OpenClaw Chat
- 輸入命令...
+ 輸入命令...
OpenClaw
@@ -2356,10 +2359,10 @@
OpenClaw Canvas
- 重新載入
+ 釿–°è¼‰å…¥
- 重新載入 Canvas
+ 釿–°è¼‰å…¥ Canvas
OpenClaw Menu
@@ -2368,67 +2371,67 @@
📊 使用量與成本
- 在瀏覽器中開啟
+ 在ç€è¦½å™¨ä¸é–‹å•Ÿ
- 關閉
+ 關閉
- 在瀏覽器中開啟
+ 在ç€è¦½å™¨ä¸é–‹å•Ÿ
- 刪除工作
+ 刪除工作
更多語音設定…
- ElevenLabs API 金鑰
+ ElevenLabs API 金鑰
- API 金鑰會使用 Windows DPAPI 加密儲存。修改其他欄位時若保持空白,則保留先前儲存的值。
+ API 金鑰會使用 Windows DPAPI åŠ å¯†å„²å˜ã€‚ä¿®æ”¹å…¶ä»–æ¬„ä½æ™‚è‹¥ä¿æŒç©ºç™½ï¼Œå‰‡ä¿ç•™å…ˆå‰å„²å˜çš„值。
- ElevenLabs 模型
+ ElevenLabs 模型
eleven_multilingual_v2
- ElevenLabs 語音 ID
+ ElevenLabs 語音 ID
- 提供者
+ æä¾›è€…
ElevenLabs
- Piper(本機 ML,建議)
+ Piper(本機 ML,建è°ï¼‰
- Windows 內建語音
+ Windows 內建語音
🎙️ 語音與音訊
- 設定語音轉文字和語音互動設定。所有語音處理均在本機本地執行。
+ è¨å®šèªžéŸ³è½‰æ–‡å—和語音互動è¨å®šã€‚所有語音處ç†å‡åœ¨æœ¬æ©Ÿæœ¬åœ°åŸ·è¡Œã€‚
- 語音轉文字
+ 語音轉文å—
- 透過麥克風啟用語音輸入。需要下載 Whisper 模型。
+ é€éŽéº¥å…‹é¢¨å•Ÿç”¨èªžéŸ³è¼¸å…¥ã€‚需è¦ä¸‹è¼‰ Whisper 模型。
- 啟用語音輸入
+ 啟用語音輸入
- 語音模型
+ 語音模型
- 模型大小
+ 模型大å°
Tiny (~75 MB) — 快速,基本精確度
@@ -2440,103 +2443,103 @@
Small (~466 MB) — 高精確度
- 下載模型
+ 下載模型
- 語言
+ 語言
- 自動偵測
+ è‡ªå‹•åµæ¸¬
- 英文
+ 英文
- 西班牙文
+ 西ç牙文
- 法文
+ 法文
- 德文
+ å¾·æ–‡
- 日文
+ 日文
- 中文
+ 䏿–‡
- 韓文
+ 韓文
- 葡萄牙文
+ è‘¡è„牙文
- 義大利文
+ 義大利文
- 語音聊天
+ 語音èŠå¤©
- 靜音逾時(秒)
+ éœéŸ³é€¾æ™‚(ç§’)
- 朗讀回應
+ 朗讀回應
- 音訊回饋音效
+ 音訊回饋音效
🔊 Companion 語音
- 選擇朗讀回應時使用的語音。
+ 鏿“‡æœ—讀回應時使用的語音。
- 提供者
+ æä¾›è€…
- Piper(本機神經語音)
+ Piper(本機神經語音)
- Windows(內建神經語音)
+ Windows(內建神經語音)
- ElevenLabs(雲端,需要 API 金鑰)
+ ElevenLabs(雲端,éœ€è¦ API 金鑰)
- 語音
+ 語音
- 下載語音
+ 下載語音
- 刪除
+ 刪除
▶ 預覽
- 語音從 sherpa-onnx 專案的 GitHub 發行版下載(低品質約 25 MB,高品質最高約 150 MB)。它們完全在本機執行;無音訊離開您的裝置。
+ 語音從 sherpa-onnx 專案的 GitHub 發行版下載(低å“質約 25 MB,高å“質最高約 150 MB)。它們完全在本機執行;無音訊離開您的è£ç½®ã€‚
- 語音
+ 語音
▶ 預覽語音
- API 金鑰
+ API 金鑰
- 語音 ID
+ 語音 ID
- 模型(可選)
+ 模型(å¯é¸)
- 所有語音處理均完全在您的裝置上執行。不會向任何雲端服務傳送音訊資料。
+ 所有語音處ç†å‡å®Œå…¨åœ¨æ‚¨çš„è£ç½®ä¸ŠåŸ·è¡Œã€‚䏿œƒå‘任何雲端æœå‹™å‚³é€éŸ³è¨Šè³‡æ–™ã€‚
✅ 模型就緒
@@ -2545,25 +2548,25 @@
⬇️ 需要下載
- 重新下載
+ 釿–°ä¸‹è¼‰
- 正在下載...
+ æ£åœ¨ä¸‹è¼‰...
- 正在下載... {0}%
+ æ£åœ¨ä¸‹è¼‰... {0}%
- 下載已取消
+ ä¸‹è¼‰å·²å–æ¶ˆ
❌ 操作失敗(參見 Debug 日誌)
- 已下載
+ 已下載
- 語音已在此 PC 上就緒({0} MB)。
+ èªžéŸ³å·²åœ¨æ¤ PC 上就緒({0} MB)。
語音尚未下載。按下載以取得模型(根據品質約 25–150 MB)。
@@ -2584,52 +2587,52 @@
下載完成。正在解壓縮…
- 下載已取消。
+ ä¸‹è¼‰å·²å–æ¶ˆã€‚
- 下載失敗(參見 Debug 日誌)。
+ 下載失敗(åƒè¦‹ Debug 日誌)。
- 重試下載
+ é‡è©¦ä¸‹è¼‰
- 已刪除語音。
+ 已刪除語音。
- 刪除失敗(參見 Debug 日誌)。
+ 刪除失敗(åƒè¦‹ Debug 日誌)。
- 您好!這是您的 Companion 在說話。
+ 您好!這是您的 Companion 在說話。
- 預覽失敗(參見 Debug 日誌)。
+ é 覽失敗(åƒè¦‹ Debug 日誌)。
- 載入語音時發生錯誤(參見 Debug 日誌)。
+ 載入語音時發生錯誤(åƒè¦‹ Debug 日誌)。
▶ 正在播放...
- Companion 語音
+ Companion 語音
- 就緒
+ 就緒
- 按開始並開始說話
+ 按開始並開始說話
- 按開始以開始
+ 按開始以開始
- 開始聆聽
+ é–‹å§‹è†è½
- 靜音
+ éœéŸ³
- 語音設定
+ 語音è¨å®š
🗣️ 正在聆聽...
@@ -2638,148 +2641,148 @@
現在請說話 — 我在聆聽
- 正在初始化...
+ æ£åœ¨åˆå§‹åŒ–...
- 正在啟動
+ æ£åœ¨å•Ÿå‹•
- 正在下載語音模型...
+ æ£åœ¨ä¸‹è¼‰èªžéŸ³æ¨¡åž‹...
- 正在下載模型... {0}%
+ æ£åœ¨ä¸‹è¼‰æ¨¡åž‹... {0}%
- 正在載入語音模型...
+ æ£åœ¨è¼‰å…¥èªžéŸ³æ¨¡åž‹...
- 正在啟動麥克風...
+ æ£åœ¨å•Ÿå‹•麥克風...
- 正在停止...
+ æ£åœ¨åœæ¢...
- 錯誤
+ 錯誤
- 遇到錯誤(參見 Debug 日誌)
+ é‡åˆ°éŒ¯èª¤(åƒè¦‹ Debug 日誌)
- 已靜音
+ å·²éœéŸ³
- 停止
+ åœæ¢
- 已停止
+ å·²åœæ¢
- 正在啟動...
+ æ£åœ¨å•Ÿå‹•...
- 正在聆聽
+ æ£åœ¨è†è½
- 正在處理...
+ æ£åœ¨è™•ç†...
- 未知
+ 未知
- 正在初始化麥克風...
+ æ£åœ¨åˆå§‹åŒ–麥克風...
- 正在轉錄您的語音...
+ æ£åœ¨è½‰éŒ„您的語音...
- 發生錯誤
+ 發生錯誤
Companion Voice
- 就緒
+ 就緒
- 按開始以開始
+ 按開始以開始
- 開始聆聽
+ é–‹å§‹è†è½
- 下載模型
+ 下載模型
- 下載語音
+ 下載語音
▶ 預覽語音
- 偵錯覆寫
+ åµéŒ¯è¦†å¯«
- 按聊天容器覆寫全域的「使用標準閘道聊天介面」設定。應用程式重新啟動後將重設。
+ 按èŠå¤©å®¹å™¨è¦†å¯«å…¨åŸŸçš„「使用標準閘é“èŠå¤©ä»‹é¢ã€è¨å®šã€‚應用程å¼é‡æ–°å•Ÿå‹•後將é‡è¨ã€‚
- 中心聊天分頁 UI:
+ ä¸å¿ƒèŠå¤©åˆ†é UI:
- 系統匣聊天彈出視窗 UI:
+ 系統匣èŠå¤©å½ˆå‡ºè¦–窗 UI:
- 不覆寫
+ ä¸è¦†å¯«
- 強制使用閘道聊天 UI
+ 強制使用閘é“èŠå¤© UI
- 強制使用伴侶聊天 UI
+ 強制使用伴侶èŠå¤© UI
- 不覆寫
+ ä¸è¦†å¯«
- 強制使用閘道聊天 UI
+ 強制使用閘é“èŠå¤© UI
- 強制使用伴侶聊天 UI
+ 強制使用伴侶èŠå¤© UI
- 歡迎使用 OpenClaw
+ æ¡è¿Žä½¿ç”¨ OpenClaw
- 今天我能幫你做什麼?
+ ä»Šå¤©æˆ‘èƒ½å¹«ä½ åšä»€éº¼ï¼Ÿ
- 完成
+ 完æˆ
- 執行中
+ 執行ä¸
- 錯誤
+ 錯誤
- 已中斷
+ 已䏿–·
- 載入較早的訊息
+ 載入較早的訊æ¯
- 工具呼叫
+ 工具呼å«
- 工具輸出
+ 工具輸出
- 工具錯誤
+ 工具錯誤
- 工具輸入
+ 工具輸入
- 工具
+ 工具
工具 · {0}
@@ -2794,91 +2797,91 @@
{0} 正在思考…
- 預設
+ é è¨
- 自動
+ 自動
- 最大
+ 最大
- 傳訊息給助理(按 Enter 傳送)
+ 傳訊æ¯çµ¦åŠ©ç†ï¼ˆæŒ‰ Enter 傳é€ï¼‰
連線中…
- 未連線
+ 未連線
Gateway update required — incompatible version
- 附加
+ 附åŠ
- 語音
+ 語音
正在聆聽…
- 取消
+ å–æ¶ˆ
- 更多
+ 更多
- 傳送
+ 傳é€
- 停止
+ åœæ¢
助理處理中…
- 複製訊息
+ 複製訊æ¯
- 朗讀
+ 朗讀
- 刪除訊息
+ 刪除訊æ¯
… [已截斷 {0} 位元組]
- 允許
+ å…許
- 拒絕
+ 拒絕
連線到閘道中…
- 在側邊欄選擇一個工作階段以開始聊天。
+ 在å´é‚Šæ¬„鏿“‡ä¸€å€‹å·¥ä½œéšŽæ®µä»¥é–‹å§‹èŠå¤©ã€‚
- 傳送失敗
+ 傳é€å¤±æ•—
- 中止失敗
+ 䏿¢å¤±æ•—
- 載入歷史記錄失敗
+ 載入æ·å²è¨˜éŒ„失敗
- 助理已回覆
+ 助ç†å·²å›žè¦†
連線已中斷 — 回覆已中斷。
- 測試語音輸入
+ 測試語音輸入
Toggle individual features that agents may request from this PC.
@@ -3064,7 +3067,7 @@
Resync
- 返回連線
+ 返回連線
🔗 Pending Operator/Node Pairing
@@ -3112,16 +3115,16 @@
Copy node ID
- {0}秒前
+ {0}ç§’å‰
- {0}分鐘前
+ {0}分é˜å‰
- {0}小時前
+ {0}å°æ™‚å‰
- {0}天前
+ {0}天å‰
OpenClaw Setup
@@ -3286,55 +3289,55 @@
Keep my setup
- 全部
+ 全部
- 頻道
+ é »é“
- 重新整理
+ 釿–°æ•´ç†
- 已設定
+ å·²è¨å®š
- 可用
+ å¯ç”¨
- 首頁
+ 首é
- 重新整理
+ 釿–°æ•´ç†
- 開發人員工具
+ 開發人員工具
- 隨 Windows 啟動
+ 隨 Windows 啟動
- 快速傳送快速鍵
+ 快速傳é€å¿«é€Ÿéµ
- 使用閘道的網頁聊天介面
+ 使用閘é“的網é èŠå¤©ä»‹é¢
- 顯示通知
+ 顯示通知
- 提示音
+ æç¤ºéŸ³
- 允許螢幕錄製
+ å…許螢幕錄製
- 允許相機錄製
+ å…許相機錄製
- 語音與音訊
+ 語音與音訊
- 沙盒
+ 沙盒
More diagnostics →
@@ -3514,34 +3517,34 @@
Connection Status
- 閘道已中斷
+ é–˜é“已䏿–·
- 請連接至閘道以載入繫結。
+ 請連接至閘é“以載入繫çµã€‚
- 開啟連線
+ 開啟連線
正在載入繫結…
- 等待您的核准
+ ç‰å¾…æ‚¨çš„æ ¸å‡†
- 未連線
+ 未連線
- 連線
+ 連線
- 操作員
+ æ“作員
- 從這台電腦向此閘道傳送命令並檢視工作階段。
+ 從這å°é›»è…¦å‘æ¤é–˜é“傳é€å‘½ä»¤ä¸¦æª¢è¦–工作階段。
- 未啟用
+ 未啟用
檢視工作階段 ›
@@ -3550,232 +3553,232 @@
檢視執行個體 ›
- 節點模式
+ 節點模å¼
- 啟用後,這台電腦會註冊為節點,並透過閘道向代理提供下列功能。
+ 啟用後,這å°é›»è…¦æœƒè¨»å†Šç‚ºç¯€é»ž,並é€éŽé–˜é“å‘ä»£ç†æä¾›ä¸‹åˆ—åŠŸèƒ½ã€‚
- 節點模式已停用
+ 節點模å¼å·²åœç”¨
- 複製
+ 複製
- 連線
+ 連線
管理權限 ›
- 協助我們修復此連線:
+ å”助我們修復æ¤é€£ç·š:
- SSH 通道已停止。
+ SSH 通é“å·²åœæ¢ã€‚
- 重新啟動通道
+ 釿–°å•Ÿå‹•通é“
- 編輯通道設定
+ 編輯通é“è¨å®š
- 貼上來自閘道主機的新設定代碼。
+ 貼上來自閘é“主機的新è¨å®šä»£ç¢¼ã€‚
在此貼上設定代碼…
- 套用
+ 套用
- 在閘道主機上執行:
+ 在閘é“主機上執行:
- 複製
+ 複製
- 連線
+ 連線
- 中斷連線
+ 䏿–·é€£ç·š
- 已儲存的閘道
+ 已儲å˜çš„é–˜é“
- 閘道
+ é–˜é“
- 掃描
+ 掃æ
- 新增閘道
+ 新增閘é“
- 尚無已儲存的閘道。
+ 尚無已儲å˜çš„é–˜é“。
- 在您的網路上探索到
+ 在您的網路上探索到
- 新增閘道
+ 新增閘é“
開始使用 — 安裝本機閘道
- 最快的入門方式:在這台電腦上設定由 WSL 託管的閘道,並將其設為使用中的連線。
+ 最快的入門方å¼:在這å°é›»è…¦ä¸Šè¨å®šç”± WSL 託管的閘é“,並將其è¨ç‚ºä½¿ç”¨ä¸çš„連線。
- 安裝
+ 安è£
- 或連線至現有閘道:
+ æˆ–é€£ç·šè‡³ç¾æœ‰é–˜é“:
- 直接
+ 直接
- URL + 權杖
+ URL + 權æ–
- 設定代碼
+ è¨å®šä»£ç¢¼
- 貼上 QR 內容
+ 貼上 QR 內容
- 每種方法都支援選用的 SSH 通道。
+ æ¯ç¨®æ–¹æ³•都支æ´é¸ç”¨çš„ SSH 通é“。
- 或在您的網路上尋找。
+ 或在您的網路上尋找。
- 掃描
+ 掃æ
- 在您的網路上探索到
+ 在您的網路上探索到
- 返回
+ 返回
- 新增閘道
+ 新增閘é“
- 直接
+ 直接
- 設定代碼
+ è¨å®šä»£ç¢¼
- 本機 WSL
+ 本機 WSL
- 閘道 URL
+ é–˜é“ URL
ws://host.local:18790
- 共用權杖
+ 共用權æ–
- 貼上共用閘道權杖
+ è²¼ä¸Šå…±ç”¨é–˜é“æ¬Šæ–
- 名稱(選用)
+ å稱(é¸ç”¨)
- 我的閘道
+ 我的閘é“
- 貼上來自閘道的 QR 碼或 `openclaw qr` 輸出的設定代碼。
+ 貼上來自閘é“çš„ QR 碼或 `openclaw qr` 輸出的è¨å®šä»£ç¢¼ã€‚
在此貼上設定代碼…
- 解碼
+ 解碼
- 在這台電腦上安裝新的 WSL 閘道,並將其設為使用中的連線。安裝精靈會視需要安裝 WSL、佈建閘道,並自動配對這台裝置。
+ 在這å°é›»è…¦ä¸Šå®‰è£æ–°çš„ WSL é–˜é“,並將其è¨ç‚ºä½¿ç”¨ä¸çš„連線。安è£ç²¾éˆæœƒè¦–需è¦å®‰è£ WSLã€ä½ˆå»ºé–˜é“,並自動é…å°é€™å°è£ç½®ã€‚
- 安裝本機閘道
+ å®‰è£æœ¬æ©Ÿé–˜é“
這台電腦無法進行本機 WSL 設定 — 安裝精靈無法初始化。
- 使用 SSH 通道(選用)
+ 使用 SSH 通é“(é¸ç”¨)
- SSH 使用者
+ SSH 使用者
user
- SSH 主機
+ SSH 主機
machine-name
- 遠端連接埠
+ é 端連接åŸ
- 本機連接埠
+ 本機連接åŸ
- 儲存並連線
+ 儲å˜ä¸¦é€£ç·š
- 取消
+ å–æ¶ˆ
⏱️ Cron 工作
- 重新整理
+ 釿–°æ•´ç†
- 新增工作
+ 新增工作
- 閘道已中斷
+ é–˜é“已䏿–·
- 請連接至閘道以載入 cron 工作。
+ 請連接至閘é“以載入 cron 工作。
- 開啟連線
+ 開啟連線
- 新增 Cron 工作
+ 新增 Cron 工作
- 名稱
+ å稱
- 晨間簡報
+ æ™¨é–“ç°¡å ±
- 排程
+ 排程
- 每
+ æ¯
- 於
+ æ–¼
Cron
- 快速預設
+ 快速é è¨
⏰ 每小時
@@ -3793,13 +3796,13 @@
📅 每週
- 運算式
+ é‹ç®—å¼
- 時區
+ 時å€
- 選用
+ é¸ç”¨
America/New_York
@@ -3826,310 +3829,310 @@
UTC
- 每
+ æ¯
- 單位
+ å–®ä½
- 分鐘
+ 分é˜
- 小時
+ å°æ™‚
- 天
+ 天
- 執行時間
+ 執行時間
- 日期
+ 日期
15:30
- 執行後刪除工作
+ 執行後刪除工作
- 提示 / 內容
+ æç¤º / 內容
- 代理應該做什麼?
+ ä»£ç†æ‡‰è©²åšä»€éº¼?
- 傳遞
+ 傳éž
- 靜默(無)
+ éœé»˜(ç„¡)
- 通知我(通告)
+ 通知我(通告)
- 通道
+ 通é“
- 例如 webchat
+ 例如 webchat
- 進階選項
+ 進階é¸é …
- 工作階段行為
+ 工作階段行為
- 每次執行建立新工作階段
+ æ¯æ¬¡åŸ·è¡Œå»ºç«‹æ–°å·¥ä½œéšŽæ®µ
- 繼續主要工作階段
+ 繼續主è¦å·¥ä½œéšŽæ®µ
- 喚醒模式
+ 喚醒模å¼
- 立即(即時)
+ ç«‹å³(峿™‚)
- 佇列(等待閒置)
+ 佇列(ç‰å¾…é–’ç½®)
- 取消
+ å–æ¶ˆ
- 建立工作
+ 建立工作
正在載入 cron 工作…
- 未設定 cron 工作
+ 未è¨å®š cron 工作
- 透過閘道設定 cron 工作後,它們會顯示在此處。
+ é€éŽé–˜é“è¨å®š cron 工作後,它們會顯示在æ¤è™•。
- 狀態
+ 狀態
- 已複製
+ 已複製
- 無覆寫
+ 無覆寫
- 強制使用閘道聊天介面
+ 強制使用閘é“èŠå¤©ä»‹é¢
- 強制使用伴侶聊天介面
+ 強制使用伴侶èŠå¤©ä»‹é¢
- 無覆寫
+ 無覆寫
- 強制使用閘道聊天介面
+ 強制使用閘é“èŠå¤©ä»‹é¢
- 強制使用伴侶聊天介面
+ 強制使用伴侶èŠå¤©ä»‹é¢
- 重新整理
+ 釿–°æ•´ç†
- 複製
+ 複製
- 開啟檔案
+ 開啟檔案
- 需要配對核准
+ 需è¦é…å°æ ¸å‡†
- 節點已連線,但在您核准配對要求之前,其功能不會啟用。
+ 節點已連線,ä½†åœ¨æ‚¨æ ¸å‡†é…å°è¦æ±‚之å‰,å…¶åŠŸèƒ½ä¸æœƒå•Ÿç”¨ã€‚
- 在「連線」頁面檢視
+ 在「連線ã€é 颿ª¢è¦–
- 返回連線
+ 返回連線
- 權限
+ 權é™
- 節點模式
+ 節點模å¼
- 啟用後,這台電腦會註冊為節點,並透過閘道向代理提供下列功能。
+ 啟用後,這å°é›»è…¦æœƒè¨»å†Šç‚ºç¯€é»ž,並é€éŽé–˜é“å‘ä»£ç†æä¾›ä¸‹åˆ—åŠŸèƒ½ã€‚
- 功能
+ 功能
- 切換代理可能從這台電腦要求的各項功能。
+ 切æ›ä»£ç†å¯èƒ½å¾žé€™å°é›»è…¦è¦æ±‚çš„å„é …åŠŸèƒ½ã€‚
- 整合
+ æ•´åˆ
- 預設動作
+ é è¨å‹•作
- 當命令不符合下列任何規則時會發生什麼。
+ 當命令ä¸ç¬¦åˆä¸‹åˆ—任何è¦å‰‡æ™‚會發生什麼。
- 模式由左至右與完整命令列比對。使用 * 作為萬用字元。變更會自動儲存。
+ 模å¼ç”±å·¦è‡³å³èˆ‡å®Œæ•´å‘½ä»¤åˆ—比å°ã€‚使用 * 作為è¬ç”¨å—元。變更會自動儲å˜ã€‚
- 已儲存
+ 已儲å˜
- 0 條規則
+ 0 æ¢è¦å‰‡
- 尚無規則。在下方新增模式以允許或拒絕特定命令。
+ å°šç„¡è¦å‰‡ã€‚在下方新增模å¼ä»¥å…許或拒絕特定命令。
- Windows 隱私
+ Windows éš±ç§
- 攝影機、麥克風與螢幕存取
+ æ”影機ã€éº¥å…‹é¢¨èˆ‡èž¢å¹•å˜å–
- 系統層級的隱私權限由 Windows 管理。開啟 Windows 隱私設定以授予或撤銷 OpenClaw 的存取權。
+ ç³»çµ±å±¤ç´šçš„éš±ç§æ¬Šé™ç”± Windows 管ç†ã€‚開啟 Windows éš±ç§è¨å®šä»¥æŽˆäºˆæˆ–撤銷 OpenClaw çš„å˜å–權。
- 節點沙箱已開啟
+ 節點沙箱已開啟
- 代理在這台電腦上執行的程式受到隔離。
+ 代ç†åœ¨é€™å°é›»è…¦ä¸ŠåŸ·è¡Œçš„程å¼å—到隔離。
- 哪些內容受隔離
+ 哪些內容å—隔離
檢視此頁面涵蓋的命令 — 以及需要其他控制項的命令。
- 代理透過 Windows 節點執行的命令 (
+ 代ç†é€éŽ Windows 節點執行的命令 (
system.run
- ) 受下方設定隔離。
+ ) å—下方è¨å®šéš”離。
代理直接在閘道上執行的命令則不受隔離。如果閘道位於這台電腦的 WSL 上,它們可能會存取您的 Windows 檔案、剪貼簿和網路 — 請使用閘道的執行核准。
- 沙箱無法使用
+ 沙箱無法使用
- 深入了解 MXC 沙箱
+ 深入了解 MXC 沙箱
- 安全等級
+ 安全ç‰ç´š
- 一鍵設定下方所有控制項。自訂資料夾會保留您的設定。
+ 一éµè¨å®šä¸‹æ–¹æ‰€æœ‰æŽ§åˆ¶é …。自訂資料夾會ä¿ç•™æ‚¨çš„è¨å®šã€‚
🔒 鎖定
- 無網際網路、無剪貼簿、無標準使用者資料夾。
+ 無網際網路ã€ç„¡å‰ªè²¼ç°¿ã€ç„¡æ¨™æº–使用者資料夾。
🛡 建議
- 網際網路開啟。對「文件」、「下載」、「桌面」為唯讀。剪貼簿可讀。
+ 網際網路開啟。å°ã€Œæ–‡ä»¶ã€ã€ã€Œä¸‹è¼‰ã€ã€ã€Œæ¡Œé¢ã€ç‚ºå”¯è®€ã€‚剪貼簿å¯è®€ã€‚
⚠ 不受保護
- 網際網路開啟。對「文件」、「下載」、「桌面」可讀寫。剪貼簿可讀寫。
+ 網際網路開啟。å°ã€Œæ–‡ä»¶ã€ã€ã€Œä¸‹è¼‰ã€ã€ã€Œæ¡Œé¢ã€å¯è®€å¯«ã€‚剪貼簿å¯è®€å¯«ã€‚
- 自訂資料夾授權仍然有效
+ 自訂資料夾授權ä»ç„¶æœ‰æ•ˆ
📁 檔案
- 預設情況下,代理只有一個可讀寫的暫存工作資料夾。在下方授予使用者資料夾的存取權限。SSH 金鑰、瀏覽器設定檔以及 OpenClaw 本身的設定一律會被封鎖。
+ é è¨æƒ…æ³ä¸‹,代ç†åªæœ‰ä¸€å€‹å¯è®€å¯«çš„æš«å˜å·¥ä½œè³‡æ–™å¤¾ã€‚在下方授予使用者資料夾的å˜å–權é™ã€‚SSH 金鑰ã€ç€è¦½å™¨è¨å®šæª”ä»¥åŠ OpenClaw 本身的è¨å®šä¸€å¾‹æœƒè¢«å°éŽ–ã€‚
- 文件
+ 文件
- 已封鎖
+ å·²å°éŽ–
- 唯讀
+ 唯讀
- 讀取與寫入
+ 讀å–與寫入
- 下載
+ 下載
- 已封鎖
+ å·²å°éŽ–
- 唯讀
+ 唯讀
- 讀取與寫入
+ 讀å–與寫入
- 桌面
+ 桌é¢
- 已封鎖
+ å·²å°éŽ–
- 唯讀
+ 唯讀
- 讀取與寫入
+ 讀å–與寫入
- PATH 上的工具目錄在沙箱內也以唯讀方式授予,因此 git、node、python 與 dotnet 等命令列工具可使用。一律不會授予磁碟機根目錄。
+ PATH ä¸Šçš„å·¥å…·ç›®éŒ„åœ¨æ²™ç®±å…§ä¹Ÿä»¥å”¯è®€æ–¹å¼æŽˆäºˆ,å› æ¤ gitã€nodeã€python 與 dotnet ç‰å‘½ä»¤åˆ—工具å¯ä½¿ç”¨ã€‚䏀律䏿œƒæŽˆäºˆç£ç¢Ÿæ©Ÿæ ¹ç›®éŒ„。
- 自訂資料夾
+ 自訂資料夾
- 自訂授權會疊加於上述規則之上。在「文件/下載/桌面」內新增具有更寬權限的資料夾,會覆寫該路徑的行層級設定。
+ è‡ªè¨‚æŽˆæ¬Šæœƒç–ŠåŠ æ–¼ä¸Šè¿°è¦å‰‡ä¹‹ä¸Šã€‚在「文件/下載/桌é¢ã€å…§æ–°å¢žå…·æœ‰æ›´å¯¬æ¬Šé™çš„資料夾,會覆寫該路徑的行層級è¨å®šã€‚
- 已封鎖
+ å·²å°éŽ–
- 唯讀
+ 唯讀
- 讀取與寫入
+ 讀å–與寫入
- 尚未新增自訂資料夾。
+ 尚未新增自訂資料夾。
- 新增資料夾
+ 新增資料夾
📋 剪貼簿
- 剪貼簿通常包含密碼、權杖與私人文字。讀取權限可能會將這些洩露給代理(若已啟用,也會洩露至網際網路)。
+ 剪貼簿通常包å«å¯†ç¢¼ã€æ¬Šæ–與ç§äººæ–‡å—ã€‚è®€å–æ¬Šé™å¯èƒ½æœƒå°‡é€™äº›æ´©éœ²çµ¦ä»£ç†(若已啟用,也會洩露至網際網路)。
- 無(預設)
+ ç„¡(é è¨)
讀取 — 代理可以看到您複製的內容
@@ -4138,34 +4141,34 @@
寫入 — 代理可以取代您的剪貼簿(無法讀取)
- 讀取與寫入
+ 讀å–與寫入
📡 網路
- 允許網際網路
+ å…許網際網路
- 如果啟用檔案或剪貼簿讀取權限,代理可能會透過網際網路傳送這些資料。
+ å¦‚æžœå•Ÿç”¨æª”æ¡ˆæˆ–å‰ªè²¼ç°¿è®€å–æ¬Šé™,代ç†å¯èƒ½æœƒé€éŽç¶²éš›ç¶²è·¯å‚³é€é€™äº›è³‡æ–™ã€‚
- 尚未包含本機網路裝置與共用。
+ å°šæœªåŒ…å«æœ¬æ©Ÿç¶²è·¯è£ç½®èˆ‡å…±ç”¨ã€‚
⏱ 限制
- 命令逾時:30 秒
+ 命令逾時:30 秒
- 最大擷取輸出(超過此值會被截斷;不限制磁碟、記憶體或 CPU):
+ 最大擷å–輸出(è¶…éŽæ¤å€¼æœƒè¢«æˆªæ–·;ä¸é™åˆ¶ç£ç¢Ÿã€è¨˜æ†¶é«”或 CPU):
1 MiB
- 4 MiB(預設)
+ 4 MiB(é è¨)
16 MiB
@@ -4174,190 +4177,190 @@
64 MiB
- 返回連線
+ 返回連線
- 工作階段
+ 工作階段
- 重新整理
+ 釿–°æ•´ç†
- 全部
+ 全部
- 閘道已中斷
+ é–˜é“已䏿–·
- 請連接至閘道以載入工作階段。
+ 請連接至閘é“以載入工作階段。
- 開啟連線
+ 開啟連線
正在載入工作階段…
- 沒有作用中的工作階段
+ 沒有作用ä¸çš„工作階段
- 重設
+ é‡è¨
- 壓縮
+ 壓縮
- 刪除
+ 刪除
- 設定
+ è¨å®š
- 一般
+ 一般
- 通知
+ 通知
- 隱私
+ éš±ç§
- 本機閘道
+ 本機閘é“
- 偵測到 MSIX 安裝
+ 嵿¸¬åˆ° MSIX 安è£
- 透過 Windows 設定 &#x2192; 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。
+ é€éŽ Windows è¨å®š &#x2192; 應用程å¼ç§»é™¤ OpenClaw 䏿œƒæ¸…除本機閘é“(WSL 散發版ã€ç£ç¢Ÿæ˜ åƒ)。請先使用下方的「移除本機閘é“ã€,然後å†è§£é™¤å®‰è£ OpenClaw。
- 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。
+ 移除 WSL 散發版 (OpenClawGateway)ã€å…¶ç£ç¢Ÿæ˜ åƒã€è‡ªå‹•å•Ÿå‹•é …ç›®,並清除閘é“èªè‰ã€‚您的 MCP æ¬Šæ–æœƒä¿ç•™ã€‚導覽æµç¨‹å°‡é‡è¨ã€‚
- 移除本機閘道
+ 移除本機閘é“
- 已儲存
+ 已儲å˜
🧩 技能
- 所有代理
+ 所有代ç†
- 已啟用
+ 已啟用
- 已停用
+ å·²åœç”¨
正在載入技能…
- 未安裝技能
+ æœªå®‰è£æŠ€èƒ½
- 技能可擴充您代理的能力。透過 CLI 或 ClawHub 安裝技能。
+ æŠ€èƒ½å¯æ“´å……您代ç†çš„能力。é€éŽ CLI 或 ClawHub å®‰è£æŠ€èƒ½ã€‚
- 閘道已中斷
+ é–˜é“已䏿–·
- 請連接至閘道以載入使用量資料。
+ 請連接至閘é“以載入使用é‡è³‡æ–™ã€‚
- 開啟連線
+ 開啟連線
正在載入每日成本…
- 此期間內無每日使用量
+ æ¤æœŸé–“å…§ç„¡æ¯æ—¥ä½¿ç”¨é‡
- 測試語音輸入
+ 測試語音輸入
- 錄製
+ 錄製
正在載入工作區檔案…
- 網頁聊天無法使用
+ ç¶²é èŠå¤©ç„¡æ³•使用
- 重試
+ é‡è©¦
- 連線狀態
+ 連線狀態
- 狀態機
+ 狀態機
- 操作員
+ æ“作員
- 關閉
+ 關閉
- 正在連線
+ æ£åœ¨é€£ç·š
- 已連線
+ 已連線
- 正在配對
+ æ£åœ¨é…å°
- 錯誤
+ 錯誤
- 節點
+ 節點
- 關閉
+ 關閉
- 正在連線
+ æ£åœ¨é€£ç·š
- 已連線
+ 已連線
- 正在配對
+ æ£åœ¨é…å°
- 錯誤
+ 錯誤
- 閘道
+ é–˜é“
- 認證
+ èªè‰
- 設定代碼
+ è¨å®šä»£ç¢¼
- 貼上設定代碼以建立端對端連線。
+ 貼上è¨å®šä»£ç¢¼ä»¥å»ºç«‹ç«¯å°ç«¯é€£ç·šã€‚
貼上設定代碼…
- 連線
+ 連線
- 中斷連線
+ 䏿–·é€£ç·š
- 直接連線
+ 直接連線
- 使用閘道 URL 和共用權杖(管理員範圍)連線。
+ ä½¿ç”¨é–˜é“ URL 和共用權æ–(管ç†å“¡ç¯„åœ)連線。
ws://localhost:18790
@@ -4366,66 +4369,66 @@
ws://localhost:18790
- 閘道 URL
+ é–˜é“ URL
- 共用閘道權杖
+ å…±ç”¨é–˜é“æ¬Šæ–
- 權杖
+ 權æ–
- 連線
+ 連線
- SSH 通道
+ SSH 通é“
- SSH 使用者
+ SSH 使用者
user
- SSH 主機
+ SSH 主機
192.168.x.x
- 遠端連接埠
+ é 端連接åŸ
- 本機連接埠
+ 本機連接åŸ
- 事件時間軸
+ 事件時間軸
- 複製
+ 複製
- 清除
+ 清除
- 診斷套件
+ 診斷套件
- 分享前請檢閱
+ 分享å‰è«‹æª¢é–±
- 套件產生器會排除敏感權杖、命令引數、內容與錄製。
+ å¥—ä»¶ç”¢ç”Ÿå™¨æœƒæŽ’é™¤æ•æ„Ÿæ¬Šæ–ã€å‘½ä»¤å¼•數ã€å…§å®¹èˆ‡éŒ„製。
- 已中斷連線
+ 已䏿–·é€£ç·š
- 操作
+ æ“作
- 節點
+ 節點
- 進階
+ 進階
-
+
\ No newline at end of file
diff --git a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
index 2ec2024cc..69f4b5b42 100644
--- a/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
+++ b/tests/OpenClaw.Tray.Tests/DiagnosticsPageContractTests.cs
@@ -308,6 +308,21 @@ public void DebugPage_HasInPageDetailView_ForLogOnly()
Assert.DoesNotContain("OnDetailClear", cs);
}
+ [Fact]
+ public void DebugPage_MainAndDetailViewsUseCanonicalCenteredWidthPattern()
+ {
+ var xaml = Read("src", "OpenClaw.Tray.WinUI", "Pages", "DebugPage.xaml");
+
+ Assert.Matches(
+ new System.Text.RegularExpressions.Regex(
+ @"x:Name=""MainView""[\s\S]{0,500}[\s\S]{0,300}
Date: Fri, 22 May 2026 17:59:54 -0400
Subject: [PATCH 090/115] fix(usage): tolerate gateway days off-by-one in cost
data
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
index 099680676..5f5656a33 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
@@ -43,7 +43,7 @@ public void Initialize()
// Only apply cached cost data when its period matches the current
// selection — otherwise the daily list briefly shows e.g. 30-day
// data while the selector reads "7 Days".
- if (_appState?.UsageCost != null && _appState.UsageCost.Days == _currentPeriodDays)
+ if (_appState?.UsageCost != null && Math.Abs(_appState.UsageCost.Days - _currentPeriodDays) <= 1)
{
UpdateUsageCost(_appState.UsageCost);
_dailyCostLoading.BeginRefresh();
@@ -100,7 +100,10 @@ public void UpdateUsage(GatewayUsageInfo usage)
public void UpdateUsageCost(GatewayCostUsageInfo cost)
{
- if (cost.Days != _currentPeriodDays)
+ // The gateway computes 'days' as Math.ceil(range / DAY_MS) + 1,
+ // which is off by one from the requested period (e.g. 8 for a 7-day
+ // request). Allow ±1 tolerance so the response is not silently dropped.
+ if (Math.Abs(cost.Days - _currentPeriodDays) > 1)
return;
if (cost.UpdatedAt < _lastAppliedUsageCostUpdatedAtUtc)
From 7f2095bf2bf118a1037383ec4f40a809ad28435c Mon Sep 17 00:00:00 2001
From: Christine Yan
Date: Fri, 22 May 2026 18:00:01 -0400
Subject: [PATCH 091/115] refactor(events): rename to Event Stream, remove all
filters
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/AgentEventsPage.xaml | 44 +++++++------------
.../Pages/AgentEventsPage.xaml.cs | 44 ++++++++++++++-----
.../Strings/en-us/Resources.resw | 8 ++--
.../Windows/HubWindow.xaml | 2 +-
4 files changed, 55 insertions(+), 43 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
index 77092f0bb..2d3a08457 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml
@@ -16,10 +16,16 @@
-
-
+
+
+
+
+
+
+
@@ -51,21 +57,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
+
+
+
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
index 49605d8e8..fbaf90fdc 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/AgentEventsPage.xaml.cs
@@ -13,7 +13,6 @@ public sealed partial class AgentEventsPage : Page
{
private const int MaxEvents = 400;
private readonly List _allEvents = new();
- private string _activeFilter = "all";
private string? _agentIdFilter;
private bool _filterDirty;
private AppState? _appState;
@@ -84,6 +83,35 @@ public void Initialize(HubWindow hub)
_appState.AgentEventAdded += OnAgentEventAdded;
_appState.PropertyChanged += OnAppStateChanged;
PopulateAgentFilter(hub);
+ UpdateLiveBadge();
+ }
+
+ private void UpdateLiveBadge()
+ {
+ var connected = _appState?.Status == ConnectionStatus.Connected;
+ DispatcherQueue?.TryEnqueue(() =>
+ {
+ if (connected)
+ {
+ LiveDot.Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(255, 255, 68, 68));
+ LiveText.Text = "Live";
+ LiveText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(255, 255, 68, 68));
+ LiveBadge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(32, 255, 68, 68));
+ }
+ else
+ {
+ LiveDot.Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(255, 128, 128, 128));
+ LiveText.Text = "Offline";
+ LiveText.Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(255, 128, 128, 128));
+ LiveBadge.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(
+ Microsoft.UI.ColorHelper.FromArgb(32, 128, 128, 128));
+ }
+ });
}
private void OnAppStateChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -93,6 +121,10 @@ private void OnAppStateChanged(object? sender, System.ComponentModel.PropertyCha
_allEvents.Clear();
ApplyFilter();
}
+ else if (e.PropertyName == nameof(AppState.Status))
+ {
+ UpdateLiveBadge();
+ }
}
private void OnAgentEventAdded(AgentEventInfo evt)
@@ -131,12 +163,6 @@ public void AddEvent(AgentEventInfo evt)
}
}
- private void OnFilterSelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
- {
- var tag = sender.SelectedItem?.Tag as string;
- _activeFilter = string.IsNullOrEmpty(tag) ? "all" : tag;
- ApplyFilter();
- }
private void ApplyFilter()
{
@@ -147,10 +173,6 @@ private void ApplyFilter()
filtered = filtered.Where(e => e.SessionKey != null &&
e.SessionKey.StartsWith($"agent:{_agentIdFilter}:", StringComparison.OrdinalIgnoreCase));
- // Filter by stream type (use ResolvedStream so "item" events with kind:"tool" match the Tool filter)
- if (_activeFilter != "all")
- filtered = filtered.Where(e => e.ResolvedStream.Equals(_activeFilter, StringComparison.OrdinalIgnoreCase));
-
var list = filtered.ToList();
EventsList.ItemsSource = list;
EventsList.Visibility = list.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index b61227152..5bca75982 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -1546,7 +1546,7 @@ On your gateway host (Mac/Linux), run:
Dashboard → openclaw://dashboard
- Agent Events
+ Event Stream
Agent
@@ -1585,10 +1585,10 @@ On your gateway host (Mac/Linux), run:
Patch
- No agent events yet
+ No events in this session
- Real-time agent events (tool calls, responses, errors) will appear here as agents run.
+ Events stream here in real-time as agents run. History clears when the app restarts.
Clear
@@ -2215,7 +2215,7 @@ On your gateway host (Mac/Linux), run:
Conversations
- Agent Events
+ Event Stream
Skills
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
index 8a1c4dc41..0c606e929 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml
@@ -153,7 +153,7 @@
-
+
From ae85282911da83b36246246f10e2a28ec1509fd1 Mon Sep 17 00:00:00 2001
From: Christine Yan
Date: Fri, 22 May 2026 18:00:07 -0400
Subject: [PATCH 092/115] test: update usage guard assertion for off-by-one
tolerance
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
tests/OpenClaw.Tray.Tests/AsyncListLoadingPageWiringTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/OpenClaw.Tray.Tests/AsyncListLoadingPageWiringTests.cs b/tests/OpenClaw.Tray.Tests/AsyncListLoadingPageWiringTests.cs
index 1eb7e3fcd..5b5048be5 100644
--- a/tests/OpenClaw.Tray.Tests/AsyncListLoadingPageWiringTests.cs
+++ b/tests/OpenClaw.Tray.Tests/AsyncListLoadingPageWiringTests.cs
@@ -50,7 +50,7 @@ public void UsagePage_GuardsStalePeriodResponses()
{
var source = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "UsagePage.xaml.cs");
- Assert.Contains("cost.Days != _currentPeriodDays", source);
+ Assert.Contains("Math.Abs(cost.Days - _currentPeriodDays) > 1", source);
Assert.Contains("cost.UpdatedAt < _lastAppliedUsageCostUpdatedAtUtc", source);
Assert.Contains("_dailyCostLoading.BeginInitialRefresh()", source);
}
From 334a9729a0af516e74488b92eca72c155912d969 Mon Sep 17 00:00:00 2001
From: Copilot
Date: Fri, 22 May 2026 15:12:54 -0700
Subject: [PATCH 093/115] Stop dropping usage.cost responses on period mismatch
The Usage page fires two usage.cost requests per refresh: one indirectly
via RequestUsageAsync() (always days=30) and one directly via the period
selector (default 7). UpdateUsageCost rejected any response where
cost.Days != _currentPeriodDays, so when the gateway only replied to one
of the two -- or didn't honor the days request param -- valid data was
silently thrown away and the Daily Cost spinner ran forever.
Now:
- Accept any usage.cost response and apply its data.
- If cost.Days is a valid selector value (7 or 30) but doesn't match the
user's current pick, silently snap the SelectorBar to that period so
the header isn't lying about what data is on screen.
- Clear ConnectionInfoBar on a successful UpdateUsageCost/UsageStatus so
late replies after a disconnect recover the UI.
- Remove the hardcoded 284.5K fake value from TokenCountText: it had
x:Uid='TokenCountText' bound to a resw entry that hardcoded '284.5K'
across all five locales, masking the real loading bug by making it
look like Tokens had populated when no usage.cost reply ever arrived.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml | 2 +-
.../Pages/UsagePage.xaml.cs | 35 ++++++++++++++++---
.../Strings/en-us/Resources.resw | 3 --
.../Strings/fr-fr/Resources.resw | 3 --
.../Strings/nl-nl/Resources.resw | 3 --
.../Strings/zh-cn/Resources.resw | 3 --
.../Strings/zh-tw/Resources.resw | 3 --
7 files changed, 32 insertions(+), 20 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
index b3980306b..31f4cd606 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml
@@ -59,7 +59,7 @@
-
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
index 6b8aaaf78..b80f2a2b7 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
@@ -161,13 +161,24 @@ public void UpdateUsage(GatewayUsageInfo usage)
public void UpdateUsageCost(GatewayCostUsageInfo cost)
{
- if (cost.Days != _currentPeriodDays)
- return;
-
if (cost.UpdatedAt < _lastAppliedUsageCostUpdatedAtUtc)
return;
+ // The Windows tray fires usage.cost twice per refresh: once via
+ // RequestUsageAsync() (always days=30) and once directly via the
+ // selector (currently 7 or 30). If the gateway ignores the `days`
+ // request param or only replies to one of the two, the page used to
+ // reject the response that didn't match _currentPeriodDays and the
+ // spinner ran forever. Accept whatever days the server returns and,
+ // when the selector is out of sync, silently snap it to that period
+ // so the header isn't lying about what data the user sees.
+ if (cost.Days > 0 && cost.Days != _currentPeriodDays)
+ {
+ SyncSelectorToServerDays(cost.Days);
+ }
+
_lastAppliedUsageCostUpdatedAtUtc = cost.UpdatedAt;
+ ConnectionInfoBar.IsOpen = false;
TotalCostText.Text = $"${cost.Totals.TotalCost:F2}";
TokenCountText.Text = FormatLargeNumber(cost.Totals.TotalTokens);
@@ -180,8 +191,24 @@ public void UpdateUsageCost(GatewayCostUsageInfo cost)
UpdateDailyCostLoadingVisuals();
}
+ private void SyncSelectorToServerDays(int days)
+ {
+ // Only the two SelectorBar items are valid targets; ignore anything
+ // else (e.g. server-defaulted 14 days) and just keep the user's pick.
+ if (days != 7 && days != 30) return;
+ _currentPeriodDays = days;
+ var target = days == 30 ? Period30DaysItem : Period7DaysItem;
+ if (!ReferenceEquals(PeriodSelector.SelectedItem, target))
+ {
+ PeriodSelector.SelectionChanged -= OnPeriodSelectionChanged;
+ try { PeriodSelector.SelectedItem = target; }
+ finally { PeriodSelector.SelectionChanged += OnPeriodSelectionChanged; }
+ }
+ }
+
public void UpdateUsageStatus(GatewayUsageStatusInfo status)
{
+ ConnectionInfoBar.IsOpen = false;
ProviderCountText.Text = status.Providers.Count.ToString();
ProviderListView.ItemsSource = status.Providers.Select(p => new ProviderRow
{
@@ -231,7 +258,7 @@ private void UpdateDailyCostLoadingVisuals()
DailyListView.Visibility = _dailyCostLoading.ShouldShowContent ? Visibility.Visible : Visibility.Collapsed;
// After Fail() the state is !IsRefreshing && !HasLoaded, which leaves the
// card visually empty (no spinner, no rows, no message). Surface a
- // disconnected message in that case so the page never looks frozen.
+ // message in that case so the page never looks frozen.
bool failed = !_dailyCostLoading.IsRefreshing && !_dailyCostLoading.HasLoaded;
DailyEmptyText.Text = failed ? DisconnectedListMessage : DailyEmptyMessage;
DailyEmptyText.Visibility = (_dailyCostLoading.ShouldShowEmpty || failed) ? Visibility.Visible : Visibility.Collapsed;
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 698596150..58d839959 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2145,9 +2145,6 @@ On your gateway host (Mac/Linux), run:
Tokens
-
- 284.5K
-
Providers
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 3624a2202..d63fdb11a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2097,9 +2097,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Jetons
-
- 284.5K
-
Fournisseurs
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index fea6ca50a..2f4541a8a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2098,9 +2098,6 @@ Voer op uw gateway-host (Mac/Linux) uit:
Toegangstokens
-
- 284.5K
-
Aanbieders
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index adbd9ea2b..2d75e0e0b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2097,9 +2097,6 @@
令牌
-
- 284.5K
-
提供程序
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index 1d97367f1..595129b18 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2097,9 +2097,6 @@
權杖
-
- 284.5K
-
提供者
From 1a3fdcafed5b7149eeb831eb3c1fffa9ab77c71e Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Fri, 22 May 2026 15:17:09 -0700
Subject: [PATCH 094/115] fix(usage): reattach after gateway client replacement
Handle OperatorClientChanged while the Usage page is open so reconnects or gateway switches refresh the page instead of leaving it attached to a stale client.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/UsagePage.xaml.cs | 50 ++++++++++++++++---
1 file changed, 43 insertions(+), 7 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
index b80f2a2b7..271a4e1f1 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs
@@ -1,5 +1,6 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using OpenClaw.Connection;
using OpenClaw.Shared;
using OpenClawTray.Services;
using System;
@@ -31,6 +32,8 @@ public UsagePage()
Unloaded += (_, _) =>
{
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
+ if (CurrentApp.ConnectionManager != null)
+ CurrentApp.ConnectionManager.OperatorClientChanged -= OnOperatorClientChanged;
DetachClient();
};
}
@@ -40,6 +43,11 @@ public void Initialize()
if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged;
_appState = CurrentApp.AppState;
_appState.PropertyChanged += OnAppStateChanged;
+ if (CurrentApp.ConnectionManager != null)
+ {
+ CurrentApp.ConnectionManager.OperatorClientChanged -= OnOperatorClientChanged;
+ CurrentApp.ConnectionManager.OperatorClientChanged += OnOperatorClientChanged;
+ }
var client = CurrentApp.GatewayClient;
AttachClient(client);
@@ -114,16 +122,44 @@ private void OnClientStatusChanged(object? sender, ConnectionStatus status)
dispatcher.TryEnqueue(() =>
{
if (_trackedClient != client) return;
- ConnectionInfoBar.IsOpen = false;
- if (!_dailyCostLoading.HasLoaded) _dailyCostLoading.BeginInitialRefresh();
- else _dailyCostLoading.BeginRefresh();
- if (!_providerLoading.HasLoaded) _providerLoading.BeginInitialRefresh();
- UpdateDailyCostLoadingVisuals();
- UpdateProviderLoadingVisuals();
- RequestRefresh(client);
+ RecoverConnectedClient(client);
});
}
+ private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventArgs e)
+ {
+ var dispatcher = DispatcherQueue;
+ if (dispatcher == null) return;
+ dispatcher.TryEnqueue(() =>
+ {
+ AttachClient(e.NewClient);
+ if (e.NewClient is { IsConnectedToGateway: true })
+ {
+ RecoverConnectedClient(e.NewClient);
+ }
+ else
+ {
+ _providerLoading.Fail();
+ _dailyCostLoading.Fail();
+ ShowDisconnected();
+ UpdateProviderLoadingVisuals();
+ UpdateDailyCostLoadingVisuals();
+ }
+ });
+ }
+
+ private void RecoverConnectedClient(IOperatorGatewayClient client)
+ {
+ ConnectionInfoBar.IsOpen = false;
+ if (!_dailyCostLoading.HasLoaded) _dailyCostLoading.BeginInitialRefresh();
+ else _dailyCostLoading.BeginRefresh();
+ if (!_providerLoading.HasLoaded) _providerLoading.BeginInitialRefresh();
+ else _providerLoading.BeginRefresh();
+ UpdateDailyCostLoadingVisuals();
+ UpdateProviderLoadingVisuals();
+ RequestRefresh(client);
+ }
+
private void RequestRefresh(IOperatorGatewayClient client)
{
_ = client.RequestUsageAsync();
From dea331f80d7cc94981108fa70476a498001f3f59 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Fri, 22 May 2026 10:25:29 -0700
Subject: [PATCH 095/115] Redesign Sessions page (Fluent 2 + deep-link to Chat)
Restructures SessionsPage to match the Permissions/Connection shell
(ScrollViewer + StackPanel, MaxWidth 900, 4 px grid) and removes UI
drift against the openclaw-design skill:
- Replace literal emoji empty-state and ad-hoc colors with Segoe
Fluent glyphs and SystemFillColor* theme brushes.
- Status dot is now a 4-state Fluent map (Success / Caution /
Critical / Neutral) with a hover tooltip describing the state.
- Primary action is an AccentButton 'Open chat' (Fluent 2
rest/hover/press/disabled via AccentButtonStyle, no Foreground
overrides); destructive Reset/Compact/Delete moved to a MenuFlyout
behind the row's overflow button.
- Filter out cron-spawned sessions (key slot == 'cron') so the
conversation list mirrors the macOS companion.
- Deep-link from a session row to the Chat surface: SessionsPage
stores the session key on HubWindow.PendingChatSessionKey;
ChatPage.ShowFunctionalSurface consumes it and remounts the
OpenClawChatRoot with initialThreadId, so both the timeline and
the composer's session dropdown render the requested session.
- Robust MenuFlyout click routing via ResolveSessionKey, which
walks back to MenuFlyout.Target when DataContext propagation into
popup items fails (canonical WinUI binding quirk).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Pages/ChatPage.xaml.cs | 20 +-
.../Pages/SessionsPage.xaml | 364 +++++++++++-------
.../Pages/SessionsPage.xaml.cs | 144 +++++--
.../Strings/en-us/Resources.resw | 6 +-
.../Strings/fr-fr/Resources.resw | 6 +-
.../Strings/nl-nl/Resources.resw | 6 +-
.../Strings/zh-cn/Resources.resw | 6 +-
.../Strings/zh-tw/Resources.resw | 6 +-
.../Windows/HubWindow.xaml.cs | 2 +
9 files changed, 369 insertions(+), 191 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
index b8f12d213..769e92065 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
@@ -26,6 +26,7 @@ public sealed partial class ChatPage : Page
private HubWindow? _hub;
private MountedFunctionalChat? _functionalHost;
private IChatDataProvider? _mountedProvider;
+ private string? _mountedThreadId;
private string? _chatUrl;
private bool _webViewInitialized;
private bool _webViewMode;
@@ -212,7 +213,21 @@ private void ShowFunctionalSurface()
var provider = app?.ChatProvider;
Func? readAloud = app is null ? null : app.SpeakChatTextAsync;
- if (_functionalHost is not null && ReferenceEquals(_mountedProvider, provider))
+ // Consume a pending session-key hand-off from SessionsPage so the
+ // chat root mounts with that thread selected. Remount is the only
+ // path — OpenClawChatRoot has no live "switch thread" API.
+ var pendingSessionKey = _hub?.PendingChatSessionKey;
+ if (pendingSessionKey is not null && _hub is not null)
+ {
+ _hub.PendingChatSessionKey = null;
+ }
+ var threadIdToMount = pendingSessionKey ?? _mountedThreadId;
+ var threadChanged = pendingSessionKey is not null
+ && !string.Equals(_mountedThreadId, pendingSessionKey, StringComparison.Ordinal);
+
+ if (_functionalHost is not null
+ && ReferenceEquals(_mountedProvider, provider)
+ && !threadChanged)
{
PlaceholderPanel.Visibility = Visibility.Collapsed;
ChatHost.Visibility = Visibility.Visible;
@@ -245,6 +260,7 @@ private void ShowFunctionalSurface()
_functionalHost = CurrentApp.ActiveHubWindow!.MountFunctionalChat(
ChatHost,
provider,
+ initialThreadId: threadIdToMount,
onReadAloud: readAloud,
onStopSpeaking: () => app?.StopChatSpeaking(),
onVoiceRequest: VoiceTranscribeAsync,
@@ -254,6 +270,7 @@ private void ShowFunctionalSurface()
initialMuted: CurrentApp.Settings?.VoiceTtsEnabled == false,
suppressAutoDispose: true);
_mountedProvider = provider;
+ _mountedThreadId = threadIdToMount;
// If the V hotkey (or another caller) requested auto-start voice,
// trigger it after the UI thread processes the mount (composer needs
@@ -342,6 +359,7 @@ private void DisposeFunctionalHost()
var host = _functionalHost;
_functionalHost = null;
_mountedProvider = null;
+ _mountedThreadId = null;
try { host?.Dispose(); } catch { /* tear-down race — non-fatal */ }
}
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
index 274fce74a..7aa4124bc 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml
@@ -5,149 +5,245 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
NavigationCacheMode="Enabled">
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
index 8879d852e..36c43bac3 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs
@@ -82,12 +82,25 @@ private void OnOpenConnectionClick(object sender, RoutedEventArgs e)
public void UpdateSessions(SessionInfo[] sessions)
{
- _allSessions = sessions;
- _sessionLoading.Complete(sessions.Length);
+ // Drop cron-spawned sessions (key shape "agent::cron" — slot is
+ // the third ":"-separated part). They have their own home on the
+ // Cron page; surfacing them here overcrowds the conversation list.
+ _allSessions = sessions
+ .Where(s => !IsCronSession(s))
+ .ToArray();
+ _sessionLoading.Complete(_allSessions.Length);
RebuildChannelTabs();
ApplyFilter();
}
+ private static bool IsCronSession(SessionInfo s)
+ {
+ if (string.IsNullOrEmpty(s.Key)) return false;
+ var parts = s.Key.Split(':');
+ return parts.Length >= 3
+ && string.Equals(parts[2], "cron", StringComparison.OrdinalIgnoreCase);
+ }
+
private void RebuildChannelTabs()
{
if (_allSessions == null) return;
@@ -165,21 +178,17 @@ private void OnAppStateChanged(object? sender, PropertyChangedEventArgs e)
private SessionViewModel ToViewModel(SessionInfo s)
{
- var isActive = s.Status == "active" || s.Status == "running";
-
- // Detail line: Provider · Model · Channel
var parts = new List(3);
if (!string.IsNullOrWhiteSpace(s.Provider)) parts.Add(s.Provider!);
if (!string.IsNullOrWhiteSpace(s.Model)) parts.Add(s.Model!);
if (!string.IsNullOrWhiteSpace(s.Channel)) parts.Add(s.Channel!);
- // Token display
var hasTokens = s.InputTokens > 0 || s.OutputTokens > 0;
var tokensText = hasTokens
? $"↓{FormatTokenCount(s.InputTokens)} / ↑{FormatTokenCount(s.OutputTokens)}"
: "";
- // Context % — ContextTokens is the window size, TotalTokens is usage
+ // ContextTokens is the window size, TotalTokens is usage.
double contextPercent = 0;
if (s.ContextTokens > 0 && s.TotalTokens > 0)
contextPercent = Math.Min(100.0, (double)s.TotalTokens / s.ContextTokens * 100.0);
@@ -190,7 +199,8 @@ private SessionViewModel ToViewModel(SessionInfo s)
DisplayName = !string.IsNullOrWhiteSpace(s.DisplayName) ? s.DisplayName! : s.Key,
AgeText = s.AgeText,
DetailLine = parts.Count > 0 ? string.Join(" · ", parts) : "",
- StatusColor = new SolidColorBrush(isActive ? Colors.LimeGreen : Colors.Gray),
+ StatusBrush = ResolveStatusBrush(s),
+ StatusTooltip = ResolveStatusTooltip(s),
TokensText = tokensText,
ContextPercent = contextPercent,
HasTokenData = hasTokens || contextPercent > 0,
@@ -198,6 +208,48 @@ private SessionViewModel ToViewModel(SessionInfo s)
};
}
+ private static Brush ResolveStatusBrush(SessionInfo s)
+ {
+ var status = s.Status?.Trim().ToLowerInvariant();
+ if (status is "error" or "failed" or "failure")
+ return s_criticalBrush.Value;
+ if (s.AbortedLastRun)
+ return s_cautionBrush.Value;
+ if (status is "active" or "running")
+ return s_successBrush.Value;
+ return s_neutralBrush.Value;
+ }
+
+ private static string ResolveStatusTooltip(SessionInfo s)
+ {
+ var status = s.Status?.Trim().ToLowerInvariant();
+ if (status is "error" or "failed" or "failure") return "Error";
+ if (s.AbortedLastRun) return "Aborted last run";
+ if (status is "active" or "running") return "Running";
+ return "Idle";
+ }
+
+ private static readonly Lazy s_successBrush =
+ new(() => (Brush)Application.Current.Resources["SystemFillColorSuccessBrush"]);
+ private static readonly Lazy s_cautionBrush =
+ new(() => (Brush)Application.Current.Resources["SystemFillColorCautionBrush"]);
+ private static readonly Lazy s_criticalBrush =
+ new(() => (Brush)Application.Current.Resources["SystemFillColorCriticalBrush"]);
+ private static readonly Lazy s_neutralBrush =
+ new(() => (Brush)Application.Current.Resources["SystemFillColorNeutralBrush"]);
+
+ private void OnOpenChat(object sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is string key)
+ {
+ if (CurrentApp.ActiveHubWindow is HubWindow hub)
+ {
+ hub.PendingChatSessionKey = key;
+ }
+ ((IAppCommands)CurrentApp).Navigate("chat", "sessions");
+ }
+ }
+
private void ChannelSelector_SelectionChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args)
{
var selected = sender.SelectedItem;
@@ -205,37 +257,51 @@ private void ChannelSelector_SelectionChanged(SelectorBar sender, SelectorBarSel
ApplyFilter();
}
- private async void OnResetSession(object sender, RoutedEventArgs e)
+ private static string? ResolveSessionKey(object sender)
{
- if (sender is Button btn && btn.Tag is string key)
+ if (sender is FrameworkElement fe)
{
- var client = CurrentApp.GatewayClient;
- if (client == null) { ShowDisconnected(); return; }
- try { await client.ResetSessionAsync(key); }
- catch (Exception ex) { ShowActionFailure("Reset failed", ex); }
+ if (fe.DataContext is SessionViewModel vm && !string.IsNullOrEmpty(vm.Key))
+ return vm.Key;
+ if (fe.Tag is string tag && !string.IsNullOrEmpty(tag))
+ return tag;
+ if (fe is MenuFlyoutItem mfi && mfi.Parent is MenuFlyout mf
+ && mf.Target is FrameworkElement target)
+ {
+ if (target.DataContext is SessionViewModel targetVm && !string.IsNullOrEmpty(targetVm.Key))
+ return targetVm.Key;
+ if (target.Tag is string targetTag && !string.IsNullOrEmpty(targetTag))
+ return targetTag;
+ }
}
+ return null;
+ }
+
+ private async void OnResetSession(object sender, RoutedEventArgs e)
+ {
+ if (ResolveSessionKey(sender) is not string key) return;
+ var client = CurrentApp.GatewayClient;
+ if (client == null) { ShowDisconnected(); return; }
+ try { await client.ResetSessionAsync(key); }
+ catch (Exception ex) { ShowActionFailure("Reset failed", ex); }
}
private async void OnDeleteSession(object sender, RoutedEventArgs e)
{
- if (sender is Button btn && btn.Tag is string key)
- {
- var client = CurrentApp.GatewayClient;
- if (client == null) { ShowDisconnected(); return; }
- try { await client.DeleteSessionAsync(key); }
- catch (Exception ex) { ShowActionFailure("Delete failed", ex); }
- }
+ if (ResolveSessionKey(sender) is not string key) return;
+ var client = CurrentApp.GatewayClient;
+ if (client == null) { ShowDisconnected(); return; }
+ try { await client.DeleteSessionAsync(key); }
+ catch (Exception ex) { ShowActionFailure("Delete failed", ex); }
}
private async void OnCompactSession(object sender, RoutedEventArgs e)
{
- if (sender is Button btn && btn.Tag is string key)
- {
- var client = CurrentApp.GatewayClient;
- if (client == null) { ShowDisconnected(); return; }
- try { await client.CompactSessionAsync(key); }
- catch (Exception ex) { ShowActionFailure("Compact failed", ex); }
- }
+ if (ResolveSessionKey(sender) is not string key) return;
+ var client = CurrentApp.GatewayClient;
+ if (client == null) { ShowDisconnected(); return; }
+ try { await client.CompactSessionAsync(key); }
+ catch (Exception ex) { ShowActionFailure("Compact failed", ex); }
}
private void OnRefresh(object sender, RoutedEventArgs e)
@@ -255,19 +321,14 @@ private void OnRefresh(object sender, RoutedEventArgs e)
_ = client.RequestSessionsAsync();
_ = client.RequestModelsListAsync();
- if (RefreshButton.Content is StackPanel)
+ if (RefreshLabel is not null)
{
- // Temporarily update the text inside the StackPanel
- var sp = (StackPanel)RefreshButton.Content;
- if (sp.Children.Count > 1 && sp.Children[1] is TextBlock tb)
- {
- tb.Text = "Refreshing...";
- _refreshTimer?.Stop();
- _refreshTimer = DispatcherQueue.CreateTimer();
- _refreshTimer.Interval = TimeSpan.FromSeconds(1);
- _refreshTimer.Tick += (t, a) => { tb.Text = "Refresh"; _refreshTimer.Stop(); };
- _refreshTimer.Start();
- }
+ RefreshLabel.Text = "Refreshing...";
+ _refreshTimer?.Stop();
+ _refreshTimer = DispatcherQueue.CreateTimer();
+ _refreshTimer.Interval = TimeSpan.FromSeconds(1);
+ _refreshTimer.Tick += (t, a) => { RefreshLabel.Text = "Refresh"; _refreshTimer.Stop(); };
+ _refreshTimer.Start();
}
}
@@ -302,7 +363,8 @@ public class SessionViewModel
public string DisplayName { get; set; } = "";
public string AgeText { get; set; } = "";
public string DetailLine { get; set; } = "";
- public SolidColorBrush StatusColor { get; set; } = new(Colors.Gray);
+ public Brush StatusBrush { get; set; } = new SolidColorBrush(Colors.Gray);
+ public string StatusTooltip { get; set; } = "Idle";
public string TokensText { get; set; } = "";
public double ContextPercent { get; set; }
public bool HasTokenData { get; set; }
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index d711c4a26..d53034fef 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -4275,13 +4275,13 @@ On your gateway host (Mac/Linux), run:
No active sessions
-
+
Reset
-
+
Compact
-
+
Delete
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index ca838d2f7..98a4b3e12 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -4227,13 +4227,13 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Aucune session active
-
+
Réinitialiser
-
+
Compacter
-
+
Supprimer
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 06f152d20..087966f0e 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -4228,13 +4228,13 @@ Voer op uw gateway-host (Mac/Linux) uit:
Geen actieve sessies
-
+
Resetten
-
+
Compact maken
-
+
Verwijderen
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 871c15a46..675928322 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -4227,13 +4227,13 @@
æ— æ´»åŠ¨ä¼šè¯
-
+
é‡ç½®
-
+
压缩
-
+
åˆ é™¤
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index f9467f4bc..e3a35857f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -4227,13 +4227,13 @@
沒有作用ä¸çš„工作階段
-
+
é‡è¨
-
+
壓縮
-
+
刪除
diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
index 9c37daa0f..d8d248bcc 100644
--- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs
@@ -45,6 +45,8 @@ public sealed partial class HubWindow : WindowEx
public VoiceService? VoiceServiceInstance { get; set; }
/// When true, ChatPage should auto-start voice recording on next navigation. Consumed (reset to false) by ChatPage.
public bool PendingAutoStartVoice { get; set; }
+ /// Session key the chat surface should select on its next mount. Consumed (cleared) by ChatPage.
+ public string? PendingChatSessionKey { get; set; }
public string? NodeFullDeviceId { get; set; }
private Microsoft.UI.Dispatching.DispatcherQueueTimer? _gatewayNavHideTimer;
From 7d36605d370e2526179753ac11511c7be32a2bae Mon Sep 17 00:00:00 2001
From: Copilot
Date: Fri, 22 May 2026 15:20:11 -0700
Subject: [PATCH 096/115] Always remount chat host when SessionsPage hands off
a session key
_mountedThreadId only records what we asked for, not what the user
later picked inside OpenClawChatRoot's composer dropdown. Comparing
against it could leave the chat root selected on a different session
than the row the user just clicked, sending the next message to the
wrong thread (raised in PR #514 review).
Treat any pending session key from SessionsPage as an unconditional
remount so the deep-link always wins.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
index 769e92065..3393810c4 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs
@@ -214,20 +214,21 @@ private void ShowFunctionalSurface()
Func? readAloud = app is null ? null : app.SpeakChatTextAsync;
// Consume a pending session-key hand-off from SessionsPage so the
- // chat root mounts with that thread selected. Remount is the only
- // path — OpenClawChatRoot has no live "switch thread" API.
+ // chat root mounts with that thread selected. Any pending key forces
+ // a remount — _mountedThreadId only records what we asked for, not
+ // what the user later picked inside the composer's dropdown, so we
+ // cannot use it to detect "already on the right thread".
var pendingSessionKey = _hub?.PendingChatSessionKey;
if (pendingSessionKey is not null && _hub is not null)
{
_hub.PendingChatSessionKey = null;
}
var threadIdToMount = pendingSessionKey ?? _mountedThreadId;
- var threadChanged = pendingSessionKey is not null
- && !string.Equals(_mountedThreadId, pendingSessionKey, StringComparison.Ordinal);
+ var forceRemount = pendingSessionKey is not null;
if (_functionalHost is not null
&& ReferenceEquals(_mountedProvider, provider)
- && !threadChanged)
+ && !forceRemount)
{
PlaceholderPanel.Visibility = Visibility.Collapsed;
ChatHost.Visibility = Visibility.Visible;
From b41fe8243cc3f117d11235e7bb923edbb70caf93 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Fri, 22 May 2026 21:58:57 -0700
Subject: [PATCH 097/115] Expand CI test coverage (#524)
* Expand CI test coverage
Add CI build and coverage-wrapped test steps for OpenClaw.Connection.Tests, OpenClaw.WinNode.Cli.Tests, OpenClawTray.FunctionalUI.Tests, and OpenClawTray.OnboardingV2.Tests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Tolerate hosted Windows token owner warnings
Allow the wide ACL token warning test to accept either the ACL warning or the owner warning emitted by hosted Windows runners while still requiring the WinNode warning and successful command execution.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/ci.yml | 54 +++++++++++++++++++
.../AuthTokenTests.cs | 5 +-
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 383387975..a170fbc89 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,8 +80,12 @@ jobs:
run: |
dotnet build tests/OpenClaw.Shared.Tests -c Debug --no-restore
dotnet build tests/OpenClaw.Tray.Tests -c Debug -r win-x64 --no-restore
+ dotnet build tests/OpenClaw.Connection.Tests -c Debug --no-restore
+ dotnet build tests/OpenClaw.WinNode.Cli.Tests -c Debug --no-restore
dotnet build tests/OpenClaw.Tray.IntegrationTests -c Debug -r win-x64 --no-restore
dotnet build tests/OpenClaw.Tray.UITests -c Debug -r win-x64 --no-restore
+ dotnet build tests/OpenClawTray.FunctionalUI.Tests -c Debug -r win-x64 --no-restore
+ dotnet build tests/OpenClawTray.OnboardingV2.Tests -c Debug -r win-x64 --no-restore
- name: Run Shared Tests
env:
@@ -110,6 +114,30 @@ jobs:
--results-directory TestResults\Tray
--logger trx;LogFileName=OpenClaw.Tray.Tests.trx"
+ - name: Run Connection Tests
+ run: >
+ dotnet-coverage collect
+ --output TestResults\Connection\coverage.cobertura.xml
+ --output-format cobertura
+ "dotnet test tests/OpenClaw.Connection.Tests
+ --no-build
+ -c Debug
+ --verbosity normal
+ --results-directory TestResults\Connection
+ --logger trx;LogFileName=OpenClaw.Connection.Tests.trx"
+
+ - name: Run WinNode CLI Tests
+ run: >
+ dotnet-coverage collect
+ --output TestResults\WinNodeCli\coverage.cobertura.xml
+ --output-format cobertura
+ "dotnet test tests/OpenClaw.WinNode.Cli.Tests
+ --no-build
+ -c Debug
+ --verbosity normal
+ --results-directory TestResults\WinNodeCli
+ --logger trx;LogFileName=OpenClaw.WinNode.Cli.Tests.trx"
+
# Tray integration tests gate on OPENCLAW_RUN_INTEGRATION; set it so the
# MCP-server / capability tests actually run. dotnet-coverage with no
# filter captures coverage for both the test host AND the spawned tray
@@ -143,6 +171,32 @@ jobs:
& $exe --quiet
if ($LASTEXITCODE -ne 0) { throw "WindowsAppRuntimeInstall failed with exit code $LASTEXITCODE" }
+ - name: Run Functional UI Tests
+ run: >
+ dotnet-coverage collect
+ --output TestResults\FunctionalUI\coverage.cobertura.xml
+ --output-format cobertura
+ "dotnet test tests/OpenClawTray.FunctionalUI.Tests
+ --no-build
+ -c Debug
+ -r win-x64
+ --verbosity normal
+ --results-directory TestResults\FunctionalUI
+ --logger trx;LogFileName=OpenClawTray.FunctionalUI.Tests.trx"
+
+ - name: Run Onboarding V2 Tests
+ run: >
+ dotnet-coverage collect
+ --output TestResults\OnboardingV2\coverage.cobertura.xml
+ --output-format cobertura
+ "dotnet test tests/OpenClawTray.OnboardingV2.Tests
+ --no-build
+ -c Debug
+ -r win-x64
+ --verbosity normal
+ --results-directory TestResults\OnboardingV2
+ --logger trx;LogFileName=OpenClawTray.OnboardingV2.Tests.trx"
+
- name: Run Tray UI Tests
run: >
dotnet-coverage collect
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
index 11c2d3795..b196fc529 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/AuthTokenTests.cs
@@ -369,7 +369,10 @@ public async Task Token_file_with_wide_acl_emits_warn()
Assert.Equal(0, exit);
var stderr = e.ToString();
Assert.Contains("[winnode] WARN", stderr);
- Assert.Contains("ACL", stderr);
+ Assert.True(
+ stderr.Contains("ACL", StringComparison.OrdinalIgnoreCase) ||
+ stderr.Contains("owner", StringComparison.OrdinalIgnoreCase),
+ $"Expected ACL or owner warning, got: {stderr}");
}
[SupportedOSPlatform("windows")]
From 00dda45216984c62f85198769f57d9d14c7fc093 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Fri, 22 May 2026 22:15:13 -0700
Subject: [PATCH 098/115] Update WinNode CLI live tool discovery
Add live tools/list discovery, strengthen WinNode CLI drift coverage, and refresh MCP bearer-token docs.
---
docs/MCP_MODE.md | 29 ++++--
src/OpenClaw.WinNode.Cli/Program.cs | 96 ++++++++++++-------
src/OpenClaw.WinNode.Cli/skill.md | 6 +-
.../BuildToolsCallBodyTests.cs | 13 +++
.../ParseArgsTests.cs | 13 +++
.../PrintUsageTests.cs | 1 +
.../RunAsyncTests.cs | 37 +++++++
.../SkillMdDriftTests.cs | 51 ++++++++++
8 files changed, 203 insertions(+), 43 deletions(-)
diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md
index 7560d9520..9868d2acf 100644
--- a/docs/MCP_MODE.md
+++ b/docs/MCP_MODE.md
@@ -13,7 +13,7 @@ The implementation is structured so that **adding a new node capability automati
## Goals
1. **Single source of truth for capabilities.** A new `INodeCapability` registered with `WindowsNodeClient.RegisterCapability(...)` is reachable via every transport the tray supports. Today: gateway WebSocket and local MCP HTTP. Future transports (named pipe, gRPC, whatever) plug in the same way.
-2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without auth, without a tunnel.
+2. **Local-first development.** Capabilities can be exercised on Windows without standing up an OpenClaw gateway, without an account, without a gateway token, without pairing, and without a tunnel.
3. **Make MCP clients first-class consumers** of the OpenClaw native node, not afterthoughts. The tooling investment in capabilities (camera consent flows, exec approval policy, canvas WebView2 plumbing) pays off in both directions: agent-via-gateway and agent-via-local-MCP.
## Non-goals (for this iteration)
@@ -119,7 +119,7 @@ The tray's most interesting code lives in capabilities — `system.run` (LocalCo
Local MCP changes that. Concrete benefits:
-- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no token, no SSH tunnel.
+- **Manual smoke tests in seconds.** `curl -s -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` validates that the capability dispatch path works, the WinUI dispatcher marshaling is correct, the result shape matches expectations. No gateway, no gateway token, no pairing, no SSH tunnel.
- **Reproducible bug reports.** A repro becomes a `tools/call` body the bug filer can paste verbatim. No "what was the gateway doing at the time."
- **Integration tests against a real instance.** A future `tests/integration/` project can spin up the tray in MCP-only mode, fire JSON-RPC, assert results. The same test bodies a developer runs by hand are the same ones CI runs. (Harnessing WinUI itself in CI is harder, but the bridge logic — `McpToolBridge` — is already covered by `McpToolBridgeTests` with no UI involvement.)
- **Coverage for the dispatch path itself.** `WindowsNodeClient`'s capability-routing logic (`CanHandle` → `ExecuteAsync`) was previously only exercised against a live gateway. The MCP server hits the same code paths, so any local MCP test is implicit coverage of the gateway dispatch.
@@ -168,7 +168,7 @@ With MCP in-process the workflow shortens to:
2. Wire it into `NodeService.RegisterCapabilities()`.
3. Restart the tray. The new tool is *immediately* visible to any local MCP client (`tools/list` re-reads the registry every call), and to manual `curl` tests.
-The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint, observe. No gateway, no agent, no auth.
+The dev loop for capabilities is now identical to the dev loop for any local HTTP server: edit, restart, hit the endpoint with the local MCP bearer token, observe. No gateway, no agent, no gateway auth.
This compounds when you stack it with Claude Code or Cursor on the same machine. A contributor can:
@@ -181,7 +181,7 @@ It also reduces the cost of "speculative" capabilities. Today, adding a capabili
## Security model
-The server is built on **three** defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly.
+The server is built on several defensive layers, not just one. Loopback alone is *not* sufficient — a browser tab the user opens is also on the loopback interface, so a malicious page could otherwise reach `http://127.0.0.1:8765/` directly.
1. **Loopback bind.** `HttpListener` is registered with the prefix `http://127.0.0.1:8765/`. The Windows kernel binds the listening socket to the loopback interface only — packets from other interfaces are not delivered to it. Firewall configuration is irrelevant. Defends against: another machine on the network.
2. **Defensive `IsLoopback` check.** Each incoming request validates `ctx.Request.RemoteEndPoint.Address`. Belt-and-suspenders for #1.
@@ -192,10 +192,11 @@ The server is built on **three** defensive layers, not just one. Loopback alone
- the request body exceeds 4 MiB (DoS / OOM cap).
Together these three checks force a malicious cross-origin browser fetch into a CORS preflight that we deliberately do not honor (no `Access-Control-Allow-*` is ever emitted), so the actual call is blocked before reaching capability code.
-4. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls.
-5. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those.
+4. **Bearer token.** Every request must include the persistent local MCP bearer token (`Authorization: Bearer `) once the server has created `%APPDATA%\OpenClawTray\mcp-token.txt`. This blocks drive-by local clients that know the port but cannot read the per-user token file.
+5. **Concurrency cap.** A semaphore limits in-flight handlers to 8. A misbehaving local client cannot pin every threadpool thread on long-running screen/camera calls.
+6. **Capability-level controls remain in force.** `SystemCapability.SetApprovalPolicy(...)` (the exec approval policy) still gates `system.run`. Camera and screen capture still go through Windows consent flows. MCP doesn't bypass any of those.
-**Still no authentication.** Any user-context local process with a TCP socket and the port number can drive any capability. This is the same trust boundary as anything that runs as the user — a malicious process on the box could already invoke arbitrary Win32 APIs without going through MCP. We don't try to stop user-context processes from talking to MCP. If that turns out to matter (multi-user shared boxes, low-trust local processes), the right answer is per-call bearer tokens issued by the tray (one-time copy-to-clipboard from the Settings UI), not URL ACLs or HTTPS — both add deployment pain without solving the actual problem.
+**Authentication is local bearer-token based.** The token is persistent, generated by the tray, stored in the current user's OpenClawTray data directory, and verified before MCP method dispatch. It is defense-in-depth rather than a hard sandbox boundary: a malicious process already running as the same user may still be able to read user-profile files or invoke native APIs directly. If we need stronger isolation for shared machines or low-trust local processes, the next step is scoped or per-call tokens issued by the tray, not URL ACLs or HTTPS — both add deployment pain without solving the same-user trust problem.
### Verifying the gate
@@ -212,7 +213,7 @@ curl -X POST http://127.0.0.1:8765/ -H "Host: evil.com" -H "Content-Type: applic
This should be **rejected** with `415`:
```powershell
-curl -X POST http://127.0.0.1:8765/ -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}'
+curl -X POST http://127.0.0.1:8765/ -H "Authorization: Bearer " -H "Content-Type: text/plain" --data '{"jsonrpc":"2.0","id":1,"method":"ping"}'
```
These should **succeed**:
@@ -252,19 +253,24 @@ With the tray running and `EnableMcpServer = true`:
```powershell
# Server is up
-curl http://127.0.0.1:8765/
+curl http://127.0.0.1:8765/ -H "Authorization: Bearer "
# List tools
curl -s -X POST http://127.0.0.1:8765/ `
+ -H "Authorization: Bearer " `
-H "Content-Type: application/json" `
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Take a screenshot of the primary monitor
curl -s -X POST http://127.0.0.1:8765/ `
+ -H "Authorization: Bearer " `
-H "Content-Type: application/json" `
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"screen.snapshot"}}'
```
+For a simpler local CLI smoke test, run `winnode --list-tools`; it loads the
+same token file automatically.
+
For Claude Code, drop this into `.mcp.json` at the repo root or `~/.claude.json`:
```json
@@ -272,7 +278,10 @@ For Claude Code, drop this into `.mcp.json` at the repo root or `~/.claude.json`
"mcpServers": {
"openclaw-tray": {
"type": "http",
- "url": "http://127.0.0.1:8765/"
+ "url": "http://127.0.0.1:8765/",
+ "headers": {
+ "Authorization": "Bearer "
+ }
}
}
}
diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs
index b5b34b710..a05158bdd 100644
--- a/src/OpenClaw.WinNode.Cli/Program.cs
+++ b/src/OpenClaw.WinNode.Cli/Program.cs
@@ -12,6 +12,7 @@ internal sealed class WinNodeOptions
{
public string? Node { get; set; }
public string? Command { get; set; }
+ public bool ListTools { get; set; }
public string Params { get; set; } = "{}";
public int InvokeTimeoutMs { get; set; } = 15000;
public string? IdempotencyKey { get; set; }
@@ -68,7 +69,13 @@ public static async Task RunAsync(
return 2;
}
- if (string.IsNullOrWhiteSpace(options.Command))
+ if (options.ListTools && !string.IsNullOrWhiteSpace(options.Command))
+ {
+ stderr.WriteLine("--list-tools cannot be combined with --command");
+ return 2;
+ }
+
+ if (!options.ListTools && string.IsNullOrWhiteSpace(options.Command))
{
stderr.WriteLine("--command is required");
return 2;
@@ -90,44 +97,47 @@ public static async Task RunAsync(
stderr.WriteLine("[winnode] WARN: --idempotency-key ignored (no idempotency over local MCP); subsequent retries may double-execute side effects.");
}
- // F-12: --params @ loads a JSON object from disk. Useful for big
- // A2UI payloads / canvas.eval scripts that exceed comfortable command-
- // line size.
- var paramsJson = options.Params;
- if (paramsJson.StartsWith('@'))
+ JsonElement arguments = default;
+ if (!options.ListTools)
{
- var path = paramsJson[1..];
- if (string.IsNullOrWhiteSpace(path))
+ // F-12: --params @ loads a JSON object from disk. Useful for big
+ // A2UI payloads / canvas.eval scripts that exceed comfortable command-
+ // line size.
+ var paramsJson = options.Params;
+ if (paramsJson.StartsWith('@'))
{
- stderr.WriteLine("--params @: path is empty");
- return 2;
+ var path = paramsJson[1..];
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ stderr.WriteLine("--params @: path is empty");
+ return 2;
+ }
+ try
+ {
+ paramsJson = File.ReadAllText(path);
+ }
+ catch (Exception ex)
+ {
+ stderr.WriteLine($"--params: failed to read {path}: {ex.Message}");
+ return 2;
+ }
}
+
try
{
- paramsJson = File.ReadAllText(path);
- }
- catch (Exception ex)
- {
- stderr.WriteLine($"--params: failed to read {path}: {ex.Message}");
- return 2;
+ using var paramsDoc = JsonDocument.Parse(paramsJson);
+ if (paramsDoc.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ stderr.WriteLine("--params must be a JSON object");
+ return 2;
+ }
+ arguments = paramsDoc.RootElement.Clone();
}
- }
-
- JsonElement arguments;
- try
- {
- using var paramsDoc = JsonDocument.Parse(paramsJson);
- if (paramsDoc.RootElement.ValueKind != JsonValueKind.Object)
+ catch (JsonException ex)
{
- stderr.WriteLine("--params must be a JSON object");
+ stderr.WriteLine($"--params is not valid JSON: {ex.Message}");
return 2;
}
- arguments = paramsDoc.RootElement.Clone();
- }
- catch (JsonException ex)
- {
- stderr.WriteLine($"--params is not valid JSON: {ex.Message}");
- return 2;
}
// F-09: validate the resolved endpoint as an absolute http(s) URL up
@@ -180,7 +190,7 @@ public static async Task RunAsync(
if (options.Verbose)
{
stderr.WriteLine($"[winnode] endpoint: {endpoint}");
- stderr.WriteLine($"[winnode] command: {options.Command}");
+ stderr.WriteLine($"[winnode] command: {(options.ListTools ? "tools/list" : options.Command)}");
// F-07: don't echo the token-file path (PII / username leak).
// Source label is enough for debugging.
var authLabel = token.Token is null
@@ -195,7 +205,9 @@ public static async Task RunAsync(
}
}
- var (requestBytes, requestLength) = BuildToolsCallBody(options.Command!, arguments);
+ var (requestBytes, requestLength) = options.ListTools
+ ? BuildToolsListBody()
+ : BuildToolsCallBody(options.Command!, arguments);
// F-18: compute the timeout in long arithmetic so very large
// (but in-range) --invoke-timeout values can't overflow into a
@@ -463,6 +475,20 @@ internal static (byte[] Buffer, int Length) BuildToolsCallBody(string command, J
return (ms.GetBuffer(), (int)ms.Length);
}
+ internal static (byte[] Buffer, int Length) BuildToolsListBody()
+ {
+ using var ms = new MemoryStream();
+ using (var w = new Utf8JsonWriter(ms))
+ {
+ w.WriteStartObject();
+ w.WriteString("jsonrpc", "2.0");
+ w.WriteNumber("id", 1);
+ w.WriteString("method", "tools/list");
+ w.WriteEndObject();
+ }
+ return (ms.GetBuffer(), (int)ms.Length);
+ }
+
internal static string ResolveEndpoint(WinNodeOptions options, Func envLookup, TextWriter stderr)
{
if (!string.IsNullOrWhiteSpace(options.McpUrlOverride))
@@ -716,6 +742,9 @@ internal static WinNodeOptions ParseArgs(string[] args)
case "--command":
options.Command = RequireValue(args, ref i, arg);
break;
+ case "--list-tools":
+ options.ListTools = true;
+ break;
case "--params":
options.Params = RequireValue(args, ref i, arg);
break;
@@ -784,10 +813,12 @@ internal static void PrintUsage(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Usage:");
stdout.WriteLine(" winnode --command [--params ] [options]");
+ stdout.WriteLine(" winnode --list-tools [options]");
stdout.WriteLine();
stdout.WriteLine("Options:");
stdout.WriteLine(" --node Accepted for parity with `openclaw nodes invoke`; ignored");
stdout.WriteLine(" --command Command to invoke (e.g. system.which, canvas.eval) [required]");
+ stdout.WriteLine(" --list-tools Query the live MCP server and print its advertised tools");
stdout.WriteLine(" --params JSON object string for params (default: {}). Prefix with");
stdout.WriteLine(" @ to load the JSON from a file (e.g. --params @big.json)");
stdout.WriteLine(" --invoke-timeout Invoke timeout in ms (default: 15000, max: 600000)");
@@ -802,6 +833,7 @@ internal static void PrintUsage(TextWriter stdout)
stdout.WriteLine();
stdout.WriteLine("Examples:");
stdout.WriteLine(" winnode --command system.which --params '{\"bins\":[\"git\",\"node\"]}'");
+ stdout.WriteLine(" winnode --list-tools");
stdout.WriteLine(" winnode --command screen.snapshot");
stdout.WriteLine(" winnode --command canvas.present --params '{\"url\":\"https://example.com\"}'");
stdout.WriteLine();
diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md
index 58a85237d..f70d1064d 100644
--- a/src/OpenClaw.WinNode.Cli/skill.md
+++ b/src/OpenClaw.WinNode.Cli/skill.md
@@ -24,9 +24,13 @@ argument shape, and the A2UI v0.8 JSONL grammar. It is shipped alongside
```
winnode --command [--params ''] [--invoke-timeout ]
+winnode --list-tools [--mcp-url |--mcp-port ]
```
- `--command` (required) — node command (e.g. `system.which`, `canvas.a2ui.push`).
+- `--list-tools` — query the live MCP server's `tools/list` method and print the
+ advertised tools. Useful when settings-gated capabilities differ from this
+ static reference.
- `--params` — single JSON **object** string, default `{}`. Must be a JSON object,
not an array or scalar. **`--params @`** loads the JSON object from a
file on disk (useful for big A2UI payloads / `canvas.eval` scripts).
@@ -62,7 +66,7 @@ JSON (matches `openclaw nodes invoke`). stderr receives errors. Exit code:
| 1 | Tool error, JSON-RPC error, transport failure, or HTTP non-2xx |
| 2 | Argument error (missing/invalid flags, bad `--params` JSON, out-of-range port/timeout, non-http URL) |
-**Off-loapback safety:** when `--mcp-url` points at a non-loopback host, the
+**Off-loopback safety:** when `--mcp-url` points at a non-loopback host, the
CLI **refuses to send the auto-loaded local MCP token** (and warns on stderr).
An explicitly supplied `--mcp-token` is honored with a warning. This preserves
the loopback-only threat model the tray's MCP server relies on.
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
index b6f92eeee..1542f6e6d 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/BuildToolsCallBodyTests.cs
@@ -29,6 +29,19 @@ public void Produces_jsonrpc_envelope_with_tools_call_method()
Assert.Equal("git", args.GetProperty("bins")[0].GetString());
}
+ [Fact]
+ public void Produces_jsonrpc_envelope_with_tools_list_method()
+ {
+ var (buf, len) = CliRunner.BuildToolsListBody();
+ using var doc = JsonDocument.Parse(Encoding.UTF8.GetString(buf, 0, len));
+ var root = doc.RootElement;
+
+ Assert.Equal("2.0", root.GetProperty("jsonrpc").GetString());
+ Assert.Equal(1, root.GetProperty("id").GetInt32());
+ Assert.Equal("tools/list", root.GetProperty("method").GetString());
+ Assert.False(root.TryGetProperty("params", out _));
+ }
+
[Fact]
public void Empty_object_args_round_trip()
{
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs
index 1a6bd76e7..143aaefb1 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/ParseArgsTests.cs
@@ -16,6 +16,7 @@ public void Parses_all_flags()
"--idempotency-key", "abc-123",
"--mcp-url", "http://127.0.0.1:9000/",
"--mcp-port", "9001",
+ "--mcp-token", "token-123",
"--verbose",
});
@@ -26,6 +27,7 @@ public void Parses_all_flags()
Assert.Equal("abc-123", opts.IdempotencyKey);
Assert.Equal("http://127.0.0.1:9000/", opts.McpUrlOverride);
Assert.Equal(9001, opts.McpPortOverride);
+ Assert.Equal("token-123", opts.McpTokenOverride);
Assert.True(opts.Verbose);
}
@@ -40,9 +42,19 @@ public void Defaults_when_only_command_given()
Assert.Null(opts.IdempotencyKey);
Assert.Null(opts.McpUrlOverride);
Assert.Null(opts.McpPortOverride);
+ Assert.Null(opts.McpTokenOverride);
Assert.False(opts.Verbose);
}
+ [Fact]
+ public void Parses_list_tools_flag()
+ {
+ var opts = CliRunner.ParseArgs(new[] { "--list-tools", "--mcp-port", "9001" });
+ Assert.True(opts.ListTools);
+ Assert.Null(opts.Command);
+ Assert.Equal(9001, opts.McpPortOverride);
+ }
+
[Theory]
[InlineData("--node")]
[InlineData("--command")]
@@ -51,6 +63,7 @@ public void Defaults_when_only_command_given()
[InlineData("--idempotency-key")]
[InlineData("--mcp-url")]
[InlineData("--mcp-port")]
+ [InlineData("--mcp-token")]
public void Missing_value_for_flag_throws(string flag)
{
var ex = Assert.Throws(() => CliRunner.ParseArgs(new[] { flag }));
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs
index 57e4b8bdf..47e77cf00 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/PrintUsageTests.cs
@@ -13,6 +13,7 @@ public void Prints_every_supported_flag()
Assert.Contains("--node", text);
Assert.Contains("--command", text);
+ Assert.Contains("--list-tools", text);
Assert.Contains("--params", text);
Assert.Contains("--invoke-timeout", text);
Assert.Contains("--idempotency-key", text);
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
index ee21c2bfe..80db72636 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
@@ -86,6 +86,43 @@ public async Task Missing_command_exits_2()
Assert.Contains("--command is required", e.ToString());
}
+ [Fact]
+ public async Task List_tools_does_not_require_command_and_prints_tools_result()
+ {
+ using var server = new FakeMcpServer
+ {
+ Responder = _ => (HttpStatusCode.OK,
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[{\"name\":\"system.notify\",\"description\":\"Show a toast\"}]}}",
+ "application/json"),
+ };
+
+ var (o, e) = Buffers();
+ var exit = await CliRunner.RunAsync(
+ new[] { "--list-tools", "--mcp-url", server.Url },
+ o, e, EmptyEnv);
+
+ Assert.Equal(0, exit);
+ Assert.Equal("", e.ToString());
+ Assert.Contains("\"tools\"", o.ToString());
+ Assert.Contains("system.notify", o.ToString());
+
+ using var sent = JsonDocument.Parse(server.LastRequestBody!);
+ Assert.Equal("tools/list", sent.RootElement.GetProperty("method").GetString());
+ Assert.False(sent.RootElement.TryGetProperty("params", out _));
+ }
+
+ [Fact]
+ public async Task List_tools_cannot_be_combined_with_command()
+ {
+ var (o, e) = Buffers();
+ var exit = await CliRunner.RunAsync(
+ new[] { "--list-tools", "--command", "system.notify" },
+ o, e, EmptyEnv);
+
+ Assert.Equal(2, exit);
+ Assert.Contains("cannot be combined", e.ToString());
+ }
+
[Fact]
public async Task Params_must_be_valid_json()
{
diff --git a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
index aa86569af..5d86f7347 100644
--- a/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
+++ b/tests/OpenClaw.WinNode.Cli.Tests/SkillMdDriftTests.cs
@@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
+using OpenClaw.Shared;
+using OpenClaw.Shared.Capabilities;
using OpenClaw.Shared.Mcp;
namespace OpenClaw.WinNode.Cli.Tests;
@@ -24,6 +26,17 @@ public void SkillMd_command_set_matches_capability_registry()
var documented = ParseCommandHeadings(content);
var canonical = new HashSet(McpToolBridge.KnownCommands, StringComparer.Ordinal);
+ var actual = GetCapabilityCommands();
+
+ var missingFromDescriptions = actual.Except(canonical).OrderBy(s => s).ToList();
+ var staleDescriptions = canonical.Except(actual).OrderBy(s => s).ToList();
+ if (missingFromDescriptions.Count > 0 || staleDescriptions.Count > 0)
+ {
+ var msg = "McpToolBridge.CommandDescriptions drifted from the actual capability command lists.\n" +
+ $" Missing descriptions: [{string.Join(", ", missingFromDescriptions)}]\n" +
+ $" Stale descriptions: [{string.Join(", ", staleDescriptions)}]";
+ Assert.Fail(msg);
+ }
var missingFromDoc = canonical.Except(documented).OrderBy(s => s).ToList();
var extrasInDoc = documented.Except(canonical).OrderBy(s => s).ToList();
@@ -61,6 +74,44 @@ private static HashSet ParseCommandHeadings(string md)
return set;
}
+ private static HashSet GetCapabilityCommands()
+ {
+ var provider = new FakeDeviceStatusProvider();
+ var capabilities = new INodeCapability[]
+ {
+ new AppCapability(NullLogger.Instance),
+ new BrowserProxyCapability(NullLogger.Instance, "ws://127.0.0.1:8080", bearerToken: null),
+ new CameraCapability(NullLogger.Instance),
+ new CanvasCapability(NullLogger.Instance),
+ new DeviceCapability(NullLogger.Instance, provider),
+ new LocationCapability(NullLogger.Instance),
+ new ScreenCapability(NullLogger.Instance),
+ new SttCapability(NullLogger.Instance),
+ new SystemCapability(NullLogger.Instance),
+ new TtsCapability(NullLogger.Instance),
+ };
+
+ var commands = new HashSet(StringComparer.Ordinal);
+ foreach (var capability in capabilities)
+ {
+ foreach (var command in capability.Commands)
+ {
+ commands.Add(command);
+ }
+ }
+ return commands;
+ }
+
+ private sealed class FakeDeviceStatusProvider : IDeviceStatusProvider
+ {
+ public object GetOsInfo() => new { };
+ public Task GetCpuInfoAsync() => Task.FromResult(new { });
+ public object GetMemoryInfo() => new { };
+ public object GetDiskInfo() => new { };
+ public object GetBatteryInfo() => new { };
+ public void Dispose() { }
+ }
+
///
/// skill.md ships next to winnode.exe. From the test's working directory
/// (the test bin folder), walk up to the repo root and resolve the source
From 5a87c9921c31ad89594176aac0feaf12c666db7c Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 22 May 2026 22:16:35 -0700
Subject: [PATCH 099/115] test(shared): add 45 unit tests for MenuDisplayHelper
and MenuSizingHelper (#522)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both classes had zero test coverage. These tests cover:
MenuDisplayHelper (21 tests):
- GetStatusIcon: Connected/Connecting/Error/unknown enum value
- GetChannelStatusIcon: healthy/intermediate/empty/null/unknown status
- TruncateText: short/null/whitespace/exact-max/over-max/custom-length
- FormatProviderSummary: singular vs plural
- GetNextToggleValue: on/ON/On → off; off/null/empty/other → on
MenuSizingHelper (24 tests):
- ConvertPixelsToViewUnits: 96/192 dpi, zero/negative pixels, zero dpi fallback, minimum-1 clamp
- HasDpiOrScaleChanged: same DPI+scale, different DPI, different scale, sub-tolerance scale delta, zero-DPI normalisation, NaN/0 scale normalisation
- CalculateWindowHeight: content fits/exceeds, zero/negative content, zero work area, work area smaller than minimum, custom minimum
All 45 tests pass. Pre-existing 8 failures in ExecApprovalV2NormalizationTests are Windows-path tests that fail on Linux runners — not caused by this change.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../OpenClaw.Shared.Tests/MenuHelpersTests.cs | 266 ++++++++++++++++++
1 file changed, 266 insertions(+)
create mode 100644 tests/OpenClaw.Shared.Tests/MenuHelpersTests.cs
diff --git a/tests/OpenClaw.Shared.Tests/MenuHelpersTests.cs b/tests/OpenClaw.Shared.Tests/MenuHelpersTests.cs
new file mode 100644
index 000000000..4ead3d291
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/MenuHelpersTests.cs
@@ -0,0 +1,266 @@
+using OpenClaw.Shared;
+using Xunit;
+
+namespace OpenClaw.Shared.Tests;
+
+public class MenuDisplayHelperTests
+{
+ // ── GetStatusIcon ────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(ConnectionStatus.Connected, "✅")]
+ [InlineData(ConnectionStatus.Connecting, "🔄")]
+ [InlineData(ConnectionStatus.Error, "❌")]
+ public void GetStatusIcon_KnownStatus_ReturnsExpectedIcon(ConnectionStatus status, string expected)
+ {
+ Assert.Equal(expected, MenuDisplayHelper.GetStatusIcon(status));
+ }
+
+ [Fact]
+ public void GetStatusIcon_UnknownStatus_ReturnsNeutralIcon()
+ {
+ var icon = MenuDisplayHelper.GetStatusIcon((ConnectionStatus)999);
+ Assert.Equal("⚪", icon);
+ }
+
+ // ── GetChannelStatusIcon ─────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("active", "🟢")]
+ [InlineData("ACTIVE", "🟢")]
+ public void GetChannelStatusIcon_HealthyStatus_ReturnsGreen(string status, string expected)
+ {
+ Assert.Equal(expected, MenuDisplayHelper.GetChannelStatusIcon(status));
+ }
+
+ [Fact]
+ public void GetChannelStatusIcon_NullStatus_ReturnsNeutral()
+ {
+ Assert.Equal("⚪", MenuDisplayHelper.GetChannelStatusIcon(null));
+ }
+
+ [Fact]
+ public void GetChannelStatusIcon_EmptyStatus_ReturnsNeutral()
+ {
+ Assert.Equal("⚪", MenuDisplayHelper.GetChannelStatusIcon(""));
+ }
+
+ [Fact]
+ public void GetChannelStatusIcon_UnknownStatus_ReturnsRed()
+ {
+ Assert.Equal("🔴", MenuDisplayHelper.GetChannelStatusIcon("unknown-bad-status"));
+ }
+
+ // ── TruncateText ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TruncateText_ShortText_ReturnsUnchanged()
+ {
+ Assert.Equal("hello", MenuDisplayHelper.TruncateText("hello"));
+ }
+
+ [Fact]
+ public void TruncateText_NullText_ReturnsEmptyString()
+ {
+ Assert.Equal("", MenuDisplayHelper.TruncateText(null));
+ }
+
+ [Fact]
+ public void TruncateText_WhitespaceOnly_ReturnsWhitespace()
+ {
+ Assert.Equal(" ", MenuDisplayHelper.TruncateText(" "));
+ }
+
+ [Fact]
+ public void TruncateText_TextAtExactMaxLength_ReturnsUnchanged()
+ {
+ var text = new string('a', 96);
+ Assert.Equal(text, MenuDisplayHelper.TruncateText(text));
+ }
+
+ [Fact]
+ public void TruncateText_TextExceedsMaxLength_TruncatesWithEllipsis()
+ {
+ var text = new string('a', 200);
+ var result = MenuDisplayHelper.TruncateText(text);
+ Assert.EndsWith("…", result);
+ Assert.Equal(96, result.Length);
+ }
+
+ [Fact]
+ public void TruncateText_CustomMaxLength_TruncatesAtCustomLength()
+ {
+ var result = MenuDisplayHelper.TruncateText("hello world", maxLength: 8);
+ Assert.Equal(8, result.Length);
+ Assert.EndsWith("…", result);
+ }
+
+ // ── FormatProviderSummary ────────────────────────────────────────────────
+
+ [Fact]
+ public void FormatProviderSummary_One_UsesSingular()
+ {
+ Assert.Equal("1 provider active", MenuDisplayHelper.FormatProviderSummary(1));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(2)]
+ [InlineData(10)]
+ public void FormatProviderSummary_NotOne_UsesPlural(int count)
+ {
+ var result = MenuDisplayHelper.FormatProviderSummary(count);
+ Assert.EndsWith("providers active", result);
+ }
+
+ // ── GetNextToggleValue ───────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("on", "off")]
+ [InlineData("ON", "off")]
+ [InlineData("On", "off")]
+ public void GetNextToggleValue_OnVariants_ReturnsOff(string current, string expected)
+ {
+ Assert.Equal(expected, MenuDisplayHelper.GetNextToggleValue(current));
+ }
+
+ [Theory]
+ [InlineData("off")]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("other")]
+ public void GetNextToggleValue_NonOn_ReturnsOn(string? current)
+ {
+ Assert.Equal("on", MenuDisplayHelper.GetNextToggleValue(current));
+ }
+}
+
+public class MenuSizingHelperTests
+{
+ // ── ConvertPixelsToViewUnits ─────────────────────────────────────────────
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_96Dpi_ReturnsSamePixels()
+ {
+ Assert.Equal(100, MenuSizingHelper.ConvertPixelsToViewUnits(100, 96));
+ }
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_192Dpi_ReturnsHalfPixels()
+ {
+ Assert.Equal(50, MenuSizingHelper.ConvertPixelsToViewUnits(100, 192));
+ }
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_ZeroPixels_ReturnsZero()
+ {
+ Assert.Equal(0, MenuSizingHelper.ConvertPixelsToViewUnits(0, 96));
+ }
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_NegativePixels_ReturnsZero()
+ {
+ Assert.Equal(0, MenuSizingHelper.ConvertPixelsToViewUnits(-10, 96));
+ }
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_ZeroDpi_UsesDefault96()
+ {
+ // dpi=0 falls back to 96, so 100px at 96dpi = 100 view units
+ Assert.Equal(100, MenuSizingHelper.ConvertPixelsToViewUnits(100, 0));
+ }
+
+ [Fact]
+ public void ConvertPixelsToViewUnits_SmallResult_ReturnsAtLeastOne()
+ {
+ // 1 pixel at 192 dpi → floor(0.5) = 0, but clamped to 1
+ Assert.Equal(1, MenuSizingHelper.ConvertPixelsToViewUnits(1, 192));
+ }
+
+ // ── HasDpiOrScaleChanged ─────────────────────────────────────────────────
+
+ [Fact]
+ public void HasDpiOrScaleChanged_SameDpiAndScale_ReturnsFalse()
+ {
+ Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(96, 1.0, 96, 1.0));
+ }
+
+ [Fact]
+ public void HasDpiOrScaleChanged_DifferentDpi_ReturnsTrue()
+ {
+ Assert.True(MenuSizingHelper.HasDpiOrScaleChanged(96, 1.0, 192, 1.0));
+ }
+
+ [Fact]
+ public void HasDpiOrScaleChanged_DifferentScale_ReturnsTrue()
+ {
+ Assert.True(MenuSizingHelper.HasDpiOrScaleChanged(96, 1.0, 96, 1.5));
+ }
+
+ [Fact]
+ public void HasDpiOrScaleChanged_TinyScaleDifference_ReturnsFalse()
+ {
+ // Difference < tolerance (0.001) should be treated as the same
+ Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(96, 1.0, 96, 1.0 + 0.0001));
+ }
+
+ [Fact]
+ public void HasDpiOrScaleChanged_ZeroPreviousDpi_NormalizesToDefault()
+ {
+ // Both zero → both normalised to 96 → same → no change
+ Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(0, 1.0, 0, 1.0));
+ }
+
+ [Fact]
+ public void HasDpiOrScaleChanged_InvalidScale_NormalizesToOne()
+ {
+ // NaN/0/negative scale → normalised to 1.0 on both sides → no change
+ Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(96, double.NaN, 96, 0.0));
+ }
+
+ // ── CalculateWindowHeight ────────────────────────────────────────────────
+
+ [Fact]
+ public void CalculateWindowHeight_ContentFitsInWorkArea_ReturnsContentHeight()
+ {
+ Assert.Equal(300, MenuSizingHelper.CalculateWindowHeight(300, 900));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_ContentExceedsWorkArea_ClampsToWorkArea()
+ {
+ Assert.Equal(900, MenuSizingHelper.CalculateWindowHeight(1000, 900));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_ZeroContentHeight_ReturnsMinimum()
+ {
+ Assert.Equal(100, MenuSizingHelper.CalculateWindowHeight(0, 900));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_NegativeContentHeight_TreatedAsZero()
+ {
+ Assert.Equal(100, MenuSizingHelper.CalculateWindowHeight(-50, 900));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_ZeroWorkArea_ReturnsContentOrMinimum()
+ {
+ Assert.Equal(200, MenuSizingHelper.CalculateWindowHeight(200, 0));
+ Assert.Equal(100, MenuSizingHelper.CalculateWindowHeight(50, 0));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_WorkAreaSmallerThanMinimum_ClampsToWorkArea()
+ {
+ // minimumHeight default is 100, but work area is only 60 → clamp to work area
+ Assert.Equal(60, MenuSizingHelper.CalculateWindowHeight(0, 60));
+ }
+
+ [Fact]
+ public void CalculateWindowHeight_CustomMinimumHeight_IsRespected()
+ {
+ Assert.Equal(50, MenuSizingHelper.CalculateWindowHeight(0, 900, minimumHeight: 50));
+ }
+}
From ef6ac8acbab245eb16a6e4f87fbdb94677352803 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Sat, 23 May 2026 15:05:20 -0700
Subject: [PATCH 100/115] fix(chat): surface keyless event drops
Keep dropping inbound chat and agent timeline events that lack a canonical sessionKey, but raise a one-shot visible diagnostic without including dropped payload content.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/CONNECTION_ARCHITECTURE.md | 4 +
.../Chat/OpenClawChatDataProvider.cs | 31 ++++++++
.../Strings/en-us/Resources.resw | 6 ++
.../Strings/fr-fr/Resources.resw | 6 ++
.../Strings/nl-nl/Resources.resw | 6 ++
.../Strings/zh-cn/Resources.resw | 6 ++
.../Strings/zh-tw/Resources.resw | 6 ++
.../OpenClawChatDataProviderTests.cs | 79 ++++++++++++++++++-
8 files changed, 140 insertions(+), 4 deletions(-)
diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md
index c89bd8627..13353f1d6 100644
--- a/docs/CONNECTION_ARCHITECTURE.md
+++ b/docs/CONNECTION_ARCHITECTURE.md
@@ -62,6 +62,10 @@ MigrateFromSettings(...) // one-time legacy migration
The operator client is received through the `OperatorClientChanged` event. The app subscribes to data events (sessions, nodes, usage, config, pairing, models, agents, etc.) and calls request methods for chat, node invocations, and configuration.
+### Chat timeline event routing
+
+Inbound chat and agent timeline events must include the gateway's canonical `sessionKey`. The tray client must not synthesize a literal `main` key for keyless inbound events, because that can merge unrelated events into the wrong timeline. When a keyless chat or agent event arrives, the tray drops it and raises a one-shot diagnostic so the protocol issue is visible without exposing the dropped message contents.
+
## Startup wiring (App.xaml.cs)
```
diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
index 5176d85f2..e802599bf 100644
--- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
+++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatDataProvider.cs
@@ -98,6 +98,7 @@ public sealed class OpenClawChatDataProvider : IChatDataProvider
// Track recently-sent local user message texts so we can suppress
// SSE echoes while still displaying messages from other clients.
private readonly Dictionary> _localSentTexts = new();
+ private int _keylessEventDiagnosticRaised;
// Threads where we locally initiated the current turn (via SendMessageAsync).
// When lifecycle.start arrives for a thread NOT in this set, we know a remote
// client started the turn and should fetch the user message from history.
@@ -1021,6 +1022,7 @@ private void OnChatMessageReceived(object? sender, ChatMessageInfo message)
if (string.IsNullOrEmpty(message.SessionKey))
{
Logger.Warn($"[ChatProvider] Dropping chat message with empty sessionKey (role={message.Role})");
+ RaiseKeylessEventDiagnosticOnce();
return;
}
@@ -1152,6 +1154,7 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
if (string.IsNullOrEmpty(evt.SessionKey))
{
Logger.Warn($"[ChatProvider] Dropping agent event with empty sessionKey (stream={evt.Stream})");
+ RaiseKeylessEventDiagnosticOnce();
return;
}
var threadId = evt.SessionKey;
@@ -1212,6 +1215,34 @@ private void OnAgentEventReceived(object? sender, AgentEventInfo evt)
ApplyEventAndPublish(threadId, mapped, meta);
}
+ private void RaiseKeylessEventDiagnosticOnce()
+ {
+ if (System.Threading.Interlocked.Exchange(ref _keylessEventDiagnosticRaised, 1) != 0)
+ return;
+
+ var threadId = GetKeylessEventDiagnosticThreadId();
+ var title = LocalizationHelper.GetString("Chat_Notification_KeylessEventDropped");
+ var message = LocalizationHelper.GetString("Chat_Notification_KeylessEventDroppedMessage");
+
+ RaiseNotification(new ChatProviderNotification(
+ ChatProviderNotificationKind.Error,
+ threadId ?? string.Empty,
+ title,
+ message));
+
+ if (!string.IsNullOrWhiteSpace(threadId))
+ ApplyEventAndPublish(threadId, new ChatStatusEvent(message, ChatTone.Warning));
+ }
+
+ private string? GetKeylessEventDiagnosticThreadId()
+ {
+ lock (_gate)
+ {
+ return ResolveDefaultThreadIdLocked()
+ ?? _timelines.Keys.FirstOrDefault(k => !string.IsNullOrWhiteSpace(k));
+ }
+ }
+
private string? _deferredAbortRunId; // set inside lock when pending abort fires; read outside lock to send RPC
private int _deferredAbortCount; // how many user messages to force-persist as aborted
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 1a51e2a6e..6c3ca04b0 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -2958,6 +2958,12 @@ On your gateway host (Mac/Linux), run:
Connection lost — response interrupted.
+
+ Gateway timeline event dropped
+
+
+ The gateway sent a chat timeline event without a session key, so OpenClaw ignored it to avoid mixing timelines. Update or restart the gateway if this continues.
+
Test Voice Input
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 779cb3c6f..4d968bfd3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -2910,6 +2910,12 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
Connexion perdue — réponse interrompue.
+
+ Événement de chronologie de passerelle ignoré
+
+
+ La passerelle a envoyé un événement de chronologie de chat sans clé de session ; OpenClaw l'a ignoré pour éviter de mélanger les conversations. Mettez à jour ou redémarrez la passerelle si cela continue.
+
Tester la saisie vocale
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index bee88bc50..e75a64202 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -2911,6 +2911,12 @@ Voer op uw gateway-host (Mac/Linux) uit:
Verbinding verbroken — antwoord onderbroken.
+
+ Gateway-tijdlijngebeurtenis genegeerd
+
+
+ De gateway heeft een chattijdlijngebeurtenis zonder sessiesleutel verzonden, dus OpenClaw heeft deze genegeerd om te voorkomen dat tijdlijnen door elkaar lopen. Werk de gateway bij of start deze opnieuw als dit doorgaat.
+
Spraakinvoer testen
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 1c809fecc..629cc1db5 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -2910,6 +2910,12 @@
连接已断开 — 回复已中断。
+
+ 已丢弃网关时间线事件
+
+
+ 网关发送了缺少会话键的聊天时间线事件,OpenClaw 已将其忽略,以避免混淆时间线。如果此情况持续,请更新或重启网关。
+
测试è¯éŸ³è¾“å…¥
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index de9829292..1953ed378 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -2910,6 +2910,12 @@
連線已中斷 — 回覆已中斷。
+
+ 已捨棄閘道時間軸事件
+
+
+ 閘道傳送了缺少工作階段金鑰的聊天時間軸事件,OpenClaw 已將其忽略,以避免混淆時間軸。如果此情況持續,請更新或重新啟動閘道。
+
測試語音輸入
diff --git a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
index 48f19a1ea..42e4063e5 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
+++ b/tests/OpenClaw.Tray.Tests/OpenClawChatDataProviderTests.cs
@@ -1987,6 +1987,17 @@ private static (FakeBridge bridge, OpenClawChatDataProvider provider, List snapshots, List notifications)
+ CreateConnectedProviderWithNotifications(string canonicalMainKey = "agent:main:main")
+ {
+ var (bridge, provider, snapshots, notifications) = CreateProvider();
+ bridge.HasHandshakeSnapshot = true;
+ bridge.MainSessionKey = canonicalMainKey;
+ bridge.RaiseStatus(ConnectionStatus.Connected);
+ bridge.RaiseSessions(Array.Empty());
+ return (bridge, provider, snapshots, notifications);
+ }
+
[Fact]
public async Task SendMessageAsync_FreshInstall_OptimisticEntryKeyedByCanonicalSessionKey()
{
@@ -2062,10 +2073,9 @@ public async Task ChatEvent_WithEmptySessionKey_IsDropped()
// of the bug: it would silently route mis-routed events to a synthetic
// bucket. The fix is to drop the event and log — surfacing protocol
// bugs instead of papering over them.
- var (bridge, provider, snapshots) = CreateConnectedProvider("agent:main:main");
+ var (bridge, provider, snapshots, notifications) = CreateConnectedProviderWithNotifications("agent:main:main");
await provider.LoadAsync();
await provider.SendMessageAsync("agent:main:main", "hi");
- var snapshotCountBefore = snapshots.Count;
bridge.RaiseChat(new ChatMessageInfo
{
@@ -2074,9 +2084,70 @@ public async Task ChatEvent_WithEmptySessionKey_IsDropped()
Text = "echo with no session key — should be dropped"
});
- Assert.Equal(snapshotCountBefore, snapshots.Count);
// And specifically: no synthetic "main" timeline was created.
- Assert.False(snapshots[^1].Timelines.ContainsKey("main"));
+ var latest = snapshots[^1];
+ var timeline = latest.Timelines["agent:main:main"];
+ Assert.False(latest.Timelines.ContainsKey("main"));
+ Assert.DoesNotContain(timeline.Entries, e => e.Text.Contains("echo with no session key"));
+ Assert.Contains(timeline.Entries, e =>
+ e.Kind == ChatTimelineItemKind.Status &&
+ e.Tone == ChatTone.Warning &&
+ e.Text == "Chat_Notification_KeylessEventDroppedMessage");
+ Assert.Single(notifications, n => n.Title == "Chat_Notification_KeylessEventDropped");
+ Assert.DoesNotContain(notifications, n =>
+ (n.Title?.Contains("echo with no session key") ?? false) ||
+ (n.Message?.Contains("echo with no session key") ?? false));
+ }
+
+ [Fact]
+ public async Task KeylessEvents_RaiseOnlyOneDiagnostic()
+ {
+ var (bridge, provider, snapshots, notifications) = CreateConnectedProviderWithNotifications("agent:main:main");
+ await provider.LoadAsync();
+
+ bridge.RaiseChat(new ChatMessageInfo
+ {
+ SessionKey = "",
+ Role = "assistant",
+ Text = "first dropped payload"
+ });
+ bridge.RaiseAgent(MakeAgentEvent("assistant", """{"delta":"second dropped payload"}""", sessionKey: ""));
+ bridge.RaiseChat(new ChatMessageInfo
+ {
+ SessionKey = "",
+ Role = "assistant",
+ Text = "third dropped payload"
+ });
+
+ Assert.Single(notifications, n => n.Title == "Chat_Notification_KeylessEventDropped");
+ Assert.Single(snapshots[^1].Timelines["agent:main:main"].Entries, e =>
+ e.Kind == ChatTimelineItemKind.Status &&
+ e.Text == "Chat_Notification_KeylessEventDroppedMessage");
+ Assert.DoesNotContain(notifications, n =>
+ (n.Title?.Contains("dropped payload") ?? false) ||
+ (n.Message?.Contains("dropped payload") ?? false));
+ }
+
+ [Fact]
+ public async Task AgentEvent_WithEmptySessionKey_IsDroppedAndDiagnosed()
+ {
+ var (bridge, provider, snapshots, notifications) = CreateConnectedProviderWithNotifications("agent:main:main");
+ await provider.LoadAsync();
+
+ bridge.RaiseAgent(MakeAgentEvent("assistant", """{"delta":"secret agent payload"}""", sessionKey: ""));
+
+ var latest = snapshots[^1];
+ var timeline = latest.Timelines["agent:main:main"];
+ Assert.False(latest.Timelines.ContainsKey("main"));
+ Assert.DoesNotContain(timeline.Entries, e => e.Text.Contains("secret agent payload"));
+ Assert.Contains(timeline.Entries, e =>
+ e.Kind == ChatTimelineItemKind.Status &&
+ e.Tone == ChatTone.Warning &&
+ e.Text == "Chat_Notification_KeylessEventDroppedMessage");
+ Assert.Single(notifications, n => n.Title == "Chat_Notification_KeylessEventDropped");
+ Assert.DoesNotContain(notifications, n =>
+ (n.Title?.Contains("secret agent payload") ?? false) ||
+ (n.Message?.Contains("secret agent payload") ?? false));
}
[Fact]
From cefce3952ab12a1546239c9052ea918a9d37f882 Mon Sep 17 00:00:00 2001
From: Ranjesh <28935693+ranjeshj@users.noreply.github.com>
Date: Mon, 25 May 2026 20:32:08 -0700
Subject: [PATCH 101/115] Redesign local gateway setup as out-of-process
SetupEngine (#529)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: add standalone SetupEngine with transactional pipeline
New headless setup engine that installs WSL, configures OpenClaw gateway,
pairs operator/node connections, and verifies end-to-end connectivity.
- Transactional pipeline with retry, rollback, and crash-recovery journal
- Structured JSONL logging with secret redaction
- v2 signature fix for local gateways in GatewayConnectionManager
- All 15 steps working E2E in headless mode
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: gateway bind lan + keepalive step for reliable WSL2 connectivity
- Configure gateway with bind=lan (0.0.0.0) instead of loopback to avoid
unreliable WSL2 localhost port forwarding
- Add StartKeepaliveStep (step 16) to keep distro alive after setup
completes, ensuring tray connects instantly on launch
- Add default-config.json and design doc
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(setup-engine): config-driven capabilities, settings, and WSL/gateway config
- Expand SetupConfig with nested WslConfig, GatewayConfig, CapabilitiesConfig,
TraySettingsConfig, and PairingConfig — zero hardcoded values
- Register node capabilities (stub INodeCapability) before ConnectAsync so
gateway stores caps/commands from hello message
- Write settings.json after node pairing (EnableNodeMode=true + cap toggles)
using merge logic that preserves existing user settings
- Make WSL wsl.conf generation config-driven (user, systemd, interop, etc.)
- Make gateway config-driven (bind mode, auth, health timeout, extra config)
- Write keepalive marker file to prevent tray duplicate keepalive
- Add fully commented default-config.json with all configurable properties
Verified: clean build (0 errors/0 warnings), full E2E run in 118s,
tray auto-connects with capabilities registered.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(setup-engine): drain pending device/node approvals at end of setup
Adds DrainPendingApprovalsAsync to VerifyEndToEndStep that iteratively
approves any remaining pending device or node pairing requests. This
ensures the tray launches with zero 'Pairing approval pending' badges.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore(setup-engine): remove remote gateway mode (local-wsl only)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Add SetupEngine WinUI app with fluent onboarding flow
Standalone unpackaged WinUI app (no FunctionalUI dependency) with:
- Welcome page with lobster icon, info card, V2 text strings
- Capabilities page with 2-column grid, icons, descriptions, toggles
- Progress page with step groups, badges (spinner/check/error), log viewer
- Complete page with success/error state, launch tray button
Features:
- Mica backdrop + extended title bar
- DPI-aware window sizing (720x700 logical)
- UAC manifest (asInvoker) to avoid elevation prompt
- --headless bypass for automation
- Config-driven defaults from SetupConfig
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Polish setup engine UI to match V2 onboarding flow
- Welcome: flex layout, full V2 text, 'Install new WSL Gateway' button,
confirmation dialog, 'Advanced setup' link (opens tray connection page)
- Title bar: lobster icon + 'OpenClaw Setup' text, 36px height
- Permissions page: 5 rows (notifications, camera, mic, location, screen),
live status checks, 'Open Settings' buttons, 'Refresh status'
- Complete page: party popper, amber Node Mode banner, startup toggle,
'Finish' button with tray launch + optional registry startup entry
- Window height increased to 820px for better step row spacing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Require config file — no hardcoded defaults
- Remove all hardcoded defaults from SetupConfig (DistroName, GatewayPort, BaseDistro, etc.)
- Both UI and headless exe now require a config file to run
- UI auto-loads bundled default-config.json from AppContext.BaseDirectory
- Headless Program.cs exits with error if no config found
- Added Content Include in csproj to bundle default-config.json with exe
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix cleanup, add error UX, launch tray via deep link
- CleanupStaleDistroStep: wsl --shutdown + delete orphaned VHD directory
- CompletePage: show error message + 'View full log' link on failure
- CompletePage: kill old tray, launch via openclaw://chat protocol
- Update SETUP_ENGINE_REDESIGN.md to reflect current implementation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Add setup engine wizard flow
* Polish setup wizard UI and WSL setup resilience
- add interactive gateway wizard rendering for SetupEngine UI
- make wizard messages render links and device codes inline
- refine progress/log layout and setup failure visuals
- fix wizard retry/skip behavior and credential precedence
- harden WSL cleanup, base distro reuse, and missing WSL handling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Replace old onboarding/setup with out-of-process SetupEngine.UI
- Add RollbackAsync to 6 SetupEngine steps for --uninstall support
- Add UninstallAsync to SetupPipeline (reverse rollback execution)
- Add --uninstall, --confirm-destructive, --json-output CLI flags
- Replace ShowOnboardingAsync with out-of-process SetupEngine.UI launch
- Rewrite CliUninstallHandler and SettingsPage uninstall to use SetupEngine
- Delete LocalGatewaySetup (4000+ lines), Onboarding, OnboardingV2 projects
- Extract GatewayConnectorInterfaces and WslCommandRunner from deleted code
- Fix StartupSetupState to scan per-gateway dirs for device tokens
- Simplify WSL keepalive to direct wsl process spawn
- Add SetupEngine to build.ps1 with post-build copy to WinUI output
- Set production defaults: DistroName=OpenClawGateway, GatewayPort=18789
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Add E2E first-time setup CI test
Create tests/OpenClaw.E2ETests/ project with end-to-end tests that exercise
the full setup pipeline headless via SetupEngine CLI, spawn the tray app,
and verify operator+node connectivity through MCP app.status/app.nodes calls.
- E2ESetupFixture: runs Program.Main() headless, patches settings for MCP,
spawns tray process, polls connection status, cleans up via uninstall
- SetupAndConnectTests: verifies connected state and node capabilities
- McpClient: JSON-RPC client for MCP HTTP server verification
- CI workflow: parallel e2e job with test artifact upload for debugging
- SetupEngine.csproj: add RuntimeIdentifiers for RID-specific test builds
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: setup engine reliability fixes and unit tests
Hanselman adversarial review (Opus + Codex) identified 18 issues.
Fixed 12 reliability bugs across the setup engine:
HIGH consensus (both models):
- RetryExecutor: wrap action() in try/catch to prevent pipeline crashes
- CommandRunner: catch Win32Exception on process.Start()
- ConfigureGatewayStep: shell-escape ExtraConfig values
Verified LOW consensus:
- TryResetReloadModeAsync: use CancellationToken.None in finally
- TransactionJournal: catch IOException on writes
- CleanupStaleGatewayStep: delete setup-state.json from both AppData and LocalAppData
- AutoApprove: fall back to BootstrapToken when SharedGatewayToken is null
- TrayArtifactCleanup: protect DeleteFileIfExists with try/catch
- StartKeepaliveStep: remove unused stdout/stderr redirect
- PairOperatorStep: unsubscribe DeviceTokenReceived handler
- Program: run TrayArtifactCleanup on Cancelled outcome
- CleanupStaleDistroStep: retry VHD directory deletion with backoff
Added 66 unit tests in new OpenClaw.SetupEngine.Tests project:
- RetryExecutorTests (11), SetupPipelineTests (14),
TransactionJournalTests (9), SetupLoggerTests (7),
SetupConfigTests (18), SetupContextTests (7)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* ci: replace OnboardingV2 tests with SetupEngine tests, fix E2E restore
- Replace OnboardingV2.Tests build/run steps with SetupEngine.Tests (66 unit tests)
- Remove empty OnClawTray.OnboardingV2.Tests project (superseded by SetupEngine)
- Drop --no-restore from E2E build step to fix RID-mismatch NETSDK1004
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* ci: fix SetupEngine.Tests restore and rename e2e job
- Add -r win-x64 to SetupEngine.Tests build (allows implicit restore for RID-specific deps)
- Add -r win-x64 to SetupEngine.Tests run step
- Rename e2e job to e2etests for clarity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: address ClawSweeper P1 findings — gateway cleanup, URL validation, release packaging
- CleanupStaleGatewayStep: preserve SSH-tunneled and non-local gateway
records instead of deleting by URL match alone
- InstallCliStep: validate HTTPS scheme, shell-quote URL, add
--proto '=https' --tlsv1.2 to curl
- ci.yml: publish SetupEngine.UI into release package
- Add 8 unit tests covering gateway preservation and URL validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup engine: WSL lockdown, secure defaults, token handling, diagnostics
- Default gateway bind from 'lan' (0.0.0.0) to 'loopback' (127.0.0.1)
- Add ValidateWslLockdownStep: verify user, dirs, ownership after configure
- Replace --token argv with OPENCLAW_GATEWAY_TOKEN env var (9 call sites)
- Add ExistingConfigDetector for dynamic replacement dialog on WelcomePage
- Remove unimplemented OperatorScopes/NodeScopes/CliScopes from PairingConfig
- Add port conflict detection (ss -tlnp) and improved failure diagnostics
- Add RedactTokens helper for log sanitization
- Default SkipPermissions to false, add fallback tray exe path
- Fix docs drift: step count, default claims
- Add UI step group mappings for validate-wsl-lockdown and run-wizard
- 279 new lines of unit tests (lockdown, bind validation, token redaction, etc.)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup engine reliability
Add setup run locking, append-only recovery journals, atomic persistence, bounded command output and rollback handling, UI cancellation/error guards, wizard loop bounds, isolated local data support, and expanded token redaction.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix setup engine WSL isolation
Ensure setup runs privileged WSL configuration as root so imported base distros with non-root defaults still configure correctly. Align local AppData override handling across SetupEngine, tray setup detection, keepalive, and e2e isolation, and accept numeric setup-state phases written by the new engine.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Stabilize setup engine CI tests
Serialize SetupEngine tests that mutate process-wide environment variables and make the E2E fixture wait until app.nodes reports an online node with capabilities before allowing tests to proceed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup engine approval and rollback flows
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Bring setup window to foreground
Focus SetupEngine.UI when launched from the tray and briefly make the setup window topmost so user-initiated setup actions are visible. Reuse the tray icon for the setup app so both companion surfaces share branding.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Use isolated identity path for setup detection
Prefer OPENCLAW_TRAY_DATA_DIR for tray identity checks in isolated runs so startup setup detection sees the per-gateway device tokens written by SetupEngine and does not relaunch setup after a successful install.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix empty cron and event stream states
Mark cron loading complete when the gateway returns an empty jobs list, and place the Event Stream empty state in the main content row so the page does not look blank when no agent events have arrived yet.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix event stream page localization crash
Remove an invalid x:Uid from the Event Stream clear-button TextBlock. The matching resource includes a Content property, which TextBlock does not support, causing AgentEventsPage to throw during XAML load and making navigation appear to do nothing.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup engine gateway configuration
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix connection dashboard token link
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Improve setup wizard option hints
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Polish setup UI assets and welcome animation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address setup hardening feedback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup approval drain
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden setup wizard validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Polish setup progress indicators
Use Fluent icon glyphs for completed and failed setup steps, add a subtle pending indicator, and give the active progress ring enough space to render cleanly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix tray UI test runtime bootstrap
Align the UI test WinAppSDK bootstrap/runtime install with the Microsoft.WindowsAppSDK package version and add fixture startup diagnostics.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix WSL keepalive startup
Use the active gateway registry record when deciding whether tray startup should keep the local WSL gateway alive, and add unit and e2e coverage for the keepalive process.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address remaining setup feedback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Scott Hanselman
---
.github/workflows/ci.yml | 100 +-
build.ps1 | 23 +-
docs/SETUP_ENGINE_REDESIGN.md | 392 ++
docs/gateway-node-integration.md | 7 +-
openclaw-windows-node.slnx | 10 +-
.../GatewayConnectionManager.cs | 5 +
src/OpenClaw.Connection/GatewayRecord.cs | 3 +
src/OpenClaw.SetupEngine.UI/App.xaml | 13 +
src/OpenClaw.SetupEngine.UI/App.xaml.cs | 19 +
.../OpenClaw.SetupEngine.UI.csproj | 51 +
.../Pages/CapabilitiesPage.xaml | 50 +
.../Pages/CapabilitiesPage.xaml.cs | 116 +
.../Pages/CompletePage.xaml | 93 +
.../Pages/CompletePage.xaml.cs | 119 +
.../Pages/PermissionsPage.xaml | 57 +
.../Pages/PermissionsPage.xaml.cs | 227 +
.../Pages/ProgressPage.xaml | 69 +
.../Pages/ProgressPage.xaml.cs | 360 ++
.../Pages/WelcomePage.xaml | 67 +
.../Pages/WelcomePage.xaml.cs | 109 +
.../Pages/WizardPage.xaml | 55 +
.../Pages/WizardPage.xaml.cs | 653 +++
src/OpenClaw.SetupEngine.UI/Program.cs | 29 +
src/OpenClaw.SetupEngine.UI/SetupWindow.xaml | 27 +
.../SetupWindow.xaml.cs | 117 +
src/OpenClaw.SetupEngine.UI/app.manifest | 22 +
.../ApprovalRequestHelper.cs | 160 +
src/OpenClaw.SetupEngine/AssemblyInfo.cs | 3 +
src/OpenClaw.SetupEngine/AtomicFile.cs | 58 +
src/OpenClaw.SetupEngine/CommandRunner.cs | 200 +
.../ExistingConfigDetector.cs | 105 +
.../OpenClaw.SetupEngine.csproj | 16 +
src/OpenClaw.SetupEngine/Program.cs | 216 +
src/OpenClaw.SetupEngine/RetryExecutor.cs | 59 +
src/OpenClaw.SetupEngine/SetupContext.cs | 308 ++
src/OpenClaw.SetupEngine/SetupLogger.cs | 178 +
src/OpenClaw.SetupEngine/SetupPipeline.cs | 319 ++
src/OpenClaw.SetupEngine/SetupRunLock.cs | 51 +
src/OpenClaw.SetupEngine/SetupSteps.cs | 2430 ++++++++++
src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 589 +++
.../StubNodeCapability.cs | 24 +
.../TransactionJournal.cs | 129 +
.../TrayArtifactCleanup.cs | 173 +
src/OpenClaw.SetupEngine/WizardSelection.cs | 44 +
src/OpenClaw.SetupEngine/default-config.json | 131 +
.../OpenClaw.SetupPreview.csproj | 5 +-
.../Capabilities/AppCapability.cs | 10 +
src/OpenClaw.Shared/Mcp/McpToolBridge.cs | 2 +
src/OpenClaw.Tray.WinUI/App.xaml.cs | 550 ++-
.../CliUninstallHandler.cs | 141 +-
.../Helpers/GatewayDashboardUrlBuilder.cs | 30 +
.../GatewayWizard/GatewayWizardPage.cs | 838 ----
.../GatewayWizard/GatewayWizardState.cs | 45 -
.../Onboarding/OnboardingWindow.cs | 657 ---
.../Onboarding/Services/GatewayHealthCheck.cs | 96 -
.../Onboarding/Services/InputValidator.cs | 63 -
.../Services/LocalGatewayApprover.cs | 14 -
.../Services/LocalSetupProgressStageMap.cs | 131 -
.../Services/OnboardingCompletionPolicy.cs | 15 -
.../Services/OnboardingExistingConfigGuard.cs | 183 -
.../Onboarding/Services/PermissionChecker.cs | 261 --
.../Services/WizardErrorFormatter.cs | 153 -
.../Services/WizardFlowController.cs | 215 -
.../Onboarding/Services/WizardStepParser.cs | 140 -
.../Services/WizardStepSelection.cs | 49 -
.../Onboarding/V2/OnboardingV2Bridge.cs | 928 ----
.../OpenClaw.Tray.WinUI.csproj | 43 +-
.../Pages/AgentEventsPage.xaml | 4 +-
.../Pages/ConnectionPage.xaml.cs | 27 +-
.../Pages/CronPage.xaml.cs | 5 +-
.../Pages/SettingsPage.xaml.cs | 72 +-
.../ConnectionManagerOperatorConnector.cs | 1 -
.../ConnectionManagerWindowsNodeConnector.cs | 1 -
.../Services/GatewayConnectorInterfaces.cs | 46 +
.../LocalGatewaySetup/LocalGatewaySetup.cs | 4091 -----------------
.../LocalGatewaySetupDiagnostics.cs | 751 ---
.../LocalGatewayUninstall.cs | 1026 -----
.../SetupExistingGatewayClassifier.cs | 38 +-
.../Services/StartupSetupState.cs | 59 +-
.../Services/WslCommandRunner.cs | 175 +
.../Services/WslKeepAlivePolicy.cs | 103 +
.../Windows/ConnectionStatusWindow.xaml.cs | 1 -
src/OpenClaw.WinNode.Cli/skill.md | 7 +
src/OpenClawTray.OnboardingV2/Animations.cs | 211 -
.../OnboardingV2App.cs | 200 -
.../OnboardingV2State.cs | 353 --
.../OpenClawTray.OnboardingV2.csproj | 35 -
.../Pages/AllSetPage.cs | 150 -
.../Pages/GatewayWelcomePage.cs | 57 -
.../Pages/LocalSetupProgressPage.cs | 233 -
.../Pages/PermissionsPage.cs | 337 --
.../Pages/WelcomePage.cs | 182 -
src/OpenClawTray.OnboardingV2/V2Route.cs | 16 -
src/OpenClawTray.OnboardingV2/V2Strings.cs | 149 -
.../V2SystemTheme.cs | 47 -
src/OpenClawTray.OnboardingV2/V2Theme.cs | 151 -
.../Widgets/StepDots.cs | 39 -
tests/OpenClaw.E2ETests/McpClient.cs | 92 +
.../OpenClaw.E2ETests.csproj | 17 +
.../Setup/E2ESetupFixture.cs | 765 +++
.../Setup/SetupAndConnectTests.cs | 296 ++
.../ApprovalRequestHelperTests.cs | 97 +
.../AtomicFileTests.cs | 51 +
.../EnvironmentVariableCollection.cs | 7 +
.../OpenClaw.SetupEngine.Tests.csproj | 7 +
.../RetryExecutorTests.cs | 177 +
.../SetupConfigTests.cs | 321 ++
.../SetupContextTests.cs | 127 +
.../SetupLoggerTests.cs | 171 +
.../SetupPipelineTests.cs | 396 ++
.../SetupRunLockTests.cs | 42 +
.../SetupStepsTests.cs | 580 +++
.../TransactionJournalTests.cs | 173 +
.../ValidateWslLockdownStepTests.cs | 114 +
.../WizardSelectionTests.cs | 68 +
...nectionManagerWindowsNodeConnectorTests.cs | 1 -
.../ExistingConfigGuardPolicyTests.cs | 46 -
.../GatewayDashboardUrlBuilderTests.cs | 30 +
.../GatewayHealthCheckTests.cs | 73 -
.../GatewayWizardStateTests.cs | 60 -
.../InstallerIssAssertionTests.cs | 18 -
.../LocalGatewayApproverTests.cs | 108 -
.../LocalGatewaySetupAutoPairFlagTests.cs | 185 -
.../LocalGatewaySetupDiagnosticsTests.cs | 154 -
.../LocalGatewaySetupTests.cs | 1232 -----
.../LocalGatewayUninstallTests.cs | 1516 ------
.../LocalSetupProgressStageMapTests.cs | 188 -
.../OnboardingCompletionPolicyTests.cs | 30 -
.../OnboardingExistingConfigGuardTests.cs | 144 -
.../OpenClaw.Tray.Tests.csproj | 18 +-
.../OperatorPairingApprovalTests.cs | 1071 -----
.../SecurityValidationTests.cs | 183 -
.../StartupSetupStateTests.cs | 61 +-
.../TrayMenuWindowMarkupTests.cs | 37 -
.../WindowsTrayNodePairingApprovalTests.cs | 246 -
.../WizardErrorFormatterTests.cs | 226 -
.../WizardFlowControllerTests.cs | 456 --
.../WizardSelectionTests.cs | 70 -
.../WizardStepParsingTests.cs | 175 -
.../WslDistroKeepAliveTests.cs | 435 --
.../WslKeepAlivePolicyTests.cs | 146 +
.../OpenClaw.Tray.UITests/UIThreadFixture.cs | 37 +-
.../OnboardingV2StateTests.cs | 127 -
.../OpenClawTray.OnboardingV2.Tests.csproj | 16 -
144 files changed, 12602 insertions(+), 19110 deletions(-)
create mode 100644 docs/SETUP_ENGINE_REDESIGN.md
create mode 100644 src/OpenClaw.SetupEngine.UI/App.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/App.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/Program.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/SetupWindow.xaml
create mode 100644 src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
create mode 100644 src/OpenClaw.SetupEngine.UI/app.manifest
create mode 100644 src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
create mode 100644 src/OpenClaw.SetupEngine/AssemblyInfo.cs
create mode 100644 src/OpenClaw.SetupEngine/AtomicFile.cs
create mode 100644 src/OpenClaw.SetupEngine/CommandRunner.cs
create mode 100644 src/OpenClaw.SetupEngine/ExistingConfigDetector.cs
create mode 100644 src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj
create mode 100644 src/OpenClaw.SetupEngine/Program.cs
create mode 100644 src/OpenClaw.SetupEngine/RetryExecutor.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupContext.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupLogger.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupPipeline.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupRunLock.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupSteps.cs
create mode 100644 src/OpenClaw.SetupEngine/SetupWizardRunner.cs
create mode 100644 src/OpenClaw.SetupEngine/StubNodeCapability.cs
create mode 100644 src/OpenClaw.SetupEngine/TransactionJournal.cs
create mode 100644 src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs
create mode 100644 src/OpenClaw.SetupEngine/WizardSelection.cs
create mode 100644 src/OpenClaw.SetupEngine/default-config.json
create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/GatewayWizard/GatewayWizardPage.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/GatewayWizard/GatewayWizardState.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/GatewayHealthCheck.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/InputValidator.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalSetupProgressStageMap.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingCompletionPolicy.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardErrorFormatter.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardFlowController.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepParser.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepSelection.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs
create mode 100644 src/OpenClaw.Tray.WinUI/Services/GatewayConnectorInterfaces.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetupDiagnostics.cs
delete mode 100644 src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
create mode 100644 src/OpenClaw.Tray.WinUI/Services/WslCommandRunner.cs
create mode 100644 src/OpenClaw.Tray.WinUI/Services/WslKeepAlivePolicy.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Animations.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/OnboardingV2App.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/OnboardingV2State.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/OpenClawTray.OnboardingV2.csproj
delete mode 100644 src/OpenClawTray.OnboardingV2/Pages/AllSetPage.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Pages/GatewayWelcomePage.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Pages/LocalSetupProgressPage.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Pages/PermissionsPage.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Pages/WelcomePage.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/V2Route.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/V2Strings.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/V2SystemTheme.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/V2Theme.cs
delete mode 100644 src/OpenClawTray.OnboardingV2/Widgets/StepDots.cs
create mode 100644 tests/OpenClaw.E2ETests/McpClient.cs
create mode 100644 tests/OpenClaw.E2ETests/OpenClaw.E2ETests.csproj
create mode 100644 tests/OpenClaw.E2ETests/Setup/E2ESetupFixture.cs
create mode 100644 tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/ApprovalRequestHelperTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/AtomicFileTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/EnvironmentVariableCollection.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj
create mode 100644 tests/OpenClaw.SetupEngine.Tests/RetryExecutorTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupContextTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupLoggerTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupPipelineTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupRunLockTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/TransactionJournalTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/ValidateWslLockdownStepTests.cs
create mode 100644 tests/OpenClaw.SetupEngine.Tests/WizardSelectionTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/ExistingConfigGuardPolicyTests.cs
create mode 100644 tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/GatewayHealthCheckTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/GatewayWizardStateTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewayApproverTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewaySetupAutoPairFlagTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewaySetupTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/LocalSetupProgressStageMapTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/OnboardingCompletionPolicyTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/OperatorPairingApprovalTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/SecurityValidationTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WindowsTrayNodePairingApprovalTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WizardErrorFormatterTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WizardFlowControllerTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WizardSelectionTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WizardStepParsingTests.cs
delete mode 100644 tests/OpenClaw.Tray.Tests/WslDistroKeepAliveTests.cs
create mode 100644 tests/OpenClaw.Tray.Tests/WslKeepAlivePolicyTests.cs
delete mode 100644 tests/OpenClawTray.OnboardingV2.Tests/OnboardingV2StateTests.cs
delete mode 100644 tests/OpenClawTray.OnboardingV2.Tests/OpenClawTray.OnboardingV2.Tests.csproj
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a170fbc89..ff7104973 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,7 +85,7 @@ jobs:
dotnet build tests/OpenClaw.Tray.IntegrationTests -c Debug -r win-x64 --no-restore
dotnet build tests/OpenClaw.Tray.UITests -c Debug -r win-x64 --no-restore
dotnet build tests/OpenClawTray.FunctionalUI.Tests -c Debug -r win-x64 --no-restore
- dotnet build tests/OpenClawTray.OnboardingV2.Tests -c Debug -r win-x64 --no-restore
+ dotnet build tests/OpenClaw.SetupEngine.Tests -c Debug -r win-x64
- name: Run Shared Tests
env:
@@ -158,14 +158,14 @@ jobs:
--logger trx;LogFileName=OpenClaw.Tray.IntegrationTests.trx"
# UI tests need a real visual tree AND a system-registered WindowsAppRuntime
- # framework MSIX — the test fixture calls Bootstrap.Initialize(1.8, stable),
+ # framework MSIX — the test fixture calls Bootstrap.Initialize(2.0, stable),
# which looks up the framework package by identity. The hosted windows-2025
# runner image doesn't preinstall it, so we install it explicitly here.
- # Version pinned to match Microsoft.WindowsAppSDK 1.8.260101001 in the csprojs.
- - name: Install WindowsAppRuntime 1.8
+ # Version pinned to match Microsoft.WindowsAppSDK 2.0.1 in the csprojs.
+ - name: Install WindowsAppRuntime 2.0.1
shell: pwsh
run: |
- $url = "https://aka.ms/windowsappsdk/1.8/1.8.260101001/windowsappruntimeinstall-x64.exe"
+ $url = "https://aka.ms/windowsappsdk/2.0/2.0.1/windowsappruntimeinstall-x64.exe"
$exe = "$env:RUNNER_TEMP\WindowsAppRuntimeInstall.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
& $exe --quiet
@@ -184,18 +184,18 @@ jobs:
--results-directory TestResults\FunctionalUI
--logger trx;LogFileName=OpenClawTray.FunctionalUI.Tests.trx"
- - name: Run Onboarding V2 Tests
+ - name: Run SetupEngine Tests
run: >
dotnet-coverage collect
- --output TestResults\OnboardingV2\coverage.cobertura.xml
+ --output TestResults\SetupEngine\coverage.cobertura.xml
--output-format cobertura
- "dotnet test tests/OpenClawTray.OnboardingV2.Tests
+ "dotnet test tests/OpenClaw.SetupEngine.Tests
--no-build
-c Debug
-r win-x64
--verbosity normal
- --results-directory TestResults\OnboardingV2
- --logger trx;LogFileName=OpenClawTray.OnboardingV2.Tests.trx"
+ --results-directory TestResults\SetupEngine
+ --logger trx;LogFileName=OpenClaw.SetupEngine.Tests.trx"
- name: Run Tray UI Tests
run: >
@@ -222,8 +222,72 @@ jobs:
semVer: ${{ steps.gitversion.outputs.semVer }}
majorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }}
+ e2etests:
+ needs: repo-hygiene
+ if: ${{ !cancelled() }}
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - name: Fail if repo hygiene failed
+ if: ${{ needs.repo-hygiene.result != 'success' }}
+ shell: pwsh
+ run: |
+ Write-Error "repo-hygiene failed; see the repo-hygiene job output."
+ exit 1
+
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v5
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v5
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }}
+ restore-keys: nuget-${{ runner.os }}-
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build Shared Library
+ run: dotnet build src/OpenClaw.Shared -c Debug --no-restore
+
+ - name: Build SetupEngine
+ run: dotnet build src/OpenClaw.SetupEngine -c Debug --no-restore
+
+ - name: Build Tray App (WinUI)
+ run: dotnet build src/OpenClaw.Tray.WinUI -c Debug -r win-x64
+
+ - name: Build E2E Tests
+ run: dotnet build tests/OpenClaw.E2ETests -c Debug -r win-x64
+
+ - name: Run E2E Tests
+ run: >
+ dotnet test tests/OpenClaw.E2ETests
+ --no-build
+ -c Debug
+ -r win-x64
+ --verbosity normal
+ --results-directory TestResults/E2E
+ --logger "trx;LogFileName=OpenClaw.E2ETests.trx"
+ --logger "console;verbosity=detailed"
+
+ - name: Upload E2E Test Results & Logs
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: e2e-test-results
+ path: |
+ TestResults/E2E/
+ if-no-files-found: warn
+
build:
- needs: test
+ needs: [test, e2etests]
runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }}
strategy:
matrix:
@@ -253,6 +317,12 @@ jobs:
- name: Publish WinUI Tray App
run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r ${{ matrix.rid }} --self-contained --no-restore -p:Version=${{ needs.test.outputs.semVer }} -o publish
+ - name: Publish SetupEngine.UI
+ run: |
+ dotnet publish src/OpenClaw.SetupEngine.UI -c Release -r ${{ matrix.rid }} --self-contained -p:Version=${{ needs.test.outputs.semVer }} -o publish-setup
+ mkdir publish\SetupEngine
+ copy publish-setup\* publish\SetupEngine\ -Recurse
+
- name: Disable NuGet source mapping for signing
if: startsWith(github.ref, 'refs/tags/v') && matrix.rid != 'win-arm64'
shell: pwsh
@@ -290,7 +360,7 @@ jobs:
path: publish/
build-msix:
- needs: test
+ needs: [test, e2etests]
runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }}
continue-on-error: true
strategy:
@@ -382,7 +452,7 @@ jobs:
path: ${{ steps.find-msix.outputs.msix_path }}
build-extension:
- needs: test
+ needs: [test, e2etests]
runs-on: windows-latest
strategy:
matrix:
@@ -416,8 +486,8 @@ jobs:
path: src/OpenClaw.CommandPalette/bin/${{ matrix.platform }}/Debug/
release:
- needs: [repo-hygiene, test, build, build-msix, build-extension]
- if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.build.result == 'success' && needs.build-extension.result == 'success' && !cancelled()
+ needs: [repo-hygiene, test, e2etests, build, build-msix, build-extension]
+ if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && needs.build-extension.result == 'success' && !cancelled()
runs-on: windows-latest
permissions:
contents: write
diff --git a/build.ps1 b/build.ps1
index 8d422ddcf..3a17978b7 100644
--- a/build.ps1
+++ b/build.ps1
@@ -23,7 +23,7 @@
#>
param(
- [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli")]
+ [ValidateSet("All", "Tray", "WinUI", "Shared", "CommandPalette", "Cli", "WinNodeCli", "SetupEngine")]
[string]$Project = "All",
[ValidateSet("Debug", "Release")]
@@ -204,9 +204,10 @@ $projects = @{
"Tray" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true }
"WinUI" = @{ Path = "src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj"; UseRid = $true }
"CommandPalette" = @{ Path = "src/OpenClaw.CommandPalette/OpenClaw.CommandPalette.csproj"; UseRid = $false }
+ "SetupEngine" = @{ Path = "src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj"; UseRid = $true }
}
-$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "WinUI") } else { @($Project) }
+$toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "SetupEngine", "WinUI") } else { @($Project) }
# Always build Shared first if building other projects
if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Shared") {
@@ -221,7 +222,23 @@ foreach ($proj in $toBuild) {
}
# =============================================================================
-# SUMMARY
+# POST-BUILD: Copy SetupEngine.UI into WinUI output so the tray can find it
+# =============================================================================
+if (($buildResults.ContainsKey("SetupEngine") -and $buildResults["SetupEngine"]) -and
+ (($buildResults.ContainsKey("WinUI") -and $buildResults["WinUI"]) -or ($buildResults.ContainsKey("Tray") -and $buildResults["Tray"]))) {
+ $setupTfm = Get-ProjectTargetFramework $projects["SetupEngine"].Path
+ $winUITfm = Get-ProjectTargetFramework $projects["WinUI"].Path
+ if ($setupTfm -and $winUITfm) {
+ $setupOutDir = "src\OpenClaw.SetupEngine.UI\bin\$Configuration\$setupTfm\$rid"
+ $winUIOutDir = "src\OpenClaw.Tray.WinUI\bin\$Configuration\$winUITfm\$rid"
+ $destDir = Join-Path $winUIOutDir "SetupEngine"
+ if (Test-Path $setupOutDir) {
+ if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
+ Copy-Item "$setupOutDir\*" $destDir -Recurse -Force
+ Write-Info "Copied SetupEngine.UI output → $destDir"
+ }
+ }
+}
# =============================================================================
Write-Header "Build Summary"
diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md
new file mode 100644
index 000000000..00622b253
--- /dev/null
+++ b/docs/SETUP_ENGINE_REDESIGN.md
@@ -0,0 +1,392 @@
+# Setup Engine — Architecture & Reference
+
+## Overview
+
+The Setup Engine is a **standalone, config-driven system** for provisioning an OpenClaw WSL gateway from scratch. It consists of two projects:
+
+1. **`OpenClaw.SetupEngine`** — Headless pipeline (console exe). Runs 18 steps sequentially with full JSONL logging, transaction journal, and rollback support.
+2. **`OpenClaw.SetupEngine.UI`** — WinUI3 app that wraps the same pipeline with a 5-page fluent wizard UI.
+
+Both accept a JSON config file to customize behavior. A bundled `default-config.json` ships with each exe and provides secure defaults (loopback bind, WSL isolation, systemd enabled). All defaults can be overridden via config file or environment variables.
+
+---
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ OpenClaw.SetupEngine (net10.0, console exe) │
+│ │
+│ SetupPipeline ──→ 18 SetupStep classes ──→ StepResult │
+│ │ │ │
+│ SetupContext CommandRunner (WSL + Process) │
+│ SetupConfig TransactionJournal (JSONL) │
+│ SetupLogger RetryExecutor │
+│ │
+│ refs: OpenClaw.Connection, OpenClaw.Shared │
+└─────────────────────────────────────────────────────────────┘
+ ▲ callback: Action
+ │
+┌─────────────────────────────────────────────────────────────┐
+│ OpenClaw.SetupEngine.UI (net10.0-windows10.0.22621, WinUI3)│
+│ 5 pages, direct code-behind, no MVVM │
+│ Welcome → Capabilities → Progress → Permissions → Complete │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Project Structure
+
+```
+src/OpenClaw.SetupEngine/
+├── OpenClaw.SetupEngine.csproj # net10.0, console exe
+├── Program.cs # CLI entry: --config, --headless, --dry-run, --rollback-on-failure
+├── SetupPipeline.cs # Sequential step orchestrator (132 lines)
+├── SetupContext.cs # Config model + shared state bag (217 lines)
+├── SetupSteps.cs # All setup step implementations
+├── TransactionJournal.cs # Append-only JSONL journal (77 lines)
+├── SetupLogger.cs # Structured JSONL logger (112 lines)
+├── CommandRunner.cs # Concrete WSL/process command runner
+├── RetryExecutor.cs # Exponential backoff retry
+├── StubNodeCapability.cs # Minimal capability stubs for pairing
+└── default-config.json # THE source of truth for all config values
+
+src/OpenClaw.SetupEngine.UI/
+├── OpenClaw.SetupEngine.UI.csproj # WinAppSDK 2.0.1, self-contained
+├── App.xaml / App.xaml.cs # Application entry
+├── Program.cs # Main with WinUI activation
+├── SetupWindow.xaml / .xaml.cs # 720×820 window, Mica, title bar, navigation
+└── Pages/
+ ├── WelcomePage.xaml / .cs # Logo, info card, Install button + ContentDialog
+ ├── CapabilitiesPage.xaml / .cs # 2-column grid with icons + descriptions
+ ├── ProgressPage.xaml / .cs # Live step rows + streaming log viewer
+ ├── PermissionsPage.xaml / .cs # 5 permission checks + Open Settings buttons
+ └── CompletePage.xaml / .cs # Party popper, amber banner, startup toggle
+```
+
+**Total engine code: ~1,882 lines across 8 files.** UI adds ~10 more files.
+
+---
+
+## Config File (`default-config.json`)
+
+**Config is required.** Neither the headless exe nor the UI will run without one. The bundled `default-config.json` is auto-loaded from `AppContext.BaseDirectory` if no `--config` is specified.
+
+```json
+{
+ "DistroName": "OpenClawGateway",
+ "GatewayPort": 18789,
+ "BaseDistro": "Ubuntu-24.04",
+ "Headless": true,
+ "AutoApprovePairing": true,
+ "CleanBeforeRun": true,
+ "SkipPermissions": false,
+ "SkipWizard": false,
+ "WizardAnswers": {
+ "openclaw-setup": "true",
+ "security-disclaimer": "true",
+ "i-understand-this-is-personal-by-default-and-shared-multi-user-use-requires-lock-down-continue": "true",
+ "setup-mode": "quickstart",
+ "existing-config-detected": "true",
+ "config-handling": "keep",
+ "quickstart": "true",
+ "model-auth-provider": "skip",
+ "default-model": "__keep__",
+ "select-channel-quickstart": "__skip__",
+ "search-provider": "__skip__",
+ "configure-skills-now-recommended": "false"
+ },
+ "LogLevel": "trace",
+ "LogPath": null,
+ "GatewayUrl": null,
+ "BootstrapToken": null,
+ "RollbackOnFailure": false,
+
+ "Wsl": {
+ "User": "openclaw",
+ "Systemd": true,
+ "Interop": false,
+ "AppendWindowsPath": false,
+ "Automount": false,
+ "MountFsTab": false,
+ "UseWindowsTimezone": true,
+ "Memory": null,
+ "Swap": null
+ },
+
+ "Gateway": {
+ "Bind": "loopback",
+ "InstallUrl": null,
+ "Version": null,
+ "HealthTimeoutSeconds": 90,
+ "ReloadMode": "hot",
+ "AuthMode": "token",
+ "ExtraConfig": null
+ },
+
+ "Capabilities": {
+ "System": true, "Canvas": true, "Screen": true,
+ "Camera": true, "Location": true, "Browser": true,
+ "Device": true, "Tts": true, "Stt": true
+ },
+
+ "Settings": {
+ "EnableNodeMode": true,
+ "AutoStart": false,
+ "NodeSystemRunEnabled": true,
+ "NodeCanvasEnabled": true,
+ "NodeScreenEnabled": true,
+ "NodeCameraEnabled": true,
+ "NodeLocationEnabled": true,
+ "NodeBrowserProxyEnabled": true,
+ "NodeTtsEnabled": true,
+ "NodeSttEnabled": true
+ },
+
+ "Pairing": {
+ "TimeoutSeconds": 60
+ }
+}
+```
+
+### Config Layering (priority, highest wins)
+
+1. CLI flags (`--headless`, `--log-path`, `--rollback-on-failure`, `--no-rollback-on-failure`)
+2. Config file (explicit `--config` or bundled `default-config.json`)
+3. Environment variables (`OPENCLAW_SETUP_DISTRO_NAME`, etc.)
+
+---
+
+## Pipeline Steps (18 total)
+
+Executed sequentially. Each step is a small class (30–120 lines) in `SetupSteps.cs`.
+
+| # | Step Class | What It Does |
+|---|-----------|-------------|
+| 1 | `CleanupStaleDistroStep` | Unregister leftover WSL distro if `CleanBeforeRun` |
+| 2 | `CleanupStaleGatewayStep` | Stop orphaned gateway service, remove config |
+| 3 | `PreflightOsStep` | Validate Windows 64-bit, version ≥ 22H2 |
+| 4 | `PreflightWslStep` | Verify WSL installed and version ≥ 2 |
+| 5 | `PreflightPortStep` | Check gateway port is available |
+| 6 | `CreateWslInstanceStep` | Export base distro → import as new instance |
+| 7 | `ConfigureWslInstanceStep` | Write wsl.conf, create user, set dirs |
+| 8 | `ValidateWslLockdownStep` | Verify WSL isolation settings are applied |
+| 9 | `InstallCliStep` | Run install script inside WSL |
+| 10 | `ConfigureGatewayStep` | Write gateway config (bind, port, auth) |
+| 11 | `InstallGatewayServiceStep` | `openclaw gateway install --force` |
+| 12 | `StartGatewayStep` | Start service, poll health endpoint (90s timeout) |
+| 13 | `MintBootstrapTokenStep` | Generate bootstrap token via CLI |
+| 14 | `PairOperatorStep` | WebSocket operator connection + device approval |
+| 15 | `PairNodeStep` | WebSocket node connection + capability registration |
+| 16 | `VerifyEndToEndStep` | End-to-end health check (operator → node round trip) |
+| 17 | `RunGatewayWizardStep` | Run/configure the gateway wizard unless skipped |
+| 18 | `StartKeepaliveStep` | Background WSL keepalive to prevent VM shutdown |
+
+### Step Base Class
+
+```csharp
+public abstract class SetupStep
+{
+ public abstract string Id { get; }
+ public abstract string DisplayName { get; }
+ public abstract Task ExecuteAsync(SetupContext ctx, CancellationToken ct);
+ public virtual Task RollbackAsync(SetupContext ctx, CancellationToken ct) => Task.CompletedTask;
+ public virtual bool CanSkip(SetupContext ctx) => false;
+ public virtual bool CanRetry => true;
+ public virtual RetryPolicy Retry => RetryPolicy.Default;
+}
+```
+
+### StepResult
+
+```csharp
+public sealed record StepResult(StepOutcome Outcome, string? Message = null, Exception? Exception = null);
+```
+
+---
+
+## Key Components
+
+### SetupPipeline
+
+Sequential orchestrator. For each step:
+1. Check `CanSkip` → skip if true
+2. Execute with retry (via `RetryExecutor`)
+3. On failure + `RollbackOnFailure` → try failed-step cleanup, then rollback completed steps in reverse
+4. Journal records every start/complete/rollback
+
+### SetupContext
+
+Shared state bag passed to all steps. Contains:
+- `Config` — the loaded `SetupConfig`
+- `Logger` — structured JSONL logger
+- `Journal` — transaction journal
+- `Commands` — `CommandRunner` for executing WSL/process commands
+- Accumulated runtime state: `DistroName`, `GatewayUrl`, `BootstrapToken`, `GatewayRecordId`
+
+### CommandRunner
+
+A single concrete runner executes Windows processes and WSL scripts (`wsl.exe -d -- bash -lc ...`) with timeouts, bounded output collection, and environment injection.
+
+Every command is logged with exe, sanitized args, timeout, exit code, sanitized stdout/stderr, and elapsed time.
+
+### TransactionJournal
+
+Append-only JSONL file (`.journal.jsonl`) recording step transitions. Enables:
+- Forensic replay of what happened
+- Future `--resume` from last good state
+- Rollback decision tracking
+
+### SetupLogger
+
+Structured JSONL logger. Records sanitized entries for:
+- Step start/complete with timing
+- Every shell command and bounded output
+- Decisions made (e.g., "chose to clean existing distro")
+- State transitions
+- Errors with stack traces
+
+Log path defaults to `%APPDATA%\OpenClawTray\Logs\Setup\setup-.log`
+
+---
+
+## UI Flow
+
+The WinUI app is a **thin shell** — no business logic, just rendering pipeline state. End-user UI runs default to `RollbackOnFailure=true`; `--no-rollback-on-failure` preserves an explicit debugging opt-out.
+
+### Page Flow: Welcome → Capabilities → Progress → Permissions → Complete
+
+**WelcomePage**
+- Lobster icon + "OpenClaw Setup" title bar
+- Info card explaining what will be installed
+- "Install new WSL Gateway" button with ContentDialog confirmation
+- "Advanced setup" link → launches tray with `--page connection`
+
+**CapabilitiesPage**
+- 2-column grid showing capabilities from config
+- Icons + descriptions for each (System, Canvas, Screen, Camera, etc.)
+- "Continue" proceeds to Progress
+
+**ProgressPage**
+- Step rows with spinning ProgressRing → ✓/✗ badges
+- Live streaming log viewer (monospace, auto-scroll)
+- On success → navigates to Permissions
+- On failure → navigates to Complete(success=false)
+
+**PermissionsPage**
+- 5 permission rows: Notifications, Camera, Microphone, Location, Screen Capture
+- Live status checks (registry, DeviceAccessInformation, GraphicsCaptureSession)
+- "Open Settings" buttons launch `ms-settings://` URIs
+- "Refresh status" button, "Continue" proceeds to Complete
+
+**CompletePage**
+- Party popper image
+- "All set!" / error heading
+- Amber "Node Mode Active" warning banner
+- "Launch OpenClaw at startup?" toggle (writes HKCU Run registry)
+- "Finish" button launches tray and closes
+
+### Window Properties
+- 720×820 logical pixels (DPI-scaled)
+- Mica backdrop
+- Custom title bar with lobster icon
+
+---
+
+## CLI Usage
+
+### Headless (Console Exe)
+
+```
+OpenClaw.SetupEngine.exe # uses bundled default-config.json
+OpenClaw.SetupEngine.exe --config custom.json # explicit config
+OpenClaw.SetupEngine.exe --config custom.json --headless # force headless
+OpenClaw.SetupEngine.exe --dry-run # validate config, don't execute
+OpenClaw.SetupEngine.exe --rollback-on-failure # clean up on failure
+OpenClaw.SetupEngine.exe --no-rollback-on-failure # explicit rollback opt-out
+OpenClaw.SetupEngine.exe --log-path ./trace.log # override log location
+```
+
+Exit codes: 0 = success, 1 = failure
+
+### UI (WinUI Exe)
+
+```
+OpenClaw.SetupEngine.UI.exe # uses bundled default-config.json
+OpenClaw.SetupEngine.UI.exe --config custom.json # explicit config
+OpenClaw.SetupEngine.UI.exe --no-rollback-on-failure # debug opt-out from UI cleanup
+```
+
+Uses the bundled default config when no explicit `--config` is supplied.
+
+---
+
+## Build & Run
+
+```powershell
+# Build headless engine
+dotnet build src\OpenClaw.SetupEngine\OpenClaw.SetupEngine.csproj
+
+# Build UI app (requires Platform specification)
+dotnet build src\OpenClaw.SetupEngine.UI\OpenClaw.SetupEngine.UI.csproj -p:Platform=x64
+
+# Run UI
+Start-Process "src\OpenClaw.SetupEngine.UI\bin\x64\Debug\net10.0-windows10.0.22621.0\win-x64\OpenClaw.SetupEngine.UI.exe"
+
+# Run headless
+& "src\OpenClaw.SetupEngine\bin\Debug\net10.0\OpenClaw.SetupEngine.exe"
+```
+
+---
+
+## Design Principles
+
+1. **Config is explicit** — secure bundled defaults can be overridden by config file, environment, or flags
+2. **Log everything** — every command, decision, and state change in structured JSONL
+3. **Steps are small** — each step is a focused class, 30–120 lines
+4. **Fail closed on approval** — setup validates approval request IDs and avoids ambiguous node approvals
+5. **Clean-start guarantee** — stale state from prior runs is cleaned before proceeding
+6. **UI is optional** — engine works identically without UI; UI is a passive observer
+7. **Direct code-behind** — no MVVM, no ViewModels, no framework abstractions in UI
+8. **Transactional** — journal + rollback on failure, enabled by default for the UI
+
+---
+
+## What We Reuse
+
+| Component | Source | How |
+|-----------|--------|-----|
+| WebSocket protocol | `OpenClaw.Shared` | Project reference |
+| Gateway registry/credentials | `OpenClaw.Connection` | Project reference |
+| Credential resolver | `OpenClaw.Connection` | Direct use |
+| Node connector | `OpenClaw.Connection` | Direct use |
+| Setup code decoder | `OpenClaw.Connection` | Direct use |
+| Bounded WSL drain logic | Reimplemented cleanly | 5s timeout pattern |
+
+---
+
+## Future Work
+
+| Item | Status | Notes |
+|------|--------|-------|
+| Interactive gateway wizard in UI | Not started | RPC wizard protocol exists; needs dynamic page renderer |
+| Resume from journal (`--resume`) | Designed, not implemented | Journal records state; pipeline can skip completed steps |
+| Retry button in Progress UI | Not started | Pipeline supports retry; UI needs "Retry" affordance |
+| Tray integration (invoke engine from tray) | Not started | Engine is standalone exe; tray could spawn it |
+| Replace `LocalGatewaySetup.cs` | Out of scope | Requires feature-flag switchover in tray |
+
+---
+
+## Design Decisions
+
+| # | Decision | Choice | Rationale |
+|---|----------|--------|-----------|
+| 1 | Config format | JSON | No extra dependency; commented JSON for readability |
+| 2 | Config source | Bundled default config plus overrides | Provides secure defaults while preserving explicit environment-specific overrides |
+| 3 | Log viewer | Real-time streaming in Progress page | Essential for debugging; makes iteration fast |
+| 4 | Rollback scope | UI default on; headless/config opt-in or explicit opt-out | End-user setup should clean partial installs; debugging can preserve artifacts |
+| 5 | UI framework | Direct code-behind, no MVVM | Minimal code; setup UI is write-once, low-churn |
+| 6 | Two projects | Engine (console) + UI (WinUI) | Engine testable/automatable independently |
+| 7 | Step parallelism | Sequential only | Simplicity; steps have ordering dependencies |
+| 8 | Gateway bind | Loopback by default, LAN explicit opt-in | Secure default; LAN mode must be deliberate |
diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md
index d2f254aaf..747128ee1 100644
--- a/docs/gateway-node-integration.md
+++ b/docs/gateway-node-integration.md
@@ -187,9 +187,10 @@ defaults as a stricter, canonical-platform path:
4. If metadata is missing or noncanonical, fall back to `"unknown"` and a
conservative allowlist.
-Our node should therefore send canonical Windows metadata instead of relying on
-gateway-wide `gateway.nodes.allowCommands` for the normal first-party Windows
-companion flow.
+Our node should therefore send canonical Windows metadata. SetupEngine also
+writes `gateway.nodes.allowCommands` from its enabled capability configuration
+for local WSL gateway installs so the first-party Windows companion flow has an
+explicit gateway policy matching the node's advertised commands.
---
diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx
index 2be592b93..d73a4e31b 100644
--- a/openclaw-windows-node.slnx
+++ b/openclaw-windows-node.slnx
@@ -3,8 +3,6 @@
-
-
@@ -22,7 +20,12 @@
-
+
+
+
+
+
+
@@ -42,7 +45,6 @@
-
diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs
index 55fa260ef..f06b537e3 100644
--- a/src/OpenClaw.Connection/GatewayConnectionManager.cs
+++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs
@@ -239,6 +239,11 @@ private async Task ConnectCoreAsync(string? gatewayId = null)
_gatewayNeedsV2Signature = true;
};
+ // Local gateways only support v2 signatures — skip the v3 attempt entirely
+ // to avoid a spurious "metadata-upgrade" re-pairing triggered by the v3→v2 fallback.
+ if (record.IsLocal)
+ _gatewayNeedsV2Signature = true;
+
// If we already know this gateway needs v2, tell the client upfront
if (_gatewayNeedsV2Signature)
lifecycle.DataClient.UseV2Signature = true;
diff --git a/src/OpenClaw.Connection/GatewayRecord.cs b/src/OpenClaw.Connection/GatewayRecord.cs
index d4df48fca..025bc22f6 100644
--- a/src/OpenClaw.Connection/GatewayRecord.cs
+++ b/src/OpenClaw.Connection/GatewayRecord.cs
@@ -27,6 +27,9 @@ public sealed record GatewayRecord
/// True for gateways provisioned locally (localhost/WSL).
public bool IsLocal { get; init; }
+ /// WSL distro name for gateway records provisioned by SetupEngine.
+ public string? SetupManagedDistroName { get; init; }
+
/// Per-gateway SSH tunnel configuration. Null if no tunnel needed.
public SshTunnelConfig? SshTunnel { get; init; }
diff --git a/src/OpenClaw.SetupEngine.UI/App.xaml b/src/OpenClaw.SetupEngine.UI/App.xaml
new file mode 100644
index 000000000..1beb117c8
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/App.xaml.cs b/src/OpenClaw.SetupEngine.UI/App.xaml.cs
new file mode 100644
index 000000000..006c38135
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/App.xaml.cs
@@ -0,0 +1,19 @@
+using Microsoft.UI.Xaml;
+
+namespace OpenClaw.SetupEngine.UI;
+
+public partial class App : Application
+{
+ public static SetupWindow? MainWindow { get; private set; }
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ MainWindow = new SetupWindow();
+ MainWindow.BringToFrontForSetupLaunch();
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj
new file mode 100644
index 000000000..aaf86f8e7
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj
@@ -0,0 +1,51 @@
+
+
+
+ WinExe
+ net10.0-windows10.0.22621.0
+ true
+ enable
+ enable
+ OpenClaw.SetupEngine.UI
+ OpenClaw.SetupEngine.UI
+ x64;ARM64
+ win-x64;win-arm64
+ None
+ true
+ app.manifest
+ ..\OpenClaw.Tray.WinUI\Assets\openclaw.ico
+ en-US
+ $(DefineConstants);DISABLE_XAML_GENERATED_MAIN
+
+
+
+ win-x64
+
+
+ win-arm64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml
new file mode 100644
index 000000000..71e14ddd4
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs
new file mode 100644
index 000000000..b4362f80d
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml.cs
@@ -0,0 +1,116 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class CapabilitiesPage : Page
+{
+ private SetupConfig? _config;
+ private readonly Dictionary _toggles = new();
+
+ // (config property, display name, description, fluent icon glyph)
+ private static readonly (string Key, string Name, string Desc, string Glyph)[] Capabilities =
+ [
+ ("System", "System", "Shell commands, files, clipboard", "\uE756"),
+ ("Canvas", "Canvas", "Whiteboard and annotations", "\uE790"),
+ ("Screen", "Screen capture", "Screenshots and recording", "\uE7F4"),
+ ("Camera", "Camera", "Webcam photos and video", "\uE722"),
+ ("Location", "Location", "Share device location", "\uE81D"),
+ ("Browser", "Browser", "Web navigation and automation", "\uE774"),
+ ("Device", "Device", "Volume, brightness, system info", "\uE772"),
+ ("Tts", "Text-to-speech", "Speak text aloud", "\uE767"),
+ ("Stt", "Speech-to-text", "Transcribe spoken audio", "\uE720"),
+ ];
+
+ public CapabilitiesPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ _config = e.Parameter as SetupConfig ?? new SetupConfig();
+ BuildToggles();
+ }
+
+ private void BuildToggles()
+ {
+ var caps = _config!.Capabilities;
+ var totalRows = (Capabilities.Length + 1) / 2; // ceiling division for 2 columns
+
+ // Add row definitions
+ for (int i = 0; i < totalRows; i++)
+ CapGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ for (int i = 0; i < Capabilities.Length; i++)
+ {
+ var (key, name, desc, glyph) = Capabilities[i];
+ var prop = typeof(CapabilitiesConfig).GetProperty(key);
+ var isEnabled = (bool)(prop?.GetValue(caps) ?? true);
+
+ var toggle = new ToggleSwitch
+ {
+ IsOn = isEnabled,
+ OnContent = "",
+ OffContent = "",
+ MinWidth = 0,
+ };
+ _toggles[key] = toggle;
+
+ // Card-like item: icon + text + toggle
+ var item = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition { Width = GridLength.Auto },
+ new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) },
+ new ColumnDefinition { Width = GridLength.Auto },
+ },
+ Padding = new Thickness(10, 12, 6, 12),
+ };
+
+ var icon = new TextBlock
+ {
+ Text = glyph,
+ FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Segoe Fluent Icons"),
+ FontSize = 20,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 12, 0),
+ Opacity = 0.85,
+ };
+
+ var textStack = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center };
+ textStack.Children.Add(new TextBlock { Text = name, FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
+ textStack.Children.Add(new TextBlock { Text = desc, FontSize = 11, Opacity = 0.55 });
+
+ Grid.SetColumn(icon, 0);
+ Grid.SetColumn(textStack, 1);
+ Grid.SetColumn(toggle, 2);
+ item.Children.Add(icon);
+ item.Children.Add(textStack);
+ item.Children.Add(toggle);
+
+ int row = i / 2;
+ int col = i % 2;
+ Grid.SetRow(item, row);
+ Grid.SetColumn(item, col);
+ CapGrid.Children.Add(item);
+ }
+ }
+
+ private void Continue_Click(object sender, RoutedEventArgs e)
+ {
+ var caps = _config!.Capabilities;
+ foreach (var (key, _, _, _) in Capabilities)
+ {
+ if (_toggles.TryGetValue(key, out var toggle))
+ {
+ var prop = typeof(CapabilitiesConfig).GetProperty(key);
+ prop?.SetValue(caps, toggle.IsOn);
+ }
+ }
+
+ App.MainWindow?.NavigateToProgress();
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml
new file mode 100644
index 000000000..270222628
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs
new file mode 100644
index 000000000..83329a268
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml.cs
@@ -0,0 +1,119 @@
+using System.Diagnostics;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.UI;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class CompletePage : Page
+{
+ private string? _logPath;
+
+ public CompletePage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ if (e.Parameter is CompletePageArgs args)
+ {
+ _logPath = args.LogPath;
+
+ if (args.Success)
+ {
+ SuccessIcon.Visibility = Visibility.Visible;
+ FailureIcon.Visibility = Visibility.Collapsed;
+ TitleText.Text = "All set!";
+ SubtitleText.Text = "OpenClaw is ready to go";
+ ErrorCard.Visibility = Visibility.Collapsed;
+ }
+ else
+ {
+ SuccessIcon.Visibility = Visibility.Collapsed;
+ FailureIcon.Visibility = Visibility.Visible;
+ TitleText.Text = "Setup failed";
+ SubtitleText.Text = args.ErrorMessage ?? "An error occurred during setup";
+ NodeModeBanner.Visibility = Visibility.Collapsed;
+ StartupRow.Visibility = Visibility.Collapsed;
+ LaunchButton.Content = "Close";
+
+ // Show error card with details and log link
+ ErrorCard.Visibility = Visibility.Visible;
+ ErrorText.Text = args.ErrorMessage ?? "Unknown error";
+ if (args.LogPath != null)
+ ViewLogLink.Content = $"View full log → {Path.GetFileName(args.LogPath)}";
+ else
+ ViewLogLink.Visibility = Visibility.Collapsed;
+ }
+ }
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ // Style the Node Mode banner with amber/brown background
+ var isDark = ActualTheme == ElementTheme.Dark;
+ NodeModeBanner.Background = new SolidColorBrush(isDark
+ ? Color.FromArgb(255, 0x4A, 0x3D, 0x10) // dark amber
+ : Color.FromArgb(255, 0xF5, 0xE6, 0xB8)); // light amber
+
+ // Default startup toggle to off (user can enable)
+ StartupToggle.IsOn = false;
+ }
+
+ private void LaunchButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Register startup if toggled on
+ if (StartupToggle.Visibility == Visibility.Visible && StartupToggle.IsOn)
+ RegisterStartup();
+
+ // Launch tray on success, just close on failure
+ if (LaunchButton.Content?.ToString() != "Close")
+ LaunchTray();
+ App.MainWindow?.Close();
+ }
+
+ private void ViewLog_Click(object sender, RoutedEventArgs e)
+ {
+ if (_logPath != null && File.Exists(_logPath))
+ Process.Start(new ProcessStartInfo(_logPath) { UseShellExecute = true });
+ }
+
+ private static void LaunchTray()
+ {
+ // Kill any existing tray instances so fresh one picks up new gateway
+ foreach (var proc in Process.GetProcessesByName("OpenClaw.Tray.WinUI"))
+ {
+ try { proc.Kill(); } catch { }
+ }
+
+ // Brief pause for process cleanup
+ Thread.Sleep(1000);
+
+ // Launch via protocol deep link — opens tray and navigates to chat
+ Process.Start(new ProcessStartInfo("openclaw://chat") { UseShellExecute = true });
+ }
+
+ private static void RegisterStartup()
+ {
+ try
+ {
+ // Find tray exe path for startup registration
+ var candidates = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "..", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.exe"),
+ Path.Combine(AppContext.BaseDirectory, "OpenClaw.Tray.WinUI.exe"),
+ };
+ var trayPath = candidates.FirstOrDefault(File.Exists);
+ if (trayPath == null) return;
+
+ using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", writable: true);
+ key?.SetValue("OpenClawTray", $"\"{Path.GetFullPath(trayPath)}\"");
+ }
+ catch { /* best effort */ }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml
new file mode 100644
index 000000000..02cc125d9
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs
new file mode 100644
index 000000000..28f98978f
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs
@@ -0,0 +1,227 @@
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Microsoft.Win32;
+using Windows.Devices.Enumeration;
+using Windows.Graphics.Capture;
+using Windows.UI;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class PermissionsPage : Page
+{
+ private SetupConfig? _config;
+
+ private record PermDef(string Name, string Glyph, string SettingsUri, Func> Check);
+
+ private static readonly PermDef[] Permissions =
+ [
+ new("Notifications", "\uEA8F", "ms-settings:notifications", CheckNotificationsAsync),
+ new("Camera", "\uE722", "ms-settings:privacy-webcam", CheckCameraAsync),
+ new("Microphone", "\uE720", "ms-settings:privacy-microphone", CheckMicrophoneAsync),
+ new("Location (optional)", "\uE81D", "ms-settings:privacy-location", CheckLocationAsync),
+ new("Screen Capture", "\uE7F4", "", CheckScreenCaptureAsync),
+ ];
+
+ public PermissionsPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ _config = e.Parameter as SetupConfig ?? new SetupConfig();
+ _ = RefreshPermissions();
+ }
+
+ private async Task RefreshPermissions()
+ {
+ PermRows.Children.Clear();
+ var isDark = ActualTheme == ElementTheme.Dark;
+ var cardBg = new SolidColorBrush(isDark
+ ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C)
+ : Color.FromArgb(255, 0xF5, 0xF5, 0xF5));
+
+ foreach (var perm in Permissions)
+ {
+ var (status, granted) = await perm.Check();
+ PermRows.Children.Add(BuildRow(perm, status, granted, cardBg, isDark));
+ }
+ }
+
+ private static FrameworkElement BuildRow(PermDef perm, string status, bool granted, Brush cardBg, bool isDark)
+ {
+ var statusColor = granted
+ ? Color.FromArgb(255, 0x2B, 0xC3, 0x6F) // green
+ : Color.FromArgb(255, 0xF4, 0xA6, 0xB0); // pink
+
+ // Icon badge
+ var iconBadge = new Border
+ {
+ Width = 40, Height = 40,
+ CornerRadius = new CornerRadius(20),
+ Background = isDark
+ ? new SolidColorBrush(Microsoft.UI.Colors.Transparent)
+ : new SolidColorBrush(Color.FromArgb(255, 0x33, 0x33, 0x33)),
+ Child = new TextBlock
+ {
+ Text = perm.Glyph,
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 20,
+ Foreground = new SolidColorBrush(isDark ? Microsoft.UI.Colors.White : Microsoft.UI.Colors.White),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ },
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 16, 0),
+ };
+
+ // Title + status
+ var textStack = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center };
+ textStack.Children.Add(new TextBlock { Text = perm.Name, FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
+ textStack.Children.Add(new TextBlock { Text = status, FontSize = 13, Foreground = new SolidColorBrush(statusColor) });
+
+ // Open Settings button (only if URI exists)
+ FrameworkElement actionCol;
+ if (!string.IsNullOrEmpty(perm.SettingsUri))
+ {
+ var btn = new Button
+ {
+ Padding = new Thickness(8, 6, 8, 6),
+ Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent),
+ BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Transparent),
+ };
+ var btnContent = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+ btnContent.Children.Add(new TextBlock
+ {
+ Text = "\uE8A7", FontFamily = new FontFamily("Segoe Fluent Icons"),
+ FontSize = 14, VerticalAlignment = VerticalAlignment.Center
+ });
+ btnContent.Children.Add(new TextBlock { Text = "Open Settings", FontSize = 13, VerticalAlignment = VerticalAlignment.Center });
+ btn.Content = btnContent;
+ var uri = perm.SettingsUri;
+ btn.Click += async (_, _) =>
+ {
+ try { await Windows.System.Launcher.LaunchUriAsync(new Uri(uri)); }
+ catch { /* best effort */ }
+ };
+ actionCol = btn;
+ }
+ else
+ {
+ actionCol = new Border { Width = 1 };
+ }
+
+ var grid = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition { Width = GridLength.Auto },
+ new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) },
+ new ColumnDefinition { Width = GridLength.Auto },
+ },
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ Grid.SetColumn(iconBadge, 0);
+ Grid.SetColumn(textStack, 1);
+ Grid.SetColumn(actionCol, 2);
+ grid.Children.Add(iconBadge);
+ grid.Children.Add(textStack);
+ grid.Children.Add(actionCol);
+
+ return new Border
+ {
+ Child = grid,
+ Background = cardBg,
+ CornerRadius = new CornerRadius(8),
+ Padding = new Thickness(20, 18, 20, 18),
+ };
+ }
+
+ private void Refresh_Click(object sender, RoutedEventArgs e) => _ = RefreshPermissions();
+
+ private void BackToWizard_Click(object sender, RoutedEventArgs e)
+ => App.MainWindow?.NavigateToWizard();
+
+ private void Next_Click(object sender, RoutedEventArgs e)
+ => App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, null);
+
+ // ── Permission checks (passive, no OS consent dialogs) ──
+
+ private static Task<(string, bool)> CheckNotificationsAsync()
+ {
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(
+ @"Software\Microsoft\Windows\CurrentVersion\PushNotifications");
+ if (key?.GetValue("ToastEnabled") is int val && val == 0)
+ return Task.FromResult(("Disabled", false));
+ return Task.FromResult(("Enabled", true));
+ }
+ catch
+ {
+ return Task.FromResult(("Unable to check", false));
+ }
+ }
+
+ private static async Task<(string, bool)> CheckCameraAsync()
+ {
+ try
+ {
+ var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
+ if (devices.Count == 0) return ("No camera detected", false);
+ var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture);
+ return access.CurrentStatus == DeviceAccessStatus.Allowed
+ ? ("Allowed", true)
+ : ("Denied — open Settings to allow", false);
+ }
+ catch { return ("Unable to check", false); }
+ }
+
+ private static async Task<(string, bool)> CheckMicrophoneAsync()
+ {
+ try
+ {
+ var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture);
+ if (devices.Count == 0) return ("No microphone detected", false);
+ var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture);
+ return access.CurrentStatus == DeviceAccessStatus.Allowed
+ ? ("Allowed", true)
+ : ("Denied — open Settings to allow", false);
+ }
+ catch { return ("Unable to check", false); }
+ }
+
+ private static Task<(string, bool)> CheckLocationAsync()
+ {
+ try
+ {
+ using var sysKey = Registry.LocalMachine.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location");
+ if (sysKey?.GetValue("Value") is string sv && sv.Equals("Deny", StringComparison.OrdinalIgnoreCase))
+ return Task.FromResult(("Location services disabled", false));
+
+ using var userKey = Registry.CurrentUser.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location");
+ var uv = userKey?.GetValue("Value") as string;
+ if (uv != null && uv.Equals("Deny", StringComparison.OrdinalIgnoreCase))
+ return Task.FromResult(("Disabled for this user", false));
+
+ return Task.FromResult(("Location services enabled", true));
+ }
+ catch { return Task.FromResult(("Unable to check", false)); }
+ }
+
+ private static Task<(string, bool)> CheckScreenCaptureAsync()
+ {
+ try
+ {
+ return Task.FromResult(GraphicsCaptureSession.IsSupported()
+ ? ("Available — uses picker per capture", true)
+ : ("Not supported on this device", false));
+ }
+ catch { return Task.FromResult(("Unable to check", false)); }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml
new file mode 100644
index 000000000..7bd78f52f
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs
new file mode 100644
index 000000000..d49eb0294
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml.cs
@@ -0,0 +1,360 @@
+using System.Diagnostics;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using Windows.UI;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class ProgressPage : Page
+{
+ private SetupConfig? _config;
+ private SetupPipeline? _pipeline;
+ private SetupLogger? _logger;
+ private CancellationTokenSource? _runCts;
+ private readonly Dictionary _rows = new();
+ private bool _logExpanded;
+ private int _logLineCount;
+ private bool _pipelineFinished;
+ private const int MaxLogLines = 200;
+
+ // Map pipeline step IDs to display groups (N:1)
+ private static readonly (string GroupId, string DisplayName, string[] StepIds)[] StepGroups =
+ [
+ ("cleanup", "Removing existing gateway", ["cleanup-distro", "cleanup-gateway"]),
+ ("preflight", "Check system", ["preflight-os", "preflight-wsl", "preflight-port"]),
+ ("wsl-create", "Installing Ubuntu", ["wsl-create"]),
+ ("wsl-configure", "Configuring instance", ["wsl-configure", "validate-wsl-lockdown"]),
+ ("install-cli", "Installing OpenClaw", ["install-cli"]),
+ ("configure", "Preparing gateway", ["configure-gateway", "install-service"]),
+ ("start", "Starting gateway", ["start-gateway", "mint-token"]),
+ ("pairing", "Pairing device", ["pair-operator", "pair-node", "verify-e2e"]),
+ ("finish", "Finishing setup", ["run-wizard", "start-keepalive"]),
+ ];
+
+ public ProgressPage()
+ {
+ InitializeComponent();
+ Unloaded += (_, _) => CancelPipeline();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ _config = e.Parameter as SetupConfig ?? new SetupConfig();
+ SubtitleText.Text = $"Creating {_config.DistroName} WSL instance";
+
+ BuildStepRows();
+ StartPipeline();
+ }
+
+ private void BuildStepRows()
+ {
+ foreach (var (groupId, displayName, _) in StepGroups)
+ {
+ var row = new StepRow(displayName);
+ _rows[groupId] = row;
+ StepsPanel.Children.Add(row.Element);
+ }
+ }
+
+ private async void StartPipeline()
+ {
+ var config = _config!;
+ if (_runCts != null)
+ return;
+
+ config.LogPath ??= Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenClawTray", "Logs", "Setup", $"setup-engine-{DateTime.UtcNow:yyyyMMdd-HHmmss}.jsonl");
+
+ var sw = Stopwatch.StartNew();
+ using var cts = new CancellationTokenSource();
+ _runCts = cts;
+
+ try
+ {
+ _logger = new SetupLogger(config.LogPath,
+ Enum.TryParse(config.LogLevel, true, out var lvl) ? lvl : LogLevel.Trace);
+
+ _logger.LogEmitted += OnLogEmitted;
+
+ var journalPath = Path.ChangeExtension(config.LogPath, ".journal.jsonl");
+ using var journal = new TransactionJournal(journalPath);
+ var commands = new CommandRunner(_logger);
+ var ctx = new SetupContext(config, _logger, journal, commands, cts.Token);
+
+ var steps = BuildSteps(config);
+ _pipeline = new SetupPipeline(steps);
+ _pipeline.StepProgress += OnStepProgress;
+
+ var result = await Task.Run(() => _pipeline.RunAsync(ctx), cts.Token);
+ sw.Stop();
+ _pipelineFinished = true;
+
+ var success = result.Outcome == PipelineOutcome.Success;
+ if (success)
+ {
+ if (!config.SkipWizard)
+ {
+ if (_rows.TryGetValue("finish", out var finishRow))
+ finishRow.SetStatus(StepStatus.Running);
+ SubtitleText.Text = "Opening gateway setup...";
+ await Task.Delay(900);
+ finishRow?.SetStatus(StepStatus.Done);
+ App.MainWindow?.NavigateToWizard();
+ }
+ else if (config.SkipPermissions)
+ App.MainWindow?.NavigateToComplete(true, sw.Elapsed, config.LogPath);
+ else
+ App.MainWindow?.NavigateToPermissions();
+ }
+ else
+ {
+ var errorMsg = result.Outcome == PipelineOutcome.Cancelled
+ ? "Setup was cancelled."
+ : result.FailedStepId != null
+ ? $"Step '{result.FailedStepId}' failed: {result.Message}"
+ : result.Message;
+ App.MainWindow?.NavigateToComplete(false, sw.Elapsed, config.LogPath, errorMsg);
+ }
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ sw.Stop();
+ _pipelineFinished = true;
+ App.MainWindow?.NavigateToComplete(false, sw.Elapsed, config.LogPath, "Setup was cancelled.");
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ _pipelineFinished = true;
+ _logger?.Error($"Setup UI pipeline failed: {ex.Message}");
+ App.MainWindow?.NavigateToComplete(false, sw.Elapsed, config.LogPath, $"Setup crashed: {ex.Message}");
+ }
+ finally
+ {
+ if (_logger != null)
+ _logger.LogEmitted -= OnLogEmitted;
+ if (_pipeline != null)
+ _pipeline.StepProgress -= OnStepProgress;
+ _logger?.Dispose();
+ _logger = null;
+ _pipeline = null;
+ if (ReferenceEquals(_runCts, cts))
+ _runCts = null;
+ }
+ }
+
+ private void CancelPipeline()
+ {
+ if (!_pipelineFinished)
+ _runCts?.Cancel();
+ }
+
+ private void OnStepProgress(object? sender, StepProgressEvent e)
+ {
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ // Find which group this step belongs to
+ var groupIndex = Array.FindIndex(StepGroups, g => g.StepIds.Contains(e.StepId));
+ if (groupIndex < 0) return;
+
+ var group = StepGroups[groupIndex];
+ var row = _rows[group.GroupId];
+
+ if (e.Outcome == null)
+ {
+ // Step started — mark all previous groups as done if still running
+ for (int i = 0; i < groupIndex; i++)
+ {
+ var prevRow = _rows[StepGroups[i].GroupId];
+ if (prevRow.Status == StepStatus.Running)
+ prevRow.SetStatus(StepStatus.Done);
+ }
+
+ // Mark this group as running
+ if (row.Status != StepStatus.Done)
+ row.SetStatus(StepStatus.Running);
+ }
+ else if (e.Outcome == StepOutcome.Failed || e.Outcome == StepOutcome.FailedTerminal)
+ {
+ row.SetStatus(StepStatus.Failed);
+ }
+ else
+ {
+ // Step succeeded/skipped — track it
+ _completedSteps.Add(e.StepId);
+
+ // If all steps in this group are done, mark group done
+ if (group.StepIds.All(id => _completedSteps.Contains(id)))
+ row.SetStatus(StepStatus.Done);
+ }
+ });
+ }
+
+ private readonly HashSet _completedSteps = new();
+
+ private void OnLogEmitted(object? sender, LogEntry entry)
+ {
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ var line = $"[{entry.Timestamp:HH:mm:ss}] [{entry.Level}] {entry.Message}\n";
+ _logLineCount++;
+ if (_logLineCount > MaxLogLines)
+ {
+ // Trim old lines (simple: just keep appending; reset periodically)
+ if (_logLineCount % MaxLogLines == 0)
+ LogText.Text = line;
+ else
+ LogText.Text += line;
+ }
+ else
+ {
+ LogText.Text += line;
+ }
+
+ // Auto-scroll
+ LogScroller.ChangeView(null, LogScroller.ScrollableHeight, null);
+ });
+ }
+
+ private void LogToggle_Click(object sender, RoutedEventArgs e)
+ {
+ _logExpanded = !_logExpanded;
+ LogPanel.Visibility = _logExpanded ? Visibility.Visible : Visibility.Collapsed;
+ OpenLogButton.Visibility = _logExpanded ? Visibility.Visible : Visibility.Collapsed;
+ LogToggleButton.Content = _logExpanded ? "Hide logs ▼" : "Show logs ▲";
+
+ var isDark = ActualTheme == ElementTheme.Dark;
+ LogPanel.Background = new SolidColorBrush(isDark
+ ? Color.FromArgb(255, 0x1A, 0x1A, 0x1A)
+ : Color.FromArgb(255, 0xF8, 0xF8, 0xF8));
+ }
+
+ private void OpenLog_Click(object sender, RoutedEventArgs e)
+ {
+ var logPath = _config?.LogPath;
+ if (!string.IsNullOrWhiteSpace(logPath) && File.Exists(logPath))
+ {
+ Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true });
+ }
+ }
+
+ private static List BuildSteps(SetupConfig config)
+ => SetupStepFactory.BuildDefaultSteps()
+ .Where(step => step is not RunGatewayWizardStep)
+ .ToList();
+}
+
+// ─── Step Row UI Element ───
+
+internal enum StepStatus { Idle, Running, Done, Failed }
+
+internal sealed class StepRow
+{
+ public FrameworkElement Element { get; }
+ public StepStatus Status { get; private set; }
+
+ private readonly TextBlock _label;
+ private readonly ProgressRing _spinner;
+ private readonly Border _idleBadge;
+ private readonly Border _checkBadge;
+ private readonly Border _errorBadge;
+
+ public StepRow(string displayName)
+ {
+ _label = new TextBlock
+ {
+ Text = displayName,
+ FontSize = 16,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+
+ _spinner = new ProgressRing
+ {
+ Width = 28, Height = 28,
+ MinWidth = 28, MinHeight = 28,
+ IsActive = false,
+ Visibility = Visibility.Collapsed,
+ };
+
+ _idleBadge = CreateEmptyBadge();
+
+ _checkBadge = CreateIconBadge("\uE73E", Color.FromArgb(255, 0x2B, 0xC3, 0x6F), Color.FromArgb(255, 255, 255, 255));
+ _checkBadge.Visibility = Visibility.Collapsed;
+
+ _errorBadge = CreateIconBadge("\uE711", Color.FromArgb(255, 0xE8, 0x11, 0x23), Color.FromArgb(255, 255, 255, 255));
+ _errorBadge.Visibility = Visibility.Collapsed;
+
+ var badgeContainer = new Grid
+ {
+ Width = 32,
+ Height = 32,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ badgeContainer.Children.Add(_idleBadge);
+ badgeContainer.Children.Add(_spinner);
+ badgeContainer.Children.Add(_checkBadge);
+ badgeContainer.Children.Add(_errorBadge);
+
+ var grid = new Grid
+ {
+ ColumnDefinitions = { new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, new ColumnDefinition { Width = GridLength.Auto } },
+ };
+ Grid.SetColumn(_label, 0);
+ Grid.SetColumn(badgeContainer, 1);
+ grid.Children.Add(_label);
+ grid.Children.Add(badgeContainer);
+
+ Element = grid;
+ }
+
+ public void SetStatus(StepStatus status)
+ {
+ Status = status;
+ _spinner.IsActive = status == StepStatus.Running;
+ _spinner.Visibility = status == StepStatus.Running ? Visibility.Visible : Visibility.Collapsed;
+ _idleBadge.Visibility = status == StepStatus.Idle ? Visibility.Visible : Visibility.Collapsed;
+ _checkBadge.Visibility = status == StepStatus.Done ? Visibility.Visible : Visibility.Collapsed;
+ _errorBadge.Visibility = status == StepStatus.Failed ? Visibility.Visible : Visibility.Collapsed;
+ _label.Opacity = status == StepStatus.Idle ? 0.72 : 1.0;
+ _label.FontWeight = status == StepStatus.Running
+ ? Microsoft.UI.Text.FontWeights.SemiBold
+ : Microsoft.UI.Text.FontWeights.Normal;
+ }
+
+ private static Border CreateEmptyBadge()
+ {
+ return new Border
+ {
+ Width = 22,
+ Height = 22,
+ CornerRadius = new CornerRadius(11),
+ BorderThickness = new Thickness(1),
+ BorderBrush = new SolidColorBrush(Color.FromArgb(80, 255, 255, 255)),
+ };
+ }
+
+ private static Border CreateIconBadge(string glyph, Color background, Color foreground)
+ {
+ return new Border
+ {
+ Width = 22,
+ Height = 22,
+ CornerRadius = new CornerRadius(11),
+ Background = new SolidColorBrush(background),
+ Child = new FontIcon
+ {
+ Glyph = glyph,
+ FontSize = 12,
+ FontFamily = new FontFamily("Segoe Fluent Icons"),
+ Foreground = new SolidColorBrush(foreground),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ }
+ };
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml
new file mode 100644
index 000000000..105b32dae
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs
new file mode 100644
index 000000000..3fbb3eea4
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs
@@ -0,0 +1,109 @@
+using Microsoft.UI.Composition;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Hosting;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using OpenClaw.SetupEngine;
+using System.Diagnostics;
+using System.Numerics;
+using Windows.UI;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class WelcomePage : Page
+{
+ private SetupConfig? _config;
+
+ public WelcomePage()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ _config = e.Parameter as SetupConfig ?? new SetupConfig();
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ var isDark = ActualTheme == ElementTheme.Dark;
+ InfoCard.Background = new SolidColorBrush(isDark
+ ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C)
+ : Color.FromArgb(255, 0xF0, 0xF0, 0xF0));
+
+ InfoText.Text = "This local setup installs a small WSL Linux instance dedicated to OpenClaw. "
+ + "If you'd rather connect to an existing or remote gateway, choose Advanced setup.";
+
+ StartLobsterBreatheAnimation();
+ }
+
+ private void StartLobsterBreatheAnimation()
+ {
+ var visual = ElementCompositionPreview.GetElementVisual(LobsterHero);
+ var compositor = visual.Compositor;
+ var centerX = LobsterHero.ActualWidth > 0 ? LobsterHero.ActualWidth / 2 : LobsterHero.Width / 2;
+ var centerY = LobsterHero.ActualHeight > 0 ? LobsterHero.ActualHeight / 2 : LobsterHero.Height / 2;
+ visual.CenterPoint = new Vector3((float)centerX, (float)centerY, 0f);
+
+ var pulse = compositor.CreateVector3KeyFrameAnimation();
+ pulse.InsertKeyFrame(0f, new Vector3(1f, 1f, 1f));
+ pulse.InsertKeyFrame(0.5f, new Vector3(1.025f, 1.025f, 1f));
+ pulse.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f));
+ pulse.Duration = TimeSpan.FromMilliseconds(4200);
+ pulse.IterationBehavior = AnimationIterationBehavior.Forever;
+
+ visual.StartAnimation("Scale", pulse);
+ }
+
+ private async void StartButton_Click(object sender, RoutedEventArgs e)
+ {
+ var dataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR")
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray");
+
+ var existing = ExistingConfigDetector.Detect(dataDir, _config!.DistroName);
+ var summary = ExistingConfigDetector.BuildReplacementSummary(existing);
+
+ var dialog = new ContentDialog
+ {
+ Title = existing.HasLocalGateway || existing.HasDistro
+ ? "Replace existing WSL gateway?"
+ : "Install a new WSL gateway?",
+ Content = summary,
+ PrimaryButtonText = "Continue",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ XamlRoot = XamlRoot,
+ };
+
+ var result = await dialog.ShowAsync();
+ if (result == ContentDialogResult.Primary)
+ App.MainWindow?.NavigateToCapabilities();
+ }
+
+ private void AdvancedSetup_Click(object sender, RoutedEventArgs e)
+ {
+ // Launch tray app navigated to connection settings
+ var candidates = new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray", "OpenClaw.Tray.WinUI.exe"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "OpenClawTray", "OpenClaw.Tray.WinUI.exe"),
+ Path.Combine(AppContext.BaseDirectory, "..", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.exe"),
+ Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "OpenClaw.Tray.WinUI", "bin", "x64", "Debug", "net10.0-windows10.0.22621.0", "win-x64", "OpenClaw.Tray.WinUI.exe"),
+ };
+
+ var trayPath = candidates.FirstOrDefault(File.Exists);
+ var args = "--page connection";
+
+ if (trayPath != null)
+ Process.Start(new ProcessStartInfo(trayPath, args) { UseShellExecute = true });
+ else
+ {
+ try { Process.Start(new ProcessStartInfo("OpenClaw.Tray.WinUI.exe", args) { UseShellExecute = true }); }
+ catch { /* best effort */ }
+ }
+
+ App.MainWindow?.Close();
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
new file mode 100644
index 000000000..27c36b58f
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
new file mode 100644
index 000000000..03b912961
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs
@@ -0,0 +1,653 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Navigation;
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+using Windows.ApplicationModel.DataTransfer;
+
+namespace OpenClaw.SetupEngine.UI.Pages;
+
+public sealed partial class WizardPage : Page
+{
+ private const int MaxWizardSteps = 50;
+ private const int MaxSameStepVisits = 3;
+ private SetupConfig? _config;
+ private OpenClawGatewayClient? _client;
+ private string _sessionId = "";
+ private string _stepId = "";
+ private string _stepType = "";
+ private bool _sensitive;
+ private bool _errorState;
+ private int _wizardStepCount;
+ private readonly Dictionary _stepVisits = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _options = [];
+
+ public WizardPage()
+ {
+ InitializeComponent();
+ TextInput.TextChanged += (_, _) => UpdateContinueState();
+ SecretInput.PasswordChanged += (_, _) => UpdateContinueState();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ _config = e.Parameter as SetupConfig ?? new SetupConfig();
+ _ = StartWizardAsync();
+ }
+
+ protected override void OnNavigatedFrom(NavigationEventArgs e)
+ {
+ _ = DisconnectAsync();
+ }
+
+ private async Task StartWizardAsync()
+ {
+ try
+ {
+ _errorState = false;
+ await DisconnectAsync();
+ _sessionId = "";
+ _wizardStepCount = 0;
+ _stepVisits.Clear();
+ SetBusy("Connecting to gateway...");
+ _client = await ConnectClientAsync();
+ SetBusy("Starting wizard...");
+ var payload = await _client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000);
+ await ApplyPayloadAsync(payload);
+ }
+ catch (Exception ex)
+ {
+ ShowError($"Gateway wizard failed: {ex.Message}");
+ }
+ }
+
+ private async Task ConnectClientAsync()
+ {
+ var config = _config!;
+ var dataDir = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR")
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray");
+ var registry = new GatewayRegistry(dataDir);
+ registry.Load();
+ var record = registry.GetActive() ?? throw new InvalidOperationException("No active gateway record found.");
+ var identityPath = registry.GetIdentityDirectory(record.Id);
+ var token = DeviceIdentity.TryReadStoredDeviceToken(identityPath)
+ ?? record.SharedGatewayToken
+ ?? record.BootstrapToken
+ ?? throw new InvalidOperationException("No gateway credential found.");
+
+ var client = new OpenClawGatewayClient(config.EffectiveGatewayUrl, token, logger: new UiGatewayLogger(), identityPath: identityPath)
+ {
+ UseV2Signature = true
+ };
+
+ var outcome = await WaitForConnectAsync(client, TimeSpan.FromSeconds(20));
+ if (!outcome)
+ {
+ client.Dispose();
+ throw new InvalidOperationException("Could not connect to the gateway.");
+ }
+
+ return client;
+ }
+
+ private static async Task WaitForConnectAsync(OpenClawGatewayClient client, TimeSpan timeout)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void OnStatusChanged(object? sender, ConnectionStatus status)
+ {
+ if (status == ConnectionStatus.Connected)
+ tcs.TrySetResult(true);
+ else if (status is ConnectionStatus.Error or ConnectionStatus.Disconnected)
+ tcs.TrySetResult(false);
+ }
+
+ client.StatusChanged += OnStatusChanged;
+ try
+ {
+ await client.ConnectAsync();
+ using var cts = new CancellationTokenSource(timeout);
+ await using var _ = cts.Token.Register(() => tcs.TrySetResult(false));
+ return await tcs.Task;
+ }
+ finally
+ {
+ client.StatusChanged -= OnStatusChanged;
+ }
+ }
+
+ private async Task ApplyPayloadAsync(JsonElement payload)
+ {
+ if (payload.TryGetProperty("sessionId", out var sid))
+ _sessionId = sid.GetString() ?? _sessionId;
+
+ if (payload.TryGetProperty("done", out var done) && done.ValueKind == JsonValueKind.True)
+ {
+ var error = payload.TryGetProperty("error", out var err) ? err.ToString() : "";
+ if (!string.IsNullOrWhiteSpace(error) && !error.Contains("this.prompt is not a function", StringComparison.OrdinalIgnoreCase))
+ {
+ ShowError(error);
+ return;
+ }
+
+ await DisconnectAsync();
+ if (_config!.SkipPermissions)
+ App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath);
+ else
+ App.MainWindow?.NavigateToPermissions();
+ return;
+ }
+
+ if (!payload.TryGetProperty("step", out var step))
+ {
+ ShowError("Gateway wizard returned an invalid response.");
+ return;
+ }
+
+ _stepId = step.TryGetProperty("id", out var id) ? id.ToString() : "";
+ _stepType = step.TryGetProperty("type", out var type) ? type.ToString() : "note";
+ var stepIndex = payload.TryGetProperty("stepIndex", out var indexProperty) && indexProperty.TryGetInt32(out var index) ? index : 0;
+ _sensitive = step.TryGetProperty("sensitive", out var sensitive) && sensitive.ValueKind == JsonValueKind.True;
+ var title = step.TryGetProperty("title", out var titleProp) ? titleProp.ToString() : "";
+ var message = step.TryGetProperty("message", out var msgProp) ? msgProp.ToString() : "";
+ var initial = step.TryGetProperty("initialValue", out var initialProp) ? initialProp : default;
+
+ if (string.IsNullOrWhiteSpace(_stepId))
+ {
+ ShowError("Gateway wizard step is missing an id.");
+ return;
+ }
+
+ _wizardStepCount++;
+ if (_wizardStepCount > MaxWizardSteps)
+ {
+ ShowError($"Gateway wizard exceeded {MaxWizardSteps} steps.");
+ return;
+ }
+
+ var visitKey = $"{_stepId}:{stepIndex}";
+ _stepVisits.TryGetValue(visitKey, out var visits);
+ _stepVisits[visitKey] = visits + 1;
+ if (_stepVisits[visitKey] > MaxSameStepVisits)
+ {
+ ShowError($"Gateway wizard repeated step '{_stepId}' too many times.");
+ return;
+ }
+
+ ResetInputs();
+ TitleText.Text = string.IsNullOrWhiteSpace(title) ? DisplayTitleFor(_stepType) : title;
+ RenderMessage(message);
+ StepCard.MinHeight = _stepType == "note" && string.IsNullOrWhiteSpace(message) ? 140 : 260;
+ ErrorText.Visibility = Visibility.Collapsed;
+ BusyRing.Visibility = Visibility.Collapsed;
+ BusyRing.IsActive = false;
+ StatusText.Text = "Answer the gateway setup question";
+ PrimaryButton.IsEnabled = !WizardSelection.RequiresAnswer(_stepType);
+ SecondaryButton.IsEnabled = true;
+ PrimaryButton.Content = _stepType == "confirm" ? "Yes" : "Continue";
+ SecondaryButton.Content = "No";
+ SecondaryButton.Visibility = _stepType == "confirm" ? Visibility.Visible : Visibility.Collapsed;
+
+ if (!BuildOptions(step, initial))
+ return;
+
+ if (_stepType == "text")
+ {
+ if (_sensitive)
+ {
+ SecretInput.Visibility = Visibility.Visible;
+ SecretInput.Password = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : "";
+ }
+ else
+ {
+ TextInput.Visibility = Visibility.Visible;
+ TextInput.Text = initial.ValueKind == JsonValueKind.String ? initial.GetString() ?? "" : "";
+ }
+
+ UpdateContinueState();
+ }
+
+ if (_stepType == "note")
+ {
+ SecondaryButton.IsEnabled = false;
+ SecondaryButton.Visibility = Visibility.Collapsed;
+ }
+ }
+
+ private bool BuildOptions(JsonElement step, JsonElement initial)
+ {
+ if (_stepType is not ("select" or "multiselect"))
+ return true;
+
+ _options.Clear();
+ if (step.TryGetProperty("options", out var options) && options.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var option in options.EnumerateArray())
+ {
+ var value = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("value", out var valueProp)
+ ? valueProp.ToString()
+ : option.ToString();
+ var label = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("label", out var labelProp)
+ ? labelProp.ToString()
+ : value;
+ var hint = option.ValueKind == JsonValueKind.Object && option.TryGetProperty("hint", out var hintProp)
+ ? hintProp.ToString()
+ : "";
+ _options.Add(new(value, label, hint));
+ }
+ }
+
+ if (!WizardSelection.HasSelectableOptions(_stepType, _options.Select(o => o.Value).ToArray()))
+ {
+ ShowError("Gateway wizard returned a choice step without any selectable options.");
+ return false;
+ }
+
+ if (_stepType == "select")
+ {
+ SelectOptions.Visibility = Visibility.Visible;
+ foreach (var option in _options)
+ {
+ SelectOptions.Children.Add(new RadioButton
+ {
+ Content = BuildOptionContent(option),
+ Tag = option.Value,
+ GroupName = $"wizard-step-{_stepId}",
+ Padding = new Thickness(8, 6, 8, 6),
+ Margin = new Thickness(0, 0, 0, 2),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch
+ });
+ }
+
+ var initialValue = initial.ValueKind == JsonValueKind.String ? initial.GetString() : null;
+ var index = WizardSelection.SelectedIndex(initialValue, _options.Select(o => o.Value).ToArray());
+ if (index >= 0 && index < SelectOptions.Children.Count && SelectOptions.Children[index] is RadioButton radio)
+ radio.IsChecked = true;
+
+ foreach (var optionRadio in SelectOptions.Children.OfType())
+ optionRadio.Checked += (_, _) => UpdateContinueState();
+
+ UpdateContinueState();
+ }
+ else
+ {
+ MultiOptions.Visibility = Visibility.Visible;
+ var initialValues = initial.ValueKind == JsonValueKind.Array
+ ? initial.EnumerateArray().Select(v => v.ToString()).ToHashSet(StringComparer.Ordinal)
+ : [];
+ foreach (var option in _options)
+ {
+ var checkBox = new CheckBox
+ {
+ Content = BuildOptionContent(option),
+ Tag = option.Value,
+ IsChecked = initialValues.Contains(option.Value),
+ Padding = new Thickness(8, 6, 8, 6),
+ Margin = new Thickness(0, 0, 0, 2),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch
+ };
+ checkBox.Checked += (_, _) => UpdateContinueState();
+ checkBox.Unchecked += (_, _) => UpdateContinueState();
+ MultiOptions.Children.Add(checkBox);
+ }
+
+ UpdateContinueState();
+ }
+
+ return true;
+ }
+
+ private static FrameworkElement BuildOptionContent(WizardOption option)
+ {
+ var panel = new StackPanel
+ {
+ Spacing = 3,
+ Margin = new Thickness(2, 0, 0, 0),
+ HorizontalAlignment = HorizontalAlignment.Stretch
+ };
+
+ panel.Children.Add(new TextBlock
+ {
+ Text = option.Label,
+ FontSize = 14,
+ TextWrapping = TextWrapping.Wrap
+ });
+
+ if (!string.IsNullOrWhiteSpace(option.Hint))
+ {
+ panel.Children.Add(new TextBlock
+ {
+ Text = option.Hint,
+ FontSize = 12,
+ Foreground = ResourceBrush("TextFillColorSecondaryBrush"),
+ TextWrapping = TextWrapping.Wrap,
+ TextTrimming = TextTrimming.None
+ });
+ }
+
+ return panel;
+ }
+
+ private static Brush ResourceBrush(string key)
+ {
+ return Application.Current.Resources.TryGetValue(key, out var brush)
+ && brush is Brush typedBrush
+ ? typedBrush
+ : new SolidColorBrush(Microsoft.UI.Colors.Gray);
+ }
+
+ private async void Primary_Click(object sender, RoutedEventArgs e)
+ {
+ if (_errorState)
+ {
+ await StartWizardAsync();
+ return;
+ }
+
+ await SendCurrentAnswerAsync(skip: false);
+ }
+
+ private async void Secondary_Click(object sender, RoutedEventArgs e)
+ {
+ if (_errorState)
+ {
+ await SkipWizardAsync();
+ return;
+ }
+
+ await SendCurrentAnswerAsync(skip: true);
+ }
+
+ private async Task SendCurrentAnswerAsync(bool skip)
+ {
+ if (_client == null) return;
+
+ try
+ {
+ object? answerValue = null;
+ if (!skip && !TryBuildAnswerValue(out answerValue))
+ {
+ ErrorText.Text = _stepType == "multiselect"
+ ? "Choose at least one valid option."
+ : _stepType == "text"
+ ? "Enter a value to continue."
+ : "Choose a valid option.";
+ ErrorText.Visibility = Visibility.Visible;
+ UpdateContinueState();
+ return;
+ }
+
+ SetBusy(skip ? "Skipping..." : "Submitting...");
+ object parameters;
+ if (skip)
+ {
+ parameters = _stepType == "confirm"
+ ? new { sessionId = _sessionId, answer = new { stepId = _stepId, value = false } }
+ : new { sessionId = _sessionId };
+ }
+ else
+ {
+ parameters = new { sessionId = _sessionId, answer = new { stepId = _stepId, value = answerValue } };
+ }
+
+ var payload = await _client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutForCurrentStep());
+ await ApplyPayloadAsync(payload);
+ }
+ catch (Exception ex)
+ {
+ ShowError(ex.Message);
+ }
+ }
+
+ private bool TryBuildAnswerValue(out object value)
+ {
+ value = _stepType switch
+ {
+ "confirm" => true,
+ "select" => SelectOptions.Children.OfType()
+ .FirstOrDefault(r => r.IsChecked == true)
+ ?.Tag?.ToString() ?? "",
+ "multiselect" => MultiOptions.Children.OfType()
+ .Where(c => c.IsChecked == true)
+ .Select(c => c.Tag?.ToString() ?? "")
+ .Where(v => v.Length > 0)
+ .ToArray(),
+ "text" => _sensitive ? SecretInput.Password : TextInput.Text,
+ _ => "true"
+ };
+
+ if (!WizardSelection.RequiresAnswer(_stepType))
+ return true;
+
+ if (_stepType == "text")
+ return !WizardSelection.ShouldDisableContinue(_stepType, value?.ToString());
+
+ return !WizardSelection.ShouldDisableContinue(_stepType, GetSelectedOptionValues(), _options.Select(o => o.Value).ToArray());
+ }
+
+ private string[] GetSelectedOptionValues()
+ {
+ return _stepType switch
+ {
+ "select" => SelectOptions.Children.OfType()
+ .Where(r => r.IsChecked == true)
+ .Select(r => r.Tag?.ToString() ?? "")
+ .Where(v => v.Length > 0)
+ .ToArray(),
+ "multiselect" => MultiOptions.Children.OfType()
+ .Where(c => c.IsChecked == true)
+ .Select(c => c.Tag?.ToString() ?? "")
+ .Where(v => v.Length > 0)
+ .ToArray(),
+ _ => []
+ };
+ }
+
+ private void UpdateContinueState()
+ {
+ if (_errorState || !WizardSelection.RequiresAnswer(_stepType))
+ return;
+
+ PrimaryButton.IsEnabled = _stepType == "text"
+ ? !WizardSelection.ShouldDisableContinue(_stepType, _sensitive ? SecretInput.Password : TextInput.Text)
+ : !WizardSelection.ShouldDisableContinue(
+ _stepType,
+ GetSelectedOptionValues(),
+ _options.Select(o => o.Value).ToArray());
+
+ if (PrimaryButton.IsEnabled)
+ ErrorText.Visibility = Visibility.Collapsed;
+ }
+
+ private int TimeoutForCurrentStep()
+ {
+ var text = $"{TitleText.Text} {string.Join(' ', MessagePanel.Children.OfType().Select(t => t.Text))}";
+ return text.Contains("device", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("authorize", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("login", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("sign in", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("oauth", StringComparison.OrdinalIgnoreCase)
+ ? 300_000
+ : 30_000;
+ }
+
+ private void ResetInputs()
+ {
+ SelectOptions.Children.Clear();
+ SelectOptions.Visibility = Visibility.Collapsed;
+ MultiOptions.Children.Clear();
+ MultiOptions.Visibility = Visibility.Collapsed;
+ TextInput.Visibility = Visibility.Collapsed;
+ SecretInput.Visibility = Visibility.Collapsed;
+ MessagePanel.Children.Clear();
+ }
+
+ private void RenderMessage(string message)
+ {
+ MessagePanel.Children.Clear();
+ if (string.IsNullOrWhiteSpace(message))
+ return;
+
+ foreach (var line in message.Split('\n'))
+ {
+ var trimmed = line.TrimEnd('\r');
+ var codeMatch = Regex.Match(trimmed, @"^((?:Code|code|user_code|USER_CODE)\s*[:=]\s*)([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b");
+ if (codeMatch.Success)
+ {
+ MessagePanel.Children.Add(BuildCodeRow(codeMatch.Groups[1].Value, codeMatch.Groups[2].Value));
+ continue;
+ }
+
+ var urlMatch = Regex.Match(trimmed, @"https?://[^\s\)\""]+", RegexOptions.IgnoreCase);
+ if (urlMatch.Success && Uri.TryCreate(urlMatch.Value.TrimEnd('.', ','), UriKind.Absolute, out var uri))
+ {
+ MessagePanel.Children.Add(BuildLinkLine(trimmed, urlMatch.Value, uri));
+ continue;
+ }
+
+ MessagePanel.Children.Add(new TextBlock
+ {
+ Text = trimmed,
+ FontSize = 14,
+ Opacity = 0.82,
+ TextWrapping = TextWrapping.Wrap,
+ IsTextSelectionEnabled = true
+ });
+ }
+ }
+
+ private static FrameworkElement BuildLinkLine(string line, string urlText, Uri uri)
+ {
+ var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 };
+ var prefix = line[..line.IndexOf(urlText, StringComparison.Ordinal)];
+ if (!string.IsNullOrEmpty(prefix))
+ panel.Children.Add(new TextBlock { Text = prefix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center });
+
+ var button = new HyperlinkButton
+ {
+ Content = urlText,
+ NavigateUri = uri,
+ Padding = new Thickness(0),
+ FontSize = 14,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ panel.Children.Add(button);
+
+ var suffix = line[(line.IndexOf(urlText, StringComparison.Ordinal) + urlText.Length)..];
+ if (!string.IsNullOrEmpty(suffix))
+ panel.Children.Add(new TextBlock { Text = suffix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center });
+
+ return panel;
+ }
+
+ private static FrameworkElement BuildCodeRow(string prefix, string code)
+ {
+ var grid = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition { Width = GridLength.Auto },
+ new ColumnDefinition { Width = GridLength.Auto },
+ new ColumnDefinition { Width = GridLength.Auto },
+ },
+ ColumnSpacing = 10
+ };
+
+ var label = new TextBlock { Text = prefix, FontSize = 14, Opacity = 0.82, VerticalAlignment = VerticalAlignment.Center };
+ var codeText = new TextBlock
+ {
+ Text = code,
+ FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
+ FontSize = 18,
+ FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
+ IsTextSelectionEnabled = true,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var copy = new Button { Content = "Copy", Padding = new Thickness(8, 4, 8, 4) };
+ copy.Click += (_, _) =>
+ {
+ var package = new DataPackage();
+ package.SetText(code);
+ Clipboard.SetContent(package);
+ };
+
+ Grid.SetColumn(label, 0);
+ Grid.SetColumn(codeText, 1);
+ Grid.SetColumn(copy, 2);
+ grid.Children.Add(label);
+ grid.Children.Add(codeText);
+ grid.Children.Add(copy);
+ return grid;
+ }
+
+ private void SetBusy(string status)
+ {
+ StatusText.Text = status;
+ BusyRing.Visibility = Visibility.Visible;
+ BusyRing.IsActive = true;
+ PrimaryButton.IsEnabled = false;
+ SecondaryButton.IsEnabled = false;
+ }
+
+ private void ShowError(string message)
+ {
+ _errorState = true;
+ BusyRing.Visibility = Visibility.Collapsed;
+ BusyRing.IsActive = false;
+ StatusText.Text = "Wizard needs attention";
+ ErrorText.Text = message;
+ ErrorText.Visibility = Visibility.Visible;
+ PrimaryButton.Content = "Retry";
+ PrimaryButton.IsEnabled = true;
+ SecondaryButton.Content = "Skip wizard";
+ SecondaryButton.IsEnabled = true;
+ SecondaryButton.Visibility = Visibility.Visible;
+ }
+
+ private async Task SkipWizardAsync()
+ {
+ if (_client != null && !string.IsNullOrWhiteSpace(_sessionId))
+ {
+ try { await _client.SendWizardRequestAsync("wizard.cancel", new { sessionId = _sessionId }, timeoutMs: 10_000); }
+ catch { }
+ }
+
+ await DisconnectAsync();
+ if (_config!.SkipPermissions)
+ App.MainWindow?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath);
+ else
+ App.MainWindow?.NavigateToPermissions();
+ }
+
+ private async Task DisconnectAsync()
+ {
+ if (_client == null) return;
+ try { await _client.DisconnectAsync(); } catch { }
+ _client.Dispose();
+ _client = null;
+ }
+
+ private static string DisplayTitleFor(string stepType) => stepType switch
+ {
+ "confirm" => "Confirm",
+ "select" => "Choose an option",
+ "multiselect" => "Choose options",
+ "text" => "Enter value",
+ _ => "Setup"
+ };
+
+ private sealed record WizardOption(string Value, string Label, string Hint);
+
+ private sealed class UiGatewayLogger : IOpenClawLogger
+ {
+ public void Info(string message) { }
+ public void Debug(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message, Exception? ex = null) { }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/Program.cs b/src/OpenClaw.SetupEngine.UI/Program.cs
new file mode 100644
index 000000000..194936269
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/Program.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Threading;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+
+namespace OpenClaw.SetupEngine.UI;
+
+internal static class Program
+{
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ // Headless mode: bypass UI entirely, run pipeline directly
+ if (Array.Exists(args, a => a.Equals("--headless", StringComparison.OrdinalIgnoreCase)))
+ {
+ return OpenClaw.SetupEngine.Program.Main(args).GetAwaiter().GetResult();
+ }
+
+ WinRT.ComWrappersSupport.InitializeComWrappers();
+ Application.Start(p =>
+ {
+ var dispatcher = DispatcherQueue.GetForCurrentThread();
+ var context = new DispatcherQueueSynchronizationContext(dispatcher);
+ SynchronizationContext.SetSynchronizationContext(context);
+ new App();
+ });
+ return 0;
+ }
+}
diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml
new file mode 100644
index 000000000..866956cbd
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
new file mode 100644
index 000000000..8451f59e8
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs
@@ -0,0 +1,117 @@
+using Microsoft.UI;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Windowing;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using OpenClaw.SetupEngine.UI.Pages;
+using System.Runtime.InteropServices;
+
+namespace OpenClaw.SetupEngine.UI;
+
+public sealed partial class SetupWindow : Window
+{
+ private SetupConfig _config = null!;
+ private SetupRunLock? _setupLock;
+
+ [DllImport("user32.dll")]
+ private static extern uint GetDpiForWindow(IntPtr hwnd);
+
+ public SetupWindow()
+ {
+ InitializeComponent();
+
+ // Size window accounting for DPI
+ var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
+ var dpi = GetDpiForWindow(hwnd);
+ var scale = dpi / 96.0;
+ AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(720 * scale), (int)(820 * scale)));
+
+ // Extend into title bar for modern look
+ ExtendsContentIntoTitleBar = true;
+ SetTitleBar(TitleBarDrag);
+
+ // Mica backdrop
+ SystemBackdrop = new MicaBackdrop();
+
+ // Load config: explicit --config arg, or bundled default-config.json (required)
+ var args = Environment.GetCommandLineArgs();
+ var configPath = GetArg(args, "--config");
+ if (configPath == null)
+ {
+ var defaultPath = Path.Combine(AppContext.BaseDirectory, "default-config.json");
+ if (File.Exists(defaultPath))
+ configPath = defaultPath;
+ }
+
+ if (configPath == null || !File.Exists(configPath))
+ {
+ // Cannot run without config
+ Console.Error.WriteLine("ERROR: No config file found. Place default-config.json next to the exe or pass --config .");
+ Environment.Exit(1);
+ return;
+ }
+
+ _config = SetupConfig.LoadFromFile(configPath);
+ _config = SetupConfig.FromEnvironment(_config);
+ _config.ApplyUiDefaults(rollbackOnFailure: !HasFlag(args, "--no-rollback-on-failure"));
+
+ Closed += (_, _) =>
+ {
+ _setupLock?.Dispose();
+ _setupLock = null;
+ };
+
+ if (!SetupRunLock.TryAcquire(SetupContext.ResolveDataDir(), out _setupLock, out var lockMessage))
+ {
+ RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(false, TimeSpan.Zero, null, lockMessage ?? "Another setup run is active."));
+ return;
+ }
+
+ RootFrame.Navigate(typeof(WelcomePage), _config);
+ }
+
+ public void NavigateToCapabilities() => RootFrame.Navigate(typeof(CapabilitiesPage), _config);
+ public void NavigateToProgress() => RootFrame.Navigate(typeof(ProgressPage), _config);
+ public void NavigateToWizard() => RootFrame.Navigate(typeof(WizardPage), _config);
+ public void NavigateToPermissions() => RootFrame.Navigate(typeof(PermissionsPage), _config);
+ public void NavigateToComplete(bool success, TimeSpan elapsed, string? logPath, string? errorMessage = null)
+ => RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(success, elapsed, logPath, errorMessage));
+
+ public void BringToFrontForSetupLaunch()
+ {
+ Activate();
+
+ if (AppWindow.Presenter is not OverlappedPresenter presenter)
+ return;
+
+ if (presenter.State == OverlappedPresenterState.Minimized)
+ presenter.Restore();
+
+ var wasAlwaysOnTop = presenter.IsAlwaysOnTop;
+ presenter.IsAlwaysOnTop = true;
+ Activate();
+
+ var timer = DispatcherQueue.CreateTimer();
+ timer.Interval = TimeSpan.FromMilliseconds(750);
+ timer.Tick += (_, _) =>
+ {
+ timer.Stop();
+ if (!wasAlwaysOnTop && AppWindow.Presenter is OverlappedPresenter p)
+ p.IsAlwaysOnTop = false;
+ };
+ timer.Start();
+ }
+
+ private static string? GetArg(string[] args, string name)
+ {
+ for (int i = 0; i < args.Length - 1; i++)
+ if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
+ return args[i + 1];
+ return null;
+ }
+
+ private static bool HasFlag(string[] args, string name)
+ => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
+
+public sealed record CompletePageArgs(bool Success, TimeSpan Elapsed, string? LogPath, string? ErrorMessage = null);
diff --git a/src/OpenClaw.SetupEngine.UI/app.manifest b/src/OpenClaw.SetupEngine.UI/app.manifest
new file mode 100644
index 000000000..cf56a1a34
--- /dev/null
+++ b/src/OpenClaw.SetupEngine.UI/app.manifest
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
new file mode 100644
index 000000000..f8ce94a38
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/ApprovalRequestHelper.cs
@@ -0,0 +1,160 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace OpenClaw.SetupEngine;
+
+internal enum ApprovalRequestKind
+{
+ Device,
+ Node
+}
+
+internal static partial class ApprovalRequestHelper
+{
+ internal const string RequestIdEnvironmentVariable = "OPENCLAW_APPROVAL_REQUEST_ID";
+
+ internal static bool IsSafeRequestId(string? requestId)
+ => !string.IsNullOrWhiteSpace(requestId)
+ && SafeRequestIdPattern().IsMatch(requestId.Trim());
+
+ internal static string ApprovalCommand(ApprovalRequestKind kind)
+ => $"openclaw {Noun(kind)} approve \"${RequestIdEnvironmentVariable}\" --json";
+
+ internal static Dictionary AddRequestIdEnvironment(
+ IReadOnlyDictionary environment,
+ string requestId)
+ {
+ if (!IsSafeRequestId(requestId))
+ throw new ArgumentException("Unsafe approval request ID.", nameof(requestId));
+
+ var result = new Dictionary(environment)
+ {
+ [RequestIdEnvironmentVariable] = requestId.Trim()
+ };
+ return result;
+ }
+
+ internal static RequestIdParseResult TryReadSelectedRequestId(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return RequestIdParseResult.NotFound("Approval output was empty.");
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("selected", out var selected) ||
+ selected.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ return RequestIdParseResult.NotFound("Approval output did not include a selected request.");
+ }
+
+ return TryReadRequestId(selected);
+ }
+ catch (JsonException ex)
+ {
+ return RequestIdParseResult.NotFound($"Approval output was not valid JSON: {ex.Message}");
+ }
+ }
+
+ internal static RequestIdParseResult TryReadApprovedRequestId(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return RequestIdParseResult.NotFound("Approval output was empty.");
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ return TryReadRequestId(doc.RootElement);
+ }
+ catch (JsonException ex)
+ {
+ return RequestIdParseResult.NotFound($"Approval output was not valid JSON: {ex.Message}");
+ }
+ }
+
+ internal static RequestIdParseResult TryReadSinglePendingRequestId(string json)
+ {
+ var all = TryReadPendingRequestIds(json);
+ if (!all.Success)
+ return RequestIdParseResult.NotFound(all.Error ?? "Could not read pending approval requests.");
+
+ return all.RequestIds.Count switch
+ {
+ 0 => RequestIdParseResult.NotFound("No pending approval request was found."),
+ 1 => RequestIdParseResult.Found(all.RequestIds[0]),
+ _ => RequestIdParseResult.NotFound("Multiple pending approval requests were found; refusing to auto-approve an ambiguous request.")
+ };
+ }
+
+ internal static PendingRequestIdsParseResult TryReadPendingRequestIds(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return PendingRequestIdsParseResult.Fail("Pending approval output was empty.");
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (!doc.RootElement.TryGetProperty("pending", out var pending) ||
+ pending.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ return PendingRequestIdsParseResult.SuccessResult([]);
+ }
+
+ if (pending.ValueKind != JsonValueKind.Array)
+ return PendingRequestIdsParseResult.Fail("Pending approval output did not contain an array.");
+
+ var requestIds = new List();
+ foreach (var item in pending.EnumerateArray())
+ {
+ var parsed = TryReadRequestId(item);
+ if (!parsed.Success)
+ return PendingRequestIdsParseResult.Fail(parsed.Error ?? "Pending approval request did not include a safe request ID.");
+
+ requestIds.Add(parsed.RequestId!);
+ }
+
+ return PendingRequestIdsParseResult.SuccessResult(requestIds);
+ }
+ catch (JsonException ex)
+ {
+ return PendingRequestIdsParseResult.Fail($"Pending approval output was not valid JSON: {ex.Message}");
+ }
+ }
+
+ private static RequestIdParseResult TryReadRequestId(JsonElement element)
+ {
+ if (!element.TryGetProperty("requestId", out var requestIdElement) ||
+ requestIdElement.ValueKind != JsonValueKind.String)
+ {
+ return RequestIdParseResult.NotFound("Approval request did not include requestId.");
+ }
+
+ var requestId = requestIdElement.GetString()?.Trim();
+ return IsSafeRequestId(requestId)
+ ? RequestIdParseResult.Found(requestId!)
+ : RequestIdParseResult.NotFound("Approval request ID contained unsafe characters.");
+ }
+
+ private static string Noun(ApprovalRequestKind kind)
+ => kind switch
+ {
+ ApprovalRequestKind.Device => "devices",
+ ApprovalRequestKind.Node => "nodes",
+ _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
+ };
+
+ [GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", RegexOptions.Compiled)]
+ private static partial Regex SafeRequestIdPattern();
+}
+
+internal sealed record RequestIdParseResult(bool Success, string? RequestId, string? Error)
+{
+ public static RequestIdParseResult Found(string requestId) => new(true, requestId, null);
+ public static RequestIdParseResult NotFound(string error) => new(false, null, error);
+}
+
+internal sealed record PendingRequestIdsParseResult(bool Success, IReadOnlyList RequestIds, string? Error)
+{
+ public static PendingRequestIdsParseResult SuccessResult(IReadOnlyList requestIds) => new(true, requestIds, null);
+ public static PendingRequestIdsParseResult Fail(string error) => new(false, [], error);
+}
diff --git a/src/OpenClaw.SetupEngine/AssemblyInfo.cs b/src/OpenClaw.SetupEngine/AssemblyInfo.cs
new file mode 100644
index 000000000..12ce8d9ad
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("OpenClaw.SetupEngine.Tests")]
diff --git a/src/OpenClaw.SetupEngine/AtomicFile.cs b/src/OpenClaw.SetupEngine/AtomicFile.cs
new file mode 100644
index 000000000..169be185c
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/AtomicFile.cs
@@ -0,0 +1,58 @@
+namespace OpenClaw.SetupEngine;
+
+internal static class AtomicFile
+{
+ public static void WriteAllText(string path, string contents)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ var tempPath = Path.Combine(
+ directory ?? Directory.GetCurrentDirectory(),
+ $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ File.WriteAllText(tempPath, contents);
+ File.Move(tempPath, path, overwrite: true);
+ }
+ finally
+ {
+ TryDeleteTemp(tempPath);
+ }
+ }
+
+ public static async Task WriteAllTextAsync(string path, string contents, CancellationToken ct = default)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ var tempPath = Path.Combine(
+ directory ?? Directory.GetCurrentDirectory(),
+ $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
+
+ try
+ {
+ await File.WriteAllTextAsync(tempPath, contents, ct);
+ File.Move(tempPath, path, overwrite: true);
+ }
+ finally
+ {
+ TryDeleteTemp(tempPath);
+ }
+ }
+
+ private static void TryDeleteTemp(string tempPath)
+ {
+ try
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/CommandRunner.cs b/src/OpenClaw.SetupEngine/CommandRunner.cs
new file mode 100644
index 000000000..980b23402
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/CommandRunner.cs
@@ -0,0 +1,200 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace OpenClaw.SetupEngine;
+
+// ─── Command Runner ───
+
+public sealed record CommandResult(int ExitCode, string Stdout, string Stderr, TimeSpan Elapsed, bool TimedOut);
+
+public sealed class CommandRunner
+{
+ private readonly SetupLogger _logger;
+ private const int DrainTimeoutMs = 5000; // bounded drain for orphan WSL processes
+ private const int MaxCapturedStreamChars = 1_048_576;
+
+ public CommandRunner(SetupLogger logger) => _logger = logger;
+
+ ///
+ /// Run a process and capture output. Timeout kills the process.
+ ///
+ public async Task RunAsync(
+ string executable,
+ string[] arguments,
+ TimeSpan timeout,
+ IReadOnlyDictionary? environment = null,
+ string? workingDirectory = null,
+ string? stdinInput = null,
+ CancellationToken ct = default)
+ {
+ _logger.CommandStarted(executable, arguments, timeout);
+ var sw = Stopwatch.StartNew();
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = executable,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ RedirectStandardInput = stdinInput != null,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ WorkingDirectory = workingDirectory ?? ""
+ };
+
+ foreach (var arg in arguments)
+ psi.ArgumentList.Add(arg);
+
+ if (environment != null)
+ {
+ foreach (var (key, value) in environment)
+ psi.Environment[key] = value;
+ }
+
+ using var process = new Process { StartInfo = psi };
+ var stdout = new BoundedOutputBuffer(MaxCapturedStreamChars);
+ var stderr = new BoundedOutputBuffer(MaxCapturedStreamChars);
+ var timedOut = false;
+
+ process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
+ process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
+
+ try
+ {
+ process.Start();
+ }
+ catch (System.ComponentModel.Win32Exception ex)
+ {
+ sw.Stop();
+ var errorResult = new CommandResult(-1, "", $"Failed to start process '{executable}': {ex.Message}", sw.Elapsed, false);
+ _logger.CommandCompleted(executable, errorResult, sw.Elapsed);
+ return errorResult;
+ }
+
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ if (stdinInput != null)
+ {
+ await process.StandardInput.WriteAsync(stdinInput);
+ process.StandardInput.Close();
+ }
+
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeoutCts.CancelAfter(timeout);
+
+ try
+ {
+ await process.WaitForExitAsync(timeoutCts.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Timeout (not user cancellation)
+ timedOut = true;
+ TryKill(process);
+ }
+ catch (OperationCanceledException)
+ {
+ // User cancelled
+ TryKill(process);
+ throw;
+ }
+
+ // Flush async output handlers. WaitForExitAsync observes process exit, but the
+ // OutputDataReceived/ErrorDataReceived callbacks can still be draining.
+ if (!timedOut)
+ process.WaitForExit(DrainTimeoutMs);
+
+ sw.Stop();
+ var result = new CommandResult(
+ timedOut ? -1 : process.ExitCode,
+ stdout.ToString(),
+ stderr.ToString(),
+ sw.Elapsed,
+ timedOut);
+
+ _logger.CommandCompleted(executable, result, sw.Elapsed);
+ return result;
+ }
+
+ ///
+ /// Run a command inside a WSL distro.
+ ///
+ public Task RunInWslAsync(
+ string distroName,
+ string command,
+ TimeSpan timeout,
+ IReadOnlyDictionary? environment = null,
+ CancellationToken ct = default,
+ string? user = null)
+ {
+ // Strip Windows \r to avoid bash "$'\r': command not found" errors
+ command = command.Replace("\r", "");
+
+ // Build wsl.exe arguments: -d [-u ] --
+ var args = new List { "-d", distroName };
+ if (!string.IsNullOrWhiteSpace(user))
+ {
+ args.Add("-u");
+ args.Add(user);
+ }
+
+ args.AddRange(["--", "bash", "-c", command]);
+
+ // Pass WSL environment variables via WSLENV
+ Dictionary? env = null;
+ if (environment is { Count: > 0 })
+ {
+ env = new Dictionary(environment);
+ var wslEnvKeys = string.Join(":", environment.Keys);
+ env["WSLENV"] = env.TryGetValue("WSLENV", out var existing)
+ ? $"{existing}:{wslEnvKeys}"
+ : wslEnvKeys;
+ }
+
+ return RunAsync("wsl.exe", args.ToArray(), timeout, env, ct: ct);
+ }
+
+ private static void TryKill(Process process)
+ {
+ try { process.Kill(entireProcessTree: true); } catch { /* best effort */ }
+ }
+
+ private sealed class BoundedOutputBuffer(int maxChars)
+ {
+ private readonly StringBuilder _builder = new();
+ private readonly object _lock = new();
+ private int _droppedChars;
+
+ public void AppendLine(string line)
+ {
+ lock (_lock)
+ {
+ if (_builder.Length < maxChars)
+ {
+ var remaining = maxChars - _builder.Length;
+ if (line.Length + Environment.NewLine.Length <= remaining)
+ {
+ _builder.AppendLine(line);
+ return;
+ }
+
+ if (remaining > 0)
+ _builder.Append(line[..Math.Min(line.Length, remaining)]);
+ }
+
+ _droppedChars += line.Length + Environment.NewLine.Length;
+ }
+ }
+
+ public override string ToString()
+ {
+ lock (_lock)
+ {
+ if (_droppedChars == 0)
+ return _builder.ToString();
+
+ return _builder.ToString() + Environment.NewLine + $"... [truncated {_droppedChars} chars]";
+ }
+ }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs
new file mode 100644
index 000000000..9332beb15
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/ExistingConfigDetector.cs
@@ -0,0 +1,105 @@
+using OpenClaw.Connection;
+
+namespace OpenClaw.SetupEngine;
+
+///
+/// Detects existing local gateway configuration to show accurate replacement summaries.
+///
+public sealed class ExistingConfigDetector
+{
+ public sealed record ExistingConfig(
+ bool HasLocalGateway,
+ string? LocalGatewayId,
+ string? LocalGatewayUrl,
+ bool HasDistro,
+ string? DistroName,
+ bool HasIdentityFiles,
+ int PreservedGatewayCount,
+ IReadOnlyList PreservedGatewayNames);
+
+ ///
+ /// Detect existing local configuration by checking the gateway registry and WSL distros.
+ ///
+ public static ExistingConfig Detect(string dataDir, string targetDistroName)
+ {
+ var registry = new GatewayRegistry(dataDir);
+ registry.Load();
+ var all = registry.GetAll();
+
+ var localRecord = all.FirstOrDefault(r => r.IsLocal && r.SshTunnel == null);
+ var preserved = all.Where(r => !r.IsLocal || r.SshTunnel != null).ToList();
+
+ var hasDistro = false;
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "--list --quiet")
+ {
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+
+ using var proc = System.Diagnostics.Process.Start(psi);
+ if (proc != null)
+ {
+ var output = proc.StandardOutput.ReadToEnd();
+ proc.WaitForExit(5000);
+ var distros = output.Replace("\0", string.Empty)
+ .Replace("\uFEFF", string.Empty)
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .Select(d => d.Trim())
+ .Where(d => d.Length > 0);
+ hasDistro = distros.Any(d => d.Equals(targetDistroName, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ catch
+ {
+ // WSL not available.
+ }
+
+ var hasIdentity = false;
+ if (localRecord != null)
+ {
+ var identityDir = registry.GetIdentityDirectory(localRecord.Id);
+ hasIdentity = Directory.Exists(identityDir) && Directory.EnumerateFiles(identityDir).Any();
+ }
+
+ return new ExistingConfig(
+ HasLocalGateway: localRecord != null,
+ LocalGatewayId: localRecord?.Id,
+ LocalGatewayUrl: localRecord?.Url,
+ HasDistro: hasDistro,
+ DistroName: hasDistro ? targetDistroName : null,
+ HasIdentityFiles: hasIdentity,
+ PreservedGatewayCount: preserved.Count,
+ PreservedGatewayNames: preserved.Select(r => r.FriendlyName ?? r.Url).ToList());
+ }
+
+ ///
+ /// Build a human-readable summary of what will happen during setup.
+ ///
+ public static string BuildReplacementSummary(ExistingConfig config)
+ {
+ if (!config.HasLocalGateway && !config.HasDistro)
+ return "A new local WSL gateway will be created. No existing configuration will be affected.";
+
+ var lines = new List();
+
+ if (config.HasDistro)
+ lines.Add($"• WSL distro '{config.DistroName}' will be deleted and recreated");
+ if (config.HasLocalGateway)
+ lines.Add("• Local gateway record will be replaced");
+ if (config.HasIdentityFiles)
+ lines.Add("• Device identity files for the local gateway will be regenerated");
+
+ if (config.PreservedGatewayCount > 0)
+ {
+ lines.Add(string.Empty);
+ lines.Add($"The following {config.PreservedGatewayCount} gateway(s) will NOT be affected:");
+ foreach (var name in config.PreservedGatewayNames)
+ lines.Add($" • {name}");
+ }
+
+ return string.Join("\n", lines);
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj
new file mode 100644
index 000000000..7e339fdd1
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ OpenClaw.SetupEngine
+ win-x64;win-arm64
+
+
+
+
+
+
+
diff --git a/src/OpenClaw.SetupEngine/Program.cs b/src/OpenClaw.SetupEngine/Program.cs
new file mode 100644
index 000000000..6323defec
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/Program.cs
@@ -0,0 +1,216 @@
+namespace OpenClaw.SetupEngine;
+
+public static class Program
+{
+ public static async Task Main(string[] args)
+ {
+ Console.WriteLine("OpenClaw Setup Engine v0.1");
+ Console.WriteLine("─────────────────────────────");
+
+ // Parse CLI arguments
+ var configPath = GetArg(args, "--config");
+ var logPath = GetArg(args, "--log-path");
+ var headless = HasFlag(args, "--headless");
+ var rollback = HasFlag(args, "--rollback-on-failure");
+ var noRollback = HasFlag(args, "--no-rollback-on-failure");
+ var dryRun = HasFlag(args, "--dry-run");
+ var wizardOnly = HasFlag(args, "--wizard-only");
+ var uninstall = HasFlag(args, "--uninstall");
+ var confirmDestructive = HasFlag(args, "--confirm-destructive");
+ var jsonOutput = GetArg(args, "--json-output");
+ var preserveLogs = HasFlag(args, "--preserve-logs");
+
+ // Load config
+ SetupConfig config;
+ if (configPath != null && File.Exists(configPath))
+ {
+ Console.WriteLine($"Loading config from: {configPath}");
+ config = SetupConfig.LoadFromFile(configPath);
+ }
+ else
+ {
+ // Look for default-config.json next to the exe
+ var defaultPath = Path.Combine(AppContext.BaseDirectory, "default-config.json");
+ if (File.Exists(defaultPath))
+ {
+ Console.WriteLine($"Loading config from: {defaultPath}");
+ config = SetupConfig.LoadFromFile(defaultPath);
+ }
+ else
+ {
+ Console.Error.WriteLine("ERROR: No config file found. Provide --config or place default-config.json next to the exe.");
+ return 1;
+ }
+ }
+
+ // Apply CLI overrides
+ config = SetupConfig.FromEnvironment(config);
+ if (headless) config.Headless = true;
+ if (rollback) config.RollbackOnFailure = true;
+ if (noRollback) config.RollbackOnFailure = false;
+ if (wizardOnly) config.SkipWizard = false;
+ if (logPath != null) config.LogPath = logPath;
+ if (dryRun) config.DryRun = true;
+ if (confirmDestructive) config.ConfirmDestructive = true;
+
+ // Default log path if not specified
+ var logLabel = uninstall ? "uninstall" : "setup";
+ config.LogPath ??= Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenClawTray", "Logs", "Setup", $"{logLabel}-engine-{DateTime.UtcNow:yyyyMMdd-HHmmss}.jsonl");
+
+ Console.WriteLine($"Log file: {config.LogPath}");
+ Console.WriteLine($"Distro: {config.DistroName}");
+ Console.WriteLine($"Gateway: {config.EffectiveGatewayUrl}");
+ Console.WriteLine($"Mode: {(uninstall ? "UNINSTALL" : "SETUP")}");
+ if (uninstall)
+ {
+ Console.WriteLine($"Dry run: {config.DryRun}");
+ Console.WriteLine($"Confirm destructive: {config.ConfirmDestructive}");
+ }
+ Console.WriteLine();
+
+ if (dryRun && !uninstall)
+ {
+ Console.WriteLine("DRY RUN — config validated, exiting.");
+ return 0;
+ }
+
+ // Uninstall safety gate
+ if (uninstall && !confirmDestructive && !dryRun)
+ {
+ Console.Error.WriteLine("ERROR: --uninstall requires --confirm-destructive (or use --dry-run to preview).");
+ return 2;
+ }
+
+ if (!SetupRunLock.TryAcquire(SetupContext.ResolveDataDir(), out var setupLock, out var lockMessage))
+ {
+ Console.Error.WriteLine($"ERROR: {lockMessage}");
+ return 2;
+ }
+
+ using var acquiredSetupLock = setupLock;
+
+ // Create infrastructure after acquiring the run lock so a concurrent loser
+ // cannot truncate the active run's log or journal files.
+ using var logger = new SetupLogger(config.LogPath, Enum.TryParse(config.LogLevel, true, out var lvl) ? lvl : LogLevel.Trace);
+ var journalPath = Path.ChangeExtension(config.LogPath, ".journal.jsonl");
+ using var journal = new TransactionJournal(journalPath);
+ var commands = new CommandRunner(logger);
+ using var cts = new CancellationTokenSource();
+
+ // Handle Ctrl+C gracefully
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ logger.Warn("Cancellation requested (Ctrl+C)");
+ cts.Cancel();
+ };
+
+ var ctx = new SetupContext(config, logger, journal, commands, cts.Token);
+
+ // Build step pipeline
+ List steps;
+ if (uninstall)
+ {
+ steps = SetupStepFactory.BuildDefaultSteps();
+ }
+ else if (wizardOnly)
+ {
+ steps = [new RunGatewayWizardStep()];
+ }
+ else
+ {
+ steps = BuildSteps(config);
+ }
+
+ var pipeline = new SetupPipeline(steps);
+
+ pipeline.StepProgress += (_, e) =>
+ {
+ var icon = e.Outcome switch
+ {
+ StepOutcome.Success => "✓",
+ StepOutcome.Skipped => "⊘",
+ StepOutcome.Failed or StepOutcome.FailedTerminal => "✗",
+ null => "►",
+ _ => "?"
+ };
+ var elapsed = e.Elapsed.HasValue ? $" ({e.Elapsed.Value.TotalSeconds:F1}s)" : "";
+ Console.WriteLine($" {icon} {e.DisplayName}{elapsed}");
+ };
+
+ // Run!
+ logger.Info($"{(uninstall ? "Uninstall" : "Setup")} engine starting", new { version = "0.1", args = string.Join(' ', args) });
+
+ PipelineResult result;
+ if (uninstall)
+ {
+ result = await pipeline.UninstallAsync(ctx);
+
+ // Post-rollback tray-artifact cleanup (autostart, run.marker, settings, logs)
+ if (result.Outcome == PipelineOutcome.Success || result.Outcome == PipelineOutcome.Failed || result.Outcome == PipelineOutcome.Cancelled)
+ {
+ if (!config.DryRun)
+ {
+ logger.Info("Running tray-artifact cleanup...");
+ TrayArtifactCleanup.Run(ctx, preserveLogs);
+ }
+ }
+ }
+ else
+ {
+ result = await pipeline.RunAsync(ctx);
+ }
+
+ Console.WriteLine();
+ var label = uninstall ? "UNINSTALL" : "SETUP";
+ Console.WriteLine(result.Outcome switch
+ {
+ PipelineOutcome.Success => $"═══ {label} COMPLETE ═══",
+ PipelineOutcome.Failed => $"═══ {label} FAILED ═══\n {result.Message}",
+ PipelineOutcome.Cancelled => $"═══ {label} CANCELLED ═══",
+ _ => "═══ UNKNOWN STATE ═══"
+ });
+
+ Console.WriteLine($"\nLog: {config.LogPath}");
+ Console.WriteLine($"Journal: {journalPath}");
+
+ // Write JSON output if requested (for programmatic callers like CliUninstallHandler)
+ if (jsonOutput != null)
+ {
+ var outputDir = Path.GetDirectoryName(jsonOutput);
+ if (outputDir != null) Directory.CreateDirectory(outputDir);
+
+ var jsonResult = new
+ {
+ outcome = result.Outcome.ToString(),
+ exitCode = result.ExitCode,
+ failedStepId = result.FailedStepId,
+ message = result.Message,
+ logPath = config.LogPath,
+ journalPath
+ };
+ var json = System.Text.Json.JsonSerializer.Serialize(jsonResult, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ await AtomicFile.WriteAllTextAsync(jsonOutput, json);
+ }
+
+ return result.ExitCode;
+ }
+
+ private static List BuildSteps(SetupConfig config)
+ => SetupStepFactory.BuildDefaultSteps();
+
+ private static string? GetArg(string[] args, string name)
+ {
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i].Equals(name, StringComparison.OrdinalIgnoreCase))
+ return args[i + 1];
+ }
+ return null;
+ }
+
+ private static bool HasFlag(string[] args, string name)
+ => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/OpenClaw.SetupEngine/RetryExecutor.cs b/src/OpenClaw.SetupEngine/RetryExecutor.cs
new file mode 100644
index 000000000..36c1a7f14
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/RetryExecutor.cs
@@ -0,0 +1,59 @@
+namespace OpenClaw.SetupEngine;
+
+// ─── Retry Executor ───
+
+public sealed record RetryPolicy(int MaxAttempts = 3, TimeSpan? InitialDelay = null, double BackoffMultiplier = 2.0, TimeSpan? MaxDelay = null)
+{
+ public static readonly RetryPolicy Default = new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(2));
+ public static readonly RetryPolicy None = new(MaxAttempts: 1);
+
+ public TimeSpan EffectiveInitialDelay => InitialDelay ?? TimeSpan.FromSeconds(2);
+ public TimeSpan EffectiveMaxDelay => MaxDelay ?? TimeSpan.FromSeconds(30);
+}
+
+public static class RetryExecutor
+{
+ public static async Task ExecuteWithRetry(
+ Func> action,
+ RetryPolicy policy,
+ SetupLogger logger,
+ string stepId,
+ CancellationToken ct)
+ {
+ var delay = policy.EffectiveInitialDelay;
+ StepResult? lastResult = null;
+
+ for (int attempt = 1; attempt <= policy.MaxAttempts; attempt++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ try
+ {
+ lastResult = await action();
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw; // propagate cancellation
+ }
+ catch (Exception ex)
+ {
+ logger.Error($"Step '{stepId}' threw exception (attempt {attempt}/{policy.MaxAttempts}): {ex.Message}");
+ lastResult = StepResult.Fail($"Unhandled exception: {ex.Message}", ex);
+ }
+
+ if (lastResult.IsSuccess || lastResult.Outcome == StepOutcome.FailedTerminal)
+ return lastResult;
+
+ if (attempt < policy.MaxAttempts)
+ {
+ logger.Warn($"Step '{stepId}' failed (attempt {attempt}/{policy.MaxAttempts}), retrying in {delay.TotalSeconds:F0}s: {lastResult.Message}");
+ await Task.Delay(delay, ct);
+ delay = TimeSpan.FromMilliseconds(Math.Min(
+ delay.TotalMilliseconds * policy.BackoffMultiplier,
+ policy.EffectiveMaxDelay.TotalMilliseconds));
+ }
+ }
+
+ return lastResult ?? StepResult.Fail("Retry exhausted with no result");
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs
new file mode 100644
index 000000000..952ddd583
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupContext.cs
@@ -0,0 +1,308 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace OpenClaw.SetupEngine;
+
+// ─── Configuration ───
+
+public sealed class SetupConfig
+{
+ public string DistroName { get; set; } = "OpenClawGateway";
+ public int GatewayPort { get; set; } = 18789;
+ public string BaseDistro { get; set; } = "";
+ public bool SkipPermissions { get; set; }
+ public bool SkipWizard { get; set; }
+ public bool Headless { get; set; }
+ public bool AutoApprovePairing { get; set; }
+ public bool RollbackOnFailure { get; set; }
+ public int RollbackTimeoutSeconds { get; set; } = 60;
+ public bool CleanBeforeRun { get; set; }
+ public bool DryRun { get; set; }
+ public bool ConfirmDestructive { get; set; }
+ public string LogLevel { get; set; } = "trace";
+ public string? LogPath { get; set; }
+ public string? GatewayUrl { get; set; }
+ public string? BootstrapToken { get; set; }
+ public Dictionary? WizardAnswers { get; set; }
+
+ // Nested config sections — everything is configurable
+ public WslConfig Wsl { get; set; } = new();
+ public GatewayConfig Gateway { get; set; } = new();
+ public CapabilitiesConfig Capabilities { get; set; } = new();
+ public TraySettingsConfig Settings { get; set; } = new();
+ public PairingConfig Pairing { get; set; } = new();
+
+ public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://localhost:{GatewayPort}";
+
+ public static SetupConfig LoadFromFile(string path)
+ {
+ var json = File.ReadAllText(path);
+ return JsonSerializer.Deserialize(json, JsonOptions) ?? new SetupConfig();
+ }
+
+ public static SetupConfig FromEnvironment(SetupConfig? baseConfig = null)
+ {
+ var config = baseConfig ?? new SetupConfig();
+
+ if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_DISTRO") is { Length: > 0 } distro)
+ config.DistroName = distro;
+ if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_PORT") is { Length: > 0 } port && int.TryParse(port, out var p))
+ config.GatewayPort = p;
+ if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_HEADLESS") is "1" or "true")
+ config.Headless = true;
+ if (Environment.GetEnvironmentVariable("OPENCLAW_SETUP_LOG_PATH") is { Length: > 0 } logPath)
+ config.LogPath = logPath;
+
+ return config;
+ }
+
+ public SetupConfig ApplyUiDefaults(bool rollbackOnFailure = true)
+ {
+ Headless = false;
+ RollbackOnFailure = rollbackOnFailure;
+ return this;
+ }
+
+ internal static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ WriteIndented = true
+ };
+}
+
+// ─── WSL Configuration ───
+
+public sealed class WslConfig
+{
+ private static readonly System.Text.RegularExpressions.Regex s_linuxUserNamePattern =
+ new("^[a-z_][a-z0-9_-]{0,31}$", System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ public string User { get; set; } = "openclaw";
+ public bool Systemd { get; set; } = true;
+ public bool Interop { get; set; } = false;
+ public bool AppendWindowsPath { get; set; } = false;
+ public bool Automount { get; set; } = false;
+ public bool MountFsTab { get; set; } = false;
+ public bool UseWindowsTimezone { get; set; } = true;
+ public string? Memory { get; set; }
+ public string? Swap { get; set; }
+
+ public static bool IsValidLinuxUserName(string value)
+ => s_linuxUserNamePattern.IsMatch(value);
+}
+
+// ─── Gateway Configuration ───
+
+public sealed class GatewayConfig
+{
+ public string Bind { get; set; } = "loopback";
+ public string? InstallUrl { get; set; }
+ public string? Version { get; set; }
+ public int HealthTimeoutSeconds { get; set; } = 90;
+ public string ReloadMode { get; set; } = "hot";
+ public string AuthMode { get; set; } = "token";
+ public Dictionary? ExtraConfig { get; set; }
+}
+
+// ─── Capabilities Configuration ───
+
+public sealed class CapabilitiesConfig
+{
+ public bool System { get; set; } = true;
+ public bool Canvas { get; set; } = true;
+ public bool Screen { get; set; } = true;
+ public bool Camera { get; set; } = true;
+ public bool Location { get; set; } = true;
+ public bool Browser { get; set; } = true;
+ public bool Device { get; set; } = true;
+ public bool Tts { get; set; } = true;
+ public bool Stt { get; set; } = true;
+
+ ///
+ /// Returns the list of enabled capability categories and their commands
+ /// for registration on the WindowsNodeClient.
+ ///
+ public IReadOnlyList<(string Category, string[] Commands)> GetEnabledCapabilities()
+ {
+ var result = new List<(string, string[])>();
+
+ if (System) result.Add(("system", ["system.notify", "system.run", "system.run.prepare", "system.which", "system.execApprovals.get", "system.execApprovals.set"]));
+ if (Canvas) result.Add(("canvas", ["canvas.present", "canvas.hide", "canvas.navigate", "canvas.eval", "canvas.snapshot", "canvas.a2ui.push", "canvas.a2ui.pushJSONL", "canvas.a2ui.reset", "canvas.a2ui.dump", "canvas.caps"]));
+ if (Screen) result.Add(("screen", ["screen.snapshot", "screen.record"]));
+ if (Camera) result.Add(("camera", ["camera.list", "camera.snap", "camera.clip"]));
+ if (Location) result.Add(("location", ["location.get"]));
+ if (Tts) result.Add(("tts", ["tts.speak"]));
+ if (Stt) result.Add(("stt", ["stt.transcribe", "stt.listen", "stt.status"]));
+ if (Device) result.Add(("device", ["device.info", "device.status"]));
+ if (Browser) result.Add(("browser", ["browser.proxy"]));
+
+ return result;
+ }
+
+ public IReadOnlyList GetEnabledCommandIds()
+ => GetEnabledCapabilities()
+ .SelectMany(capability => capability.Commands)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Order(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+}
+
+// ─── Tray Settings (written to settings.json) ───
+
+public sealed class TraySettingsConfig
+{
+ public bool EnableNodeMode { get; set; } = true;
+ public bool AutoStart { get; set; } = false;
+ public bool NodeSystemRunEnabled { get; set; } = true;
+ public bool NodeCanvasEnabled { get; set; } = true;
+ public bool NodeScreenEnabled { get; set; } = true;
+ public bool NodeCameraEnabled { get; set; } = true;
+ public bool NodeLocationEnabled { get; set; } = true;
+ public bool NodeBrowserProxyEnabled { get; set; } = true;
+ public bool NodeTtsEnabled { get; set; } = true;
+ public bool NodeSttEnabled { get; set; } = true;
+
+ ///
+ /// Merges these settings into an existing settings.json (or creates a new one).
+ /// Only overwrites the fields we control — preserves all other user settings.
+ ///
+ public void MergeIntoSettingsFile(string settingsPath)
+ {
+ Dictionary? existing = null;
+
+ if (File.Exists(settingsPath))
+ {
+ try
+ {
+ var content = File.ReadAllText(settingsPath);
+ existing = JsonSerializer.Deserialize>(content, SetupConfig.JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ var backupPath = settingsPath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.bak";
+ File.Copy(settingsPath, backupPath, overwrite: false);
+ throw new InvalidDataException($"settings.json is corrupt; backed up to {backupPath}", ex);
+ }
+ }
+
+ var setupOwnedSettings = new Dictionary
+ {
+ ["EnableNodeMode"] = EnableNodeMode,
+ ["AutoStart"] = AutoStart,
+ };
+
+ var initialDefaults = new Dictionary
+ {
+ ["NodeSystemRunEnabled"] = NodeSystemRunEnabled,
+ ["NodeCanvasEnabled"] = NodeCanvasEnabled,
+ ["NodeScreenEnabled"] = NodeScreenEnabled,
+ ["NodeCameraEnabled"] = NodeCameraEnabled,
+ ["NodeLocationEnabled"] = NodeLocationEnabled,
+ ["NodeBrowserProxyEnabled"] = NodeBrowserProxyEnabled,
+ ["NodeTtsEnabled"] = NodeTtsEnabled,
+ ["NodeSttEnabled"] = NodeSttEnabled
+ };
+
+ var settings = new Dictionary();
+ if (existing != null)
+ {
+ foreach (var kvp in existing)
+ settings[kvp.Key] = kvp.Value;
+ }
+
+ foreach (var kvp in setupOwnedSettings)
+ settings[kvp.Key] = kvp.Value;
+
+ foreach (var kvp in initialDefaults)
+ settings.TryAdd(kvp.Key, kvp.Value);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!);
+ var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
+ AtomicFile.WriteAllText(settingsPath, json);
+ }
+}
+
+// ─── Pairing Configuration ───
+
+public sealed class PairingConfig
+{
+ // TODO: Wire OperatorScopes/NodeScopes/CliScopes into pairing requests
+ // when the gateway protocol supports scoped token issuance.
+ public int TimeoutSeconds { get; set; } = 60;
+}
+
+// ─── Step Result ───
+
+public enum StepOutcome { Success, Skipped, Failed, FailedTerminal }
+
+public sealed record StepResult(StepOutcome Outcome, string? Message = null, Exception? Error = null)
+{
+ public static StepResult Ok(string? message = null) => new(StepOutcome.Success, message);
+ public static StepResult Skip(string reason) => new(StepOutcome.Skipped, reason);
+ public static StepResult Fail(string message, Exception? ex = null) => new(StepOutcome.Failed, message, ex);
+ public static StepResult Terminal(string message, Exception? ex = null) => new(StepOutcome.FailedTerminal, message, ex);
+
+ public bool IsSuccess => Outcome is StepOutcome.Success or StepOutcome.Skipped;
+}
+
+// ─── Setup Context ───
+
+public sealed class SetupContext
+{
+ public SetupConfig Config { get; }
+ public SetupLogger Logger { get; }
+ public TransactionJournal Journal { get; }
+ public CommandRunner Commands { get; }
+ public CancellationToken CancellationToken { get; }
+
+ // Accumulated state from steps
+ public string? DistroName { get; set; }
+ public string? GatewayUrl { get; set; }
+ public string? SharedGatewayToken { get; set; }
+ public string? BootstrapToken { get; set; }
+ public string? GatewayRecordId { get; set; }
+ public string? OperatorDeviceId { get; set; }
+ public string? NodeDeviceId { get; set; }
+
+ // Data directory for gateway registry and identity files
+ public string DataDir { get; }
+ public string LocalDataDir { get; }
+
+ // WSL PATH prefix using configured user
+ public string WslPathPrefix => WslConstants.GetPathPrefix(Config.Wsl.User);
+
+ public SetupContext(SetupConfig config, SetupLogger logger, TransactionJournal journal, CommandRunner commands, CancellationToken ct)
+ {
+ Config = config;
+ Logger = logger;
+ Journal = journal;
+ Commands = commands;
+ CancellationToken = ct;
+
+ DataDir = ResolveDataDir();
+ LocalDataDir = ResolveLocalDataDir();
+
+ DistroName = config.DistroName;
+ GatewayUrl = config.EffectiveGatewayUrl;
+ }
+
+ public static string ResolveDataDir()
+ => Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR")
+ ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray");
+
+ public static string ResolveLocalDataDir()
+ {
+ if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCALAPPDATA_DIR") is { Length: > 0 } localAppDataRoot)
+ return Path.Combine(localAppDataRoot, "OpenClawTray");
+
+ // Compatibility alias used by early SetupEngine tests/builds. Unlike
+ // LOCALAPPDATA_DIR, this points directly at the OpenClawTray data folder.
+ if (Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR") is { Length: > 0 } localDataDir)
+ return localDataDir;
+
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OpenClawTray");
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/SetupLogger.cs b/src/OpenClaw.SetupEngine/SetupLogger.cs
new file mode 100644
index 000000000..e2b2a9305
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupLogger.cs
@@ -0,0 +1,178 @@
+using System.Collections.Concurrent;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace OpenClaw.SetupEngine;
+
+// ─── Structured JSONL Logger ───
+
+public enum LogLevel { Trace, Debug, Info, Warn, Error }
+
+public sealed partial class SetupLogger : IDisposable
+{
+ private readonly StreamWriter? _writer;
+ private readonly string? _filePath;
+ private readonly LogLevel _minLevel;
+ private readonly string _runId;
+ private readonly ConcurrentQueue _recentEntries = new();
+ private readonly object _writeLock = new();
+ private const int MaxRecentEntries = 256;
+
+ public event EventHandler? LogEmitted;
+ public string RunId => _runId;
+ public string? FilePath => _filePath;
+
+ public SetupLogger(string? filePath, LogLevel minLevel = LogLevel.Trace)
+ {
+ _minLevel = minLevel;
+ _runId = Guid.NewGuid().ToString("N")[..12];
+
+ if (filePath != null)
+ {
+ _filePath = filePath;
+ var dir = Path.GetDirectoryName(filePath);
+ if (dir != null) Directory.CreateDirectory(dir);
+ _writer = new StreamWriter(filePath, append: false) { AutoFlush = true };
+ }
+ }
+
+ public void Trace(string message, object? data = null) => Write(LogLevel.Trace, message, data);
+ public void Debug(string message, object? data = null) => Write(LogLevel.Debug, message, data);
+ public void Info(string message, object? data = null) => Write(LogLevel.Info, message, data);
+ public void Warn(string message, object? data = null) => Write(LogLevel.Warn, message, data);
+ public void Error(string message, object? data = null) => Write(LogLevel.Error, message, data);
+
+ public void StepStarted(string stepId, string displayName)
+ => Write(LogLevel.Info, $"step.started: {displayName}", new { step_id = stepId });
+
+ public void StepCompleted(string stepId, StepResult result, TimeSpan elapsed)
+ => Write(LogLevel.Info, $"step.completed: {stepId} → {result.Outcome}", new { step_id = stepId, outcome = result.Outcome.ToString(), message = result.Message, elapsed_ms = elapsed.TotalMilliseconds });
+
+ public void CommandStarted(string exe, string[] args, TimeSpan timeout)
+ => Write(LogLevel.Debug, $"cmd.start: {exe} {Sanitize(string.Join(' ', args))}", new { exe, args = args.Select(Sanitize).ToArray(), timeout_ms = timeout.TotalMilliseconds });
+
+ public void CommandCompleted(string exe, CommandResult result, TimeSpan elapsed)
+ {
+ var level = result.ExitCode == 0 ? LogLevel.Debug : LogLevel.Warn;
+ Write(level, $"cmd.done: {exe} exit={result.ExitCode} ({elapsed.TotalMilliseconds:F0}ms)", new
+ {
+ exe,
+ exit_code = result.ExitCode,
+ stdout = Truncate(Sanitize(result.Stdout)),
+ stderr = Truncate(Sanitize(result.Stderr)),
+ elapsed_ms = elapsed.TotalMilliseconds,
+ timed_out = result.TimedOut
+ });
+ }
+
+ public void Decision(string description, string chosen)
+ => Write(LogLevel.Info, $"decision: {description} → {chosen}");
+
+ public void StateChange(string key, string? from, string? to)
+ => Write(LogLevel.Debug, $"state: {key} [{from ?? "null"}] → [{to ?? "null"}]");
+
+ private void Write(LogLevel level, string message, object? data = null)
+ {
+ if (level < _minLevel) return;
+
+ var sanitizedMessage = Sanitize(message);
+ var entry = new LogEntry(DateTimeOffset.UtcNow, _runId, level, sanitizedMessage, SanitizeData(data));
+ _recentEntries.Enqueue(entry);
+ while (_recentEntries.Count > MaxRecentEntries)
+ _recentEntries.TryDequeue(out _);
+
+ LogEmitted?.Invoke(this, entry);
+
+ var json = JsonSerializer.Serialize(new
+ {
+ ts = entry.Timestamp.ToString("O"),
+ run = entry.RunId,
+ level = entry.Level.ToString().ToLowerInvariant(),
+ msg = entry.Message,
+ data = entry.Data
+ }, _jsonOptions);
+
+ lock (_writeLock)
+ {
+ _writer?.WriteLine(json);
+ }
+
+ // Also write to console for headless mode
+ var color = level switch
+ {
+ LogLevel.Error => ConsoleColor.Red,
+ LogLevel.Warn => ConsoleColor.Yellow,
+ LogLevel.Info => ConsoleColor.White,
+ _ => ConsoleColor.DarkGray
+ };
+ var prev = Console.ForegroundColor;
+ Console.ForegroundColor = color;
+ Console.WriteLine($"[{entry.Timestamp:HH:mm:ss.fff}] [{level}] {entry.Message}");
+ Console.ForegroundColor = prev;
+ }
+
+ // ─── Secret Redaction ───
+
+ internal static string Sanitize(string input)
+ {
+ if (string.IsNullOrEmpty(input)) return input;
+ input = PrivateKeyPattern().Replace(input, "[REDACTED-PRIVATE-KEY]");
+ input = BearerPattern().Replace(input, "$1[REDACTED]");
+ input = JwtPattern().Replace(input, "[REDACTED-JWT]");
+ input = SensitiveKeyPattern().Replace(input, "$1[REDACTED]");
+ input = HexTokenPattern().Replace(input, "[REDACTED-HEX]");
+ input = Base64TokenPattern().Replace(input, "[REDACTED-SECRET]");
+ return input;
+ }
+
+ private static object? SanitizeData(object? data)
+ {
+ if (data == null)
+ return null;
+
+ if (data is string value)
+ return Sanitize(value);
+
+ try
+ {
+ var json = JsonSerializer.Serialize(data, _jsonOptions);
+ var sanitized = Sanitize(json);
+ using var doc = JsonDocument.Parse(sanitized);
+ return doc.RootElement.Clone();
+ }
+ catch (Exception ex) when (ex is JsonException or NotSupportedException or ArgumentException)
+ {
+ return Sanitize(data.ToString() ?? string.Empty);
+ }
+ }
+
+ private static string Truncate(string input, int max = 4096)
+ => input.Length <= max ? input : input[..max] + $"... [truncated {input.Length - max} chars]";
+
+ [GeneratedRegex(@"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----", RegexOptions.Compiled)]
+ private static partial Regex PrivateKeyPattern();
+
+ [GeneratedRegex(@"(\bBearer\s+)[A-Za-z0-9._~+/=-]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ private static partial Regex BearerPattern();
+
+ [GeneratedRegex(@"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", RegexOptions.Compiled)]
+ private static partial Regex JwtPattern();
+
+ [GeneratedRegex(@"((?:""[^""]*(?:token|password|secret|api[_-]?key|setup[_-]?code|authorization)[^""]*""|[A-Za-z0-9_.-]*(?:token|password|secret|api[_-]?key|setup[_-]?code|authorization)[A-Za-z0-9_.-]*)\s*[:=]?\s*""?)[^""\s,}]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ private static partial Regex SensitiveKeyPattern();
+
+ [GeneratedRegex(@"\b[a-fA-F0-9]{32,}\b", RegexOptions.Compiled)]
+ private static partial Regex HexTokenPattern();
+
+ [GeneratedRegex(@"\b[A-Za-z0-9+/_-]{48,}={0,2}\b", RegexOptions.Compiled)]
+ private static partial Regex Base64TokenPattern();
+
+ private static readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public void Dispose() => _writer?.Dispose();
+}
+
+public sealed record LogEntry(DateTimeOffset Timestamp, string RunId, LogLevel Level, string Message, object? Data);
diff --git a/src/OpenClaw.SetupEngine/SetupPipeline.cs b/src/OpenClaw.SetupEngine/SetupPipeline.cs
new file mode 100644
index 000000000..6f29d6b35
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupPipeline.cs
@@ -0,0 +1,319 @@
+using System.Diagnostics;
+
+namespace OpenClaw.SetupEngine;
+
+// ─── Abstract Step Base ───
+
+public abstract class SetupStep
+{
+ public abstract string Id { get; }
+ public abstract string DisplayName { get; }
+
+ public abstract Task ExecuteAsync(SetupContext ctx, CancellationToken ct);
+
+ public virtual Task RollbackAsync(SetupContext ctx, CancellationToken ct) => Task.CompletedTask;
+ public virtual bool CanSkip(SetupContext ctx) => false;
+ public virtual bool CanRetry => true;
+ public virtual RetryPolicy Retry => RetryPolicy.Default;
+}
+
+// ─── Pipeline Result ───
+
+public enum PipelineOutcome { Success, Failed, Cancelled }
+
+public sealed record PipelineResult(PipelineOutcome Outcome, string? FailedStepId = null, string? Message = null)
+{
+ public int ExitCode => Outcome switch
+ {
+ PipelineOutcome.Success => 0,
+ PipelineOutcome.Failed => 1,
+ PipelineOutcome.Cancelled => 3,
+ _ => 1
+ };
+}
+
+// ─── Pipeline Events ───
+
+public sealed record StepProgressEvent(string StepId, string DisplayName, StepOutcome? Outcome, TimeSpan? Elapsed);
+
+public static class SetupStepFactory
+{
+ public static List BuildDefaultSteps()
+ {
+ return
+ [
+ new CleanupStaleDistroStep(),
+ new CleanupStaleGatewayStep(),
+ new PreflightOsStep(),
+ new PreflightWslStep(),
+ new PreflightPortStep(),
+ new CreateWslInstanceStep(),
+ new ConfigureWslInstanceStep(),
+ new ValidateWslLockdownStep(),
+ new InstallCliStep(),
+ new ConfigureGatewayStep(),
+ new InstallGatewayServiceStep(),
+ new StartGatewayStep(),
+ new MintBootstrapTokenStep(),
+ new PairOperatorStep(),
+ new PairNodeStep(),
+ new VerifyEndToEndStep(),
+ new RunGatewayWizardStep(),
+ new StartKeepaliveStep(),
+ ];
+ }
+}
+
+// ─── Setup Pipeline ───
+
+public sealed class SetupPipeline
+{
+ private readonly List _steps;
+ private readonly List _completedSteps = new();
+
+ public event EventHandler? StepProgress;
+
+ public SetupPipeline(IEnumerable steps)
+ {
+ _steps = steps.ToList();
+ }
+
+ public async Task RunAsync(SetupContext ctx)
+ {
+ _completedSteps.Clear();
+ var ct = ctx.CancellationToken;
+ ctx.Journal.RecordPipelineEvent("pipeline_started", $"steps={_steps.Count}");
+ ctx.Logger.Info($"Pipeline starting with {_steps.Count} steps", new { run_id = ctx.Logger.RunId });
+
+ var pipelineSw = Stopwatch.StartNew();
+
+ foreach (var step in _steps)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ ctx.Journal.RecordPipelineEvent("pipeline_cancelled");
+ return new PipelineResult(PipelineOutcome.Cancelled);
+ }
+
+ // Check if step can be skipped
+ if (step.CanSkip(ctx))
+ {
+ ctx.Logger.Info($"Skipping step: {step.DisplayName}");
+ ctx.Journal.RecordStepCompleted(step.Id, StepOutcome.Skipped, TimeSpan.Zero, "precondition met");
+ StepProgress?.Invoke(this, new(step.Id, step.DisplayName, StepOutcome.Skipped, null));
+ continue;
+ }
+
+ // Execute with retry
+ ctx.Logger.StepStarted(step.Id, step.DisplayName);
+ ctx.Journal.RecordStepStarted(step.Id);
+ StepProgress?.Invoke(this, new(step.Id, step.DisplayName, null, null));
+
+ var sw = Stopwatch.StartNew();
+ StepResult result;
+
+ if (step.CanRetry && step.Retry.MaxAttempts > 1)
+ {
+ try
+ {
+ result = await RetryExecutor.ExecuteWithRetry(
+ () => step.ExecuteAsync(ctx, ct),
+ step.Retry,
+ ctx.Logger,
+ step.Id,
+ ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ ctx.Journal.RecordPipelineEvent("pipeline_cancelled", $"during step {step.Id}");
+ return new PipelineResult(PipelineOutcome.Cancelled);
+ }
+ }
+ else
+ {
+ try
+ {
+ result = await step.ExecuteAsync(ctx, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ ctx.Journal.RecordPipelineEvent("pipeline_cancelled", $"during step {step.Id}");
+ return new PipelineResult(PipelineOutcome.Cancelled);
+ }
+ catch (Exception ex)
+ {
+ result = StepResult.Fail($"Unhandled exception: {ex.Message}", ex);
+ }
+ }
+
+ sw.Stop();
+ ctx.Logger.StepCompleted(step.Id, result, sw.Elapsed);
+ ctx.Journal.RecordStepCompleted(step.Id, result.Outcome, sw.Elapsed, result.Message);
+ StepProgress?.Invoke(this, new(step.Id, step.DisplayName, result.Outcome, sw.Elapsed));
+
+ if (result.IsSuccess)
+ {
+ _completedSteps.Add(step);
+ continue;
+ }
+
+ // Step failed — handle rollback if configured
+ ctx.Logger.Error($"Step '{step.Id}' failed: {result.Message}");
+
+ if (ctx.Config.RollbackOnFailure)
+ {
+ await RollbackFailedStep(step, ctx);
+ await RollbackCompletedSteps(ctx);
+ }
+
+ ctx.Journal.RecordPipelineEvent("pipeline_failed", $"step={step.Id}, message={result.Message}");
+ return new PipelineResult(PipelineOutcome.Failed, step.Id, result.Message);
+ }
+
+ pipelineSw.Stop();
+ ctx.Journal.RecordPipelineEvent("pipeline_completed", $"elapsed={pipelineSw.Elapsed.TotalSeconds:F1}s");
+ ctx.Logger.Info($"Pipeline completed successfully in {pipelineSw.Elapsed.TotalSeconds:F1}s");
+ return new PipelineResult(PipelineOutcome.Success);
+ }
+
+ private async Task RollbackCompletedSteps(SetupContext ctx)
+ {
+ ctx.Logger.Warn($"Rolling back {_completedSteps.Count} completed steps");
+ for (int i = _completedSteps.Count - 1; i >= 0; i--)
+ {
+ var step = _completedSteps[i];
+ try
+ {
+ ctx.Logger.Info($"Rolling back: {step.DisplayName}");
+ await RunRollbackWithTimeout(step, ctx, ctx.CancellationToken);
+ ctx.Journal.RecordRollback(step.Id, success: true);
+ }
+ catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ ctx.Logger.Error($"Rollback failed for {step.Id}: {ex.Message}");
+ ctx.Journal.RecordRollback(step.Id, success: false);
+ }
+ }
+ }
+
+ private static async Task RollbackFailedStep(SetupStep step, SetupContext ctx)
+ {
+ ctx.Logger.Warn($"Attempting cleanup for failed step: {step.DisplayName}");
+
+ try
+ {
+ await RunRollbackWithTimeout(step, ctx, ctx.CancellationToken);
+ ctx.Journal.RecordRollback(step.Id, success: true);
+ }
+ catch (OperationCanceledException) when (ctx.CancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ ctx.Logger.Error($"Cleanup failed for failed step {step.Id}: {ex.Message}");
+ ctx.Journal.RecordRollback(step.Id, success: false);
+ }
+ }
+
+ ///
+ /// Runs all step rollbacks in reverse order (full teardown / uninstall).
+ /// Unlike RollbackCompletedSteps, this runs ALL steps regardless of install state.
+ /// Continues past individual failures to ensure maximum cleanup.
+ ///
+ public async Task UninstallAsync(SetupContext ctx)
+ {
+ _completedSteps.Clear();
+ var ct = ctx.CancellationToken;
+
+ if (!ctx.Config.ConfirmDestructive && !ctx.Config.DryRun)
+ {
+ ctx.Logger.Error("Uninstall requires --confirm-destructive flag");
+ return new PipelineResult(PipelineOutcome.Failed, Message: "Safety gate: --confirm-destructive required for live uninstall");
+ }
+
+ ctx.Journal.RecordPipelineEvent("uninstall_started", $"steps={_steps.Count}, dry_run={ctx.Config.DryRun}");
+ ctx.Logger.Info($"Uninstall starting — {_steps.Count} steps in reverse order (dry_run={ctx.Config.DryRun})");
+
+ var pipelineSw = Stopwatch.StartNew();
+ var failures = new List<(string StepId, string Message)>();
+
+ // Run rollbacks in reverse order
+ for (int i = _steps.Count - 1; i >= 0; i--)
+ {
+ if (ct.IsCancellationRequested)
+ {
+ ctx.Journal.RecordPipelineEvent("uninstall_cancelled");
+ return new PipelineResult(PipelineOutcome.Cancelled);
+ }
+
+ var step = _steps[i];
+ ctx.Logger.Info($"Uninstalling: {step.DisplayName}");
+ StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", null, null));
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ if (ctx.Config.DryRun)
+ {
+ ctx.Logger.Info($"[DRY RUN] Would rollback: {step.Id}");
+ ctx.Journal.RecordRollback(step.Id, success: true);
+ }
+ else
+ {
+ await RunRollbackWithTimeout(step, ctx, ct);
+ ctx.Journal.RecordRollback(step.Id, success: true);
+ }
+ sw.Stop();
+ StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", StepOutcome.Success, sw.Elapsed));
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ ctx.Journal.RecordPipelineEvent("uninstall_cancelled", $"during rollback of {step.Id}");
+ return new PipelineResult(PipelineOutcome.Cancelled);
+ }
+ catch (Exception ex)
+ {
+ sw.Stop();
+ ctx.Logger.Error($"Rollback failed for {step.Id}: {ex.Message}");
+ ctx.Journal.RecordRollback(step.Id, success: false);
+ failures.Add((step.Id, ex.Message));
+ StepProgress?.Invoke(this, new(step.Id, $"Uninstall: {step.DisplayName}", StepOutcome.Failed, sw.Elapsed));
+ // Continue past failures — best-effort cleanup
+ }
+ }
+
+ pipelineSw.Stop();
+
+ if (failures.Count > 0)
+ {
+ var failMsg = $"{failures.Count} rollback(s) failed: {string.Join(", ", failures.Select(f => f.StepId))}";
+ ctx.Journal.RecordPipelineEvent("uninstall_completed_with_errors", failMsg);
+ ctx.Logger.Warn($"Uninstall completed with errors in {pipelineSw.Elapsed.TotalSeconds:F1}s — {failMsg}");
+ return new PipelineResult(PipelineOutcome.Failed, Message: failMsg);
+ }
+
+ ctx.Journal.RecordPipelineEvent("uninstall_completed", $"elapsed={pipelineSw.Elapsed.TotalSeconds:F1}s");
+ ctx.Logger.Info($"Uninstall completed successfully in {pipelineSw.Elapsed.TotalSeconds:F1}s");
+ return new PipelineResult(PipelineOutcome.Success);
+ }
+
+ private static async Task RunRollbackWithTimeout(SetupStep step, SetupContext ctx, CancellationToken ct)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, ctx.Config.RollbackTimeoutSeconds)));
+
+ try
+ {
+ await step.RollbackAsync(ctx, cts.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ throw new TimeoutException($"Rollback for step '{step.Id}' exceeded {ctx.Config.RollbackTimeoutSeconds}s.");
+ }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/SetupRunLock.cs b/src/OpenClaw.SetupEngine/SetupRunLock.cs
new file mode 100644
index 000000000..af15c7f73
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupRunLock.cs
@@ -0,0 +1,51 @@
+namespace OpenClaw.SetupEngine;
+
+public sealed class SetupRunLock : IDisposable
+{
+ private readonly FileStream _stream;
+ private readonly string _path;
+
+ private SetupRunLock(FileStream stream, string path)
+ {
+ _stream = stream;
+ _path = path;
+ }
+
+ public static bool TryAcquire(string dataDir, out SetupRunLock? runLock, out string? message)
+ {
+ Directory.CreateDirectory(dataDir);
+ var path = Path.Combine(dataDir, "setup.lock");
+
+ try
+ {
+ var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+ stream.SetLength(0);
+ using var writer = new StreamWriter(stream, leaveOpen: true) { AutoFlush = true };
+ writer.WriteLine($"pid={Environment.ProcessId}");
+ writer.WriteLine($"startedUtc={DateTimeOffset.UtcNow:O}");
+ stream.Flush(flushToDisk: true);
+
+ runLock = new SetupRunLock(stream, path);
+ message = null;
+ return true;
+ }
+ catch (IOException)
+ {
+ runLock = null;
+ message = $"Another OpenClaw setup run appears to be active. Wait for it to finish, then retry. Lock file: {path}";
+ return false;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ runLock = null;
+ message = $"Cannot create setup lock at {path}: {ex.Message}";
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ _stream.Dispose();
+ try { File.Delete(_path); } catch { }
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs
new file mode 100644
index 000000000..4f851d16b
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupSteps.cs
@@ -0,0 +1,2430 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text.Json;
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+
+namespace OpenClaw.SetupEngine;
+
+// PATH prefix for all openclaw CLI commands in WSL
+internal static class WslConstants
+{
+ public static string GetPathPrefix(string user) =>
+ $"""export PATH="/home/{user}/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:$PATH" """;
+
+ public static string WslExePath
+ {
+ get
+ {
+ var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ if (string.IsNullOrWhiteSpace(windowsDir))
+ windowsDir = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows";
+ return Path.Combine(windowsDir, "System32", "wsl.exe");
+ }
+ }
+
+ public static string SafeWindowsWorkingDirectory
+ => Environment.GetFolderPath(Environment.SpecialFolder.System) is { Length: > 0 } systemDir
+ ? systemDir
+ : Path.GetPathRoot(Environment.SystemDirectory) ?? @"C:\";
+
+ // Default (for backward compat with steps that don't have user context yet)
+ public const string PathPrefix = """export PATH="/home/openclaw/.openclaw/bin:/opt/openclaw/bin:/usr/local/bin:$PATH" """;
+}
+
+// Adapter to bridge SetupLogger → IOpenClawLogger for WebSocket clients
+internal sealed class SetupOpenClawLogger(SetupLogger logger) : IOpenClawLogger
+{
+ public void Info(string message) => logger.Info($"[WS] {message}");
+ public void Debug(string message) => logger.Debug($"[WS] {message}");
+ public void Warn(string message) => logger.Warn($"[WS] {message}");
+ public void Error(string message, Exception? ex = null) => logger.Error($"[WS] {message}{(ex != null ? $": {ex}" : "")}");
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// CLEANUP STEPS
+// ═══════════════════════════════════════════════════════════════════
+
+public sealed class CleanupStaleDistroStep : SetupStep
+{
+ public override string Id => "cleanup-distro";
+ public override string DisplayName => "Clean up stale WSL distro";
+ public override bool CanRetry => false;
+
+ public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun;
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct);
+ if (list.ExitCode != 0)
+ return StepResult.Ok("WSL not available or no distros — nothing to clean");
+
+ // wsl.exe outputs UTF-16 with potential BOM/null chars — normalize aggressively
+ var distros = list.Stdout
+ .Replace("\0", "")
+ .Replace("\uFEFF", "")
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .Select(d => d.Trim())
+ .Where(d => d.Length > 0)
+ .ToList();
+
+ ctx.Logger.Debug($"Found WSL distros: [{string.Join(", ", distros)}]");
+
+ if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Distro not registered, but disk directory may still exist from prior crash
+ var wslDir = Path.Combine(ctx.LocalDataDir, "wsl", distro);
+ if (Directory.Exists(wslDir))
+ {
+ ctx.Logger.Info($"Removing orphaned WSL directory: {wslDir}");
+ // Shut down WSL VM to release VHD locks
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct);
+ await Task.Delay(2000, ct);
+
+ // Retry deletion — VHD may still be locked briefly after WSL shutdown
+ for (int attempt = 0; attempt < 3; attempt++)
+ {
+ try
+ {
+ Directory.Delete(wslDir, recursive: true);
+ break;
+ }
+ catch (IOException) when (attempt < 2)
+ {
+ ctx.Logger.Warn($"VHD directory still locked, retrying in {(attempt + 1) * 2}s...");
+ await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct);
+ }
+ catch (UnauthorizedAccessException) when (attempt < 2)
+ {
+ ctx.Logger.Warn($"VHD directory access denied, retrying in {(attempt + 1) * 2}s...");
+ await Task.Delay(TimeSpan.FromSeconds((attempt + 1) * 2), ct);
+ }
+ }
+ }
+ ctx.Logger.Decision("No stale distro found", "skip cleanup");
+ return StepResult.Ok("No stale distro to clean");
+ }
+
+ ctx.Logger.Decision($"Found existing distro '{distro}'", "terminating and unregistering");
+
+ // Terminate first (stops gateway service), then shut WSL down to release VHD/port locks.
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct);
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct);
+ await Task.Delay(2000, ct); // Let port release
+
+ var unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct);
+ if (unregister.ExitCode != 0)
+ {
+ ctx.Logger.Warn($"First unregister attempt failed (exit {unregister.ExitCode}); forcing WSL shutdown and retrying");
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct);
+ await Task.Delay(3000, ct);
+ unregister = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct);
+ }
+
+ if (unregister.ExitCode == 0)
+ {
+ // Also remove the on-disk WSL vhdx directory (--import fails if it exists)
+ var wslDir = Path.Combine(ctx.LocalDataDir, "wsl", distro);
+ if (Directory.Exists(wslDir))
+ {
+ ctx.Logger.Info($"Removing leftover WSL directory: {wslDir}");
+ Directory.Delete(wslDir, recursive: true);
+ }
+
+ // Wait for port to be released
+ ctx.Logger.Info("Waiting for port release after distro termination...");
+ await Task.Delay(3000, ct);
+ return StepResult.Ok($"Unregistered stale distro '{distro}'");
+ }
+
+ return StepResult.Fail($"Failed to unregister distro: {unregister.Stderr}");
+ }
+}
+
+public sealed class CleanupStaleGatewayStep : SetupStep
+{
+ public override string Id => "cleanup-gateway";
+ public override string DisplayName => "Clean up stale gateway state";
+ public override bool CanRetry => false;
+
+ public override bool CanSkip(SetupContext ctx) => !ctx.Config.CleanBeforeRun;
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ // Remove stale setup-state.json from AppData (legacy location)
+ var stateFile = Path.Combine(ctx.DataDir, "setup-state.json");
+ if (File.Exists(stateFile))
+ {
+ File.Delete(stateFile);
+ ctx.Logger.Info("Deleted stale setup-state.json (AppData)");
+ }
+
+ // Also remove from LocalAppData (current write location)
+ var localStateFile = Path.Combine(ctx.LocalDataDir, "setup-state.json");
+ if (File.Exists(localStateFile))
+ {
+ File.Delete(localStateFile);
+ ctx.Logger.Info("Deleted stale setup-state.json (LocalAppData)");
+ }
+
+ // Remove stale gateway record for our local URL if it exists
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+ var existing = registry.FindByUrl(ctx.GatewayUrl!);
+ if (existing != null)
+ {
+ // Preserve non-local records and SSH-tunneled gateways — they may be
+ // remote gateways that happen to use localhost as a forwarded port.
+ if (!PairOperatorStep.IsSetupManagedLocalRecord(existing, ctx))
+ {
+ ctx.Logger.Warn($"Skipping cleanup of gateway record {existing.Id}: " +
+ "not a SetupEngine-managed local gateway");
+ }
+ else
+ {
+ // Clean identity directory
+ var identityDir = registry.GetIdentityDirectory(existing.Id);
+ if (Directory.Exists(identityDir))
+ {
+ Directory.Delete(identityDir, recursive: true);
+ ctx.Logger.Info($"Deleted stale identity directory: {identityDir}");
+ }
+ registry.Remove(existing.Id);
+ registry.Save();
+ ctx.Logger.Info($"Removed stale gateway record for {ctx.GatewayUrl}");
+ }
+ }
+
+ await Task.CompletedTask;
+ return StepResult.Ok("Gateway state cleaned");
+ }
+
+ public override Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ // Delete setup-state.json (written by VerifyEndToEndStep)
+ var localDataPath = ctx.LocalDataDir;
+
+ var stateFile = Path.Combine(localDataPath, "setup-state.json");
+ if (File.Exists(stateFile))
+ {
+ File.Delete(stateFile);
+ ctx.Logger.Info("[Uninstall] Deleted setup-state.json");
+ }
+ else
+ {
+ ctx.Logger.Info("[Uninstall] setup-state.json already absent");
+ }
+
+ return Task.CompletedTask;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// PREFLIGHT STEPS
+// ═══════════════════════════════════════════════════════════════════
+
+public sealed class PreflightOsStep : SetupStep
+{
+ public override string Id => "preflight-os";
+ public override string DisplayName => "Verify Windows OS";
+ public override bool CanRetry => false;
+
+ public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ if (!Environment.Is64BitOperatingSystem)
+ return Task.FromResult(StepResult.Terminal("64-bit Windows required"));
+
+ if (!OperatingSystem.IsWindows())
+ return Task.FromResult(StepResult.Terminal("Windows OS required"));
+
+ var version = Environment.OSVersion.Version;
+ ctx.Logger.Info($"OS: Windows {version} (64-bit)");
+
+ return Task.FromResult(StepResult.Ok($"Windows {version}"));
+ }
+}
+
+public sealed class PreflightWslStep : SetupStep
+{
+ public override string Id => "preflight-wsl";
+ public override string DisplayName => "Verify WSL available";
+ public override bool CanRetry => false;
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct);
+ if (versionResult.ExitCode != 0 && LooksUnavailable(versionResult))
+ {
+ var installResult = await InstallWslPlatformAsync(ctx, ct);
+ if (!installResult.IsSuccess)
+ return installResult;
+
+ versionResult = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct);
+ }
+
+ if (versionResult.ExitCode != 0)
+ return StepResult.Terminal($"WSL is not available. {FirstUsefulLine(versionResult)}");
+
+ ctx.Logger.Info($"WSL version output: {NormalizeWslOutput(versionResult.Stdout).Trim()}");
+ return StepResult.Ok("WSL available");
+ }
+
+ private static async Task InstallWslPlatformAsync(SetupContext ctx, CancellationToken ct)
+ {
+ ctx.Logger.Warn("WSL platform appears to be missing; launching elevated WSL platform install");
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = WslConstants.WslExePath,
+ UseShellExecute = true,
+ Verb = "runas",
+ CreateNoWindow = true,
+ WorkingDirectory = WslConstants.SafeWindowsWorkingDirectory
+ };
+ psi.ArgumentList.Add("--install");
+ psi.ArgumentList.Add("--no-distribution");
+
+ using var process = Process.Start(psi);
+ if (process == null)
+ return StepResult.Fail("Could not start elevated WSL platform installer.");
+
+ await process.WaitForExitAsync(ct);
+
+ if (process.ExitCode == 3010)
+ return StepResult.Terminal("WSL platform install requires a restart. Reboot Windows, then run setup again.");
+
+ if (process.ExitCode != 0)
+ return StepResult.Fail($"WSL platform install failed with exit code {process.ExitCode}.");
+
+ var probe = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--version"], TimeSpan.FromSeconds(5), ct: ct);
+ if (probe.ExitCode != 0 || LooksUnavailable(probe))
+ return StepResult.Terminal("WSL platform install completed, but Windows still reports WSL unavailable. Reboot Windows, then run setup again.");
+
+ return StepResult.Ok("WSL platform installed");
+ }
+ catch (System.ComponentModel.Win32Exception ex) when ((uint)ex.NativeErrorCode == 1223)
+ {
+ return StepResult.Fail("WSL platform install was cancelled at the elevation prompt.");
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail($"WSL platform install failed: {ex.Message}", ex);
+ }
+ }
+
+ private static bool LooksUnavailable(CommandResult result)
+ {
+ var text = NormalizeWslOutput($"{result.Stdout}\n{result.Stderr}");
+ return text.Contains("aka.ms/wslinstall", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("Windows Subsystem for Linux has no installed distributions", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("not recognized", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("not installed", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string NormalizeWslOutput(string value)
+ => value.Replace("\0", "").Replace("\uFEFF", "");
+
+ private static string FirstUsefulLine(CommandResult result)
+ {
+ var text = NormalizeWslOutput($"{result.Stderr}\n{result.Stdout}");
+ return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
+ ?? "Run wsl --install from an elevated terminal and retry setup.";
+ }
+}
+
+public sealed class PreflightPortStep : SetupStep
+{
+ public override string Id => "preflight-port";
+ public override string DisplayName => "Check gateway port available";
+ public override bool CanRetry => false;
+
+ public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var port = ctx.Config.GatewayPort;
+ var addresses = ctx.Config.Gateway.Bind.Equals("lan", StringComparison.OrdinalIgnoreCase)
+ ? new[] { IPAddress.Any, IPAddress.IPv6Any }
+ : [IPAddress.Loopback];
+
+ foreach (var address in addresses)
+ {
+ if (!CanBind(address, port, out var error))
+ return Task.FromResult(StepResult.Fail($"Port {port} is already in use for {DescribeBind(address)} ({error.SocketErrorCode})"));
+ }
+
+ return Task.FromResult(StepResult.Ok($"Port {port} is available"));
+ }
+
+ private static bool CanBind(IPAddress address, int port, out SocketException error)
+ {
+ var listener = new TcpListener(address, port)
+ {
+ ExclusiveAddressUse = true
+ };
+
+ try
+ {
+ listener.Start();
+ error = null!;
+ return true;
+ }
+ catch (SocketException ex)
+ {
+ error = ex;
+ return false;
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ private static string DescribeBind(IPAddress address)
+ => address.Equals(IPAddress.Any) ? "LAN IPv4 bind" :
+ address.Equals(IPAddress.IPv6Any) ? "LAN IPv6 bind" :
+ "loopback bind";
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// WSL STEPS
+// ═══════════════════════════════════════════════════════════════════
+
+public sealed class CreateWslInstanceStep : SetupStep
+{
+ public override string Id => "wsl-create";
+ public override string DisplayName => "Create WSL instance";
+ public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(5));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var baseDistro = ctx.Config.BaseDistro;
+
+ ctx.Logger.Info($"Creating WSL distro '{distro}' from base '{baseDistro}'");
+
+ // Import as our named distro
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-setup-{ctx.Logger.RunId}");
+ Directory.CreateDirectory(tempDir);
+
+ try
+ {
+ // Export base → import as our distro name
+ var exportPath = Path.Combine(tempDir, "base.tar");
+ var export = await ctx.Commands.RunAsync(
+ WslConstants.WslExePath, ["--export", baseDistro, exportPath],
+ TimeSpan.FromMinutes(5), ct: ct);
+
+ if (export.ExitCode != 0)
+ {
+ ctx.Logger.Warn($"Base distro export failed (exit {export.ExitCode}); attempting to install {baseDistro}");
+ var install = await ctx.Commands.RunAsync(
+ WslConstants.WslExePath, ["--install", baseDistro, "--no-launch"],
+ TimeSpan.FromMinutes(5), ct: ct);
+
+ if (install.ExitCode != 0 && !install.Stdout.Contains("already installed", StringComparison.OrdinalIgnoreCase))
+ return StepResult.Fail($"Failed to install base distro '{baseDistro}' (exit {install.ExitCode}): {install.Stderr}");
+
+ export = await ctx.Commands.RunAsync(
+ WslConstants.WslExePath, ["--export", baseDistro, exportPath],
+ TimeSpan.FromMinutes(5), ct: ct);
+ }
+
+ if (export.ExitCode != 0)
+ return StepResult.Fail($"Failed to export base distro: {export.Stderr}");
+
+ var installPath = Path.Combine(ctx.LocalDataDir, "wsl", distro);
+ Directory.CreateDirectory(installPath);
+
+ var import = await ctx.Commands.RunAsync(
+ WslConstants.WslExePath, ["--import", distro, installPath, exportPath, "--version", "2"],
+ TimeSpan.FromMinutes(5), ct: ct);
+
+ if (import.ExitCode != 0)
+ return StepResult.Fail($"Failed to import distro: {import.Stderr}");
+
+ return StepResult.Ok($"Created WSL2 distro '{distro}'");
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { /* best effort */ }
+ }
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct);
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--shutdown"], TimeSpan.FromSeconds(30), ct: ct);
+ await Task.Delay(2000, ct); // Let port/VHD locks release
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--unregister", distro], TimeSpan.FromSeconds(60), ct: ct);
+
+ // VHD parent dir cleanup (mirrors old uninstall step 5a)
+ var localDataPath = ctx.LocalDataDir;
+ var vhdDir = Path.Combine(localDataPath, "wsl", distro);
+ if (Directory.Exists(vhdDir))
+ {
+ Directory.Delete(vhdDir, recursive: true);
+ ctx.Logger.Info($"[Uninstall] Deleted VHD parent directory: {vhdDir}");
+ }
+
+ // WSL parent dir cleanup — remove empty wsl\ directory (mirrors old step 5b)
+ var wslDir = Path.Combine(localDataPath, "wsl");
+ if (Directory.Exists(wslDir) && !Directory.EnumerateFileSystemEntries(wslDir).Any())
+ {
+ Directory.Delete(wslDir);
+ ctx.Logger.Info("[Uninstall] Deleted empty wsl\\ parent directory");
+ }
+ }
+}
+
+public sealed class ConfigureWslInstanceStep : SetupStep
+{
+ public override string Id => "wsl-configure";
+ public override string DisplayName => "Configure WSL instance";
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var wsl = ctx.Config.Wsl;
+
+ if (!WslConfig.IsValidLinuxUserName(wsl.User))
+ return StepResult.Terminal($"Invalid WSL user '{wsl.User}'. Use a Linux username matching [a-z_][a-z0-9_-]{{0,31}}.");
+
+ // Build wsl.conf from config
+ var wslConf = $"""
+[boot]
+systemd={wsl.Systemd.ToString().ToLower()}
+
+[automount]
+enabled={wsl.Automount.ToString().ToLower()}
+mountFsTab={wsl.MountFsTab.ToString().ToLower()}
+
+[interop]
+enabled={wsl.Interop.ToString().ToLower()}
+appendWindowsPath={wsl.AppendWindowsPath.ToString().ToLower()}
+
+[user]
+default={wsl.User}
+
+[time]
+useWindowsTimezone={wsl.UseWindowsTimezone.ToString().ToLower()}
+""";
+
+ // Create user and directories
+ var script = $"""
+ set -e
+
+ # Create user if not exists
+ if ! id -u {wsl.User} &>/dev/null; then
+ useradd -m -s /bin/bash {wsl.User}
+ fi
+
+ # Create required directories
+ mkdir -p /home/{wsl.User}/.openclaw
+ mkdir -p /var/lib/openclaw
+ mkdir -p /var/log/openclaw
+ mkdir -p /opt/openclaw
+
+ chown -R {wsl.User}:{wsl.User} /home/{wsl.User}/.openclaw
+ chown -R {wsl.User}:{wsl.User} /var/lib/openclaw
+ chown -R {wsl.User}:{wsl.User} /var/log/openclaw
+ chown -R {wsl.User}:{wsl.User} /opt/openclaw
+
+ # Write wsl.conf
+ cat > /etc/wsl.conf << 'WSLCONF'
+ {wslConf}
+ WSLCONF
+
+ echo "CONFIGURED_OK"
+ """;
+
+ var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(60), ct: ct, user: "root");
+
+ if (result.ExitCode != 0 || !result.Stdout.Contains("CONFIGURED_OK"))
+ return StepResult.Fail($"Configuration failed: {result.Stderr}");
+
+ // Restart WSL to apply wsl.conf (systemd)
+ ctx.Logger.Info("Restarting WSL to apply configuration (systemd)");
+ await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--terminate", distro], TimeSpan.FromSeconds(30), ct: ct);
+ await Task.Delay(2000, ct); // Let WSL settle
+
+ return StepResult.Ok("WSL instance configured");
+ }
+}
+
+public sealed class ValidateWslLockdownStep : SetupStep
+{
+ public override string Id => "validate-wsl-lockdown";
+ public override string DisplayName => "Validate WSL lockdown";
+ public override bool CanRetry => false;
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var wsl = ctx.Config.Wsl;
+
+ var readConf = await ctx.Commands.RunInWslAsync(distro, "cat /etc/wsl.conf", TimeSpan.FromSeconds(15), ct: ct);
+ if (readConf.ExitCode != 0)
+ return StepResult.Terminal("Cannot read /etc/wsl.conf - WSL configuration may not have been applied");
+
+ var errors = ValidateWslConf(readConf.Stdout, wsl);
+ if (errors.Count > 0)
+ {
+ var msg = "WSL lockdown validation failed:\n" + string.Join("\n", errors.Select(e => $" - {e}"));
+ return StepResult.Terminal(msg);
+ }
+
+ var requiredDirs = new[]
+ {
+ $"/home/{wsl.User}/.openclaw",
+ "/var/lib/openclaw",
+ "/var/log/openclaw",
+ "/opt/openclaw"
+ };
+
+ // Generate per-directory checks inline (no bash variables).
+ // wsl.exe -- bash -c mangles double-quotes and bash $var references,
+ // so we avoid both: paths have no spaces (safe unquoted) and all
+ // values are C#-interpolated rather than stored in bash variables.
+ var dirChecks = new System.Text.StringBuilder();
+ foreach (var d in requiredDirs)
+ {
+ dirChecks.AppendLine($"test -d {d} || {{ echo DIR_MISSING:{d}; exit 1; }}");
+ dirChecks.AppendLine($"test $(stat -c %U {d} 2>/dev/null) = {wsl.User} || {{ echo OWNER_MISMATCH:{d}:$(stat -c %U {d} 2>/dev/null); exit 1; }}");
+ }
+
+ var verifyScript = "set -e\n"
+ + $"id -u {wsl.User} &>/dev/null || {{ echo USER_MISSING; exit 1; }}\n"
+ + dirChecks
+ + "echo LOCKDOWN_VALID\n";
+
+ var verify = await ctx.Commands.RunInWslAsync(distro, verifyScript, TimeSpan.FromSeconds(30), ct: ct);
+
+ ctx.Logger.Debug($"Lockdown verify exit={verify.ExitCode} stdout={verify.Stdout.Trim()} stderr={verify.Stderr.Trim()}");
+
+ if (verify.Stdout.Contains("USER_MISSING", StringComparison.Ordinal))
+ return StepResult.Terminal($"User '{wsl.User}' does not exist in distro '{distro}'");
+
+ if (verify.Stdout.Contains("DIR_MISSING:", StringComparison.Ordinal))
+ {
+ var line = verify.Stdout.Split('\n').FirstOrDefault(l => l.Contains("DIR_MISSING:")) ?? "";
+ var dir = line.Trim().Split(':', 2).ElementAtOrDefault(1)?.Trim() ?? "unknown";
+ return StepResult.Terminal($"Required directory missing: {dir}");
+ }
+
+ if (verify.Stdout.Contains("OWNER_MISMATCH:", StringComparison.Ordinal))
+ {
+ var line = verify.Stdout.Split('\n').FirstOrDefault(l => l.Contains("OWNER_MISMATCH:")) ?? "";
+ var parts = line.Trim().Split(':');
+ return StepResult.Terminal($"Directory {parts.ElementAtOrDefault(1)} owned by '{parts.ElementAtOrDefault(2)}', expected '{wsl.User}'");
+ }
+
+ if (!verify.Stdout.Contains("LOCKDOWN_VALID", StringComparison.Ordinal))
+ {
+ var detail = string.IsNullOrWhiteSpace(verify.Stderr) ? verify.Stdout.Trim() : verify.Stderr.Trim();
+ return StepResult.Terminal($"WSL lockdown validation failed: {detail}");
+ }
+
+ if (!string.IsNullOrEmpty(wsl.Memory))
+ ctx.Logger.Warn($"Wsl.Memory='{wsl.Memory}' is set but requires host-level .wslconfig, not per-distro wsl.conf");
+ if (!string.IsNullOrEmpty(wsl.Swap))
+ ctx.Logger.Warn($"Wsl.Swap='{wsl.Swap}' is set but requires host-level .wslconfig, not per-distro wsl.conf");
+
+ ctx.Logger.Info("WSL lockdown validated: all invariants verified");
+ return StepResult.Ok("WSL lockdown validated");
+ }
+
+ internal static List ValidateWslConf(string conf, WslConfig wsl)
+ {
+ var values = ParseWslConf(conf);
+ var errors = new List();
+
+ ValidateConfValue(values, "boot", "systemd", wsl.Systemd, errors);
+ ValidateConfValue(values, "interop", "enabled", wsl.Interop, errors);
+ ValidateConfValue(values, "interop", "appendWindowsPath", wsl.AppendWindowsPath, errors);
+ ValidateConfValue(values, "automount", "enabled", wsl.Automount, errors);
+ ValidateConfValue(values, "automount", "mountFsTab", wsl.MountFsTab, errors);
+ ValidateConfValue(values, "user", "default", wsl.User, errors);
+
+ return errors;
+ }
+
+ private static Dictionary> ParseWslConf(string conf)
+ {
+ var values = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ string? currentSection = null;
+
+ using var reader = new StringReader(conf);
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ var trimmed = line.Trim();
+ if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#') || trimmed.StartsWith(';'))
+ continue;
+
+ if (trimmed.StartsWith('[') && trimmed.EndsWith(']'))
+ {
+ currentSection = trimmed[1..^1].Trim();
+ if (!values.ContainsKey(currentSection))
+ values[currentSection] = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ continue;
+ }
+
+ if (currentSection is null)
+ continue;
+
+ var separator = trimmed.IndexOf('=');
+ if (separator <= 0)
+ continue;
+
+ var key = trimmed[..separator].Trim();
+ var value = trimmed[(separator + 1)..].Trim();
+ values[currentSection][key] = value;
+ }
+
+ return values;
+ }
+
+ private static void ValidateConfValue(Dictionary> conf, string section, string key, bool expected, List errors) =>
+ ValidateConfValue(conf, section, key, expected.ToString().ToLowerInvariant(), errors);
+
+ private static void ValidateConfValue(Dictionary> conf, string section, string key, string expected, List errors)
+ {
+ if (!conf.TryGetValue(section, out var sectionValues) ||
+ !sectionValues.TryGetValue(key, out var actual) ||
+ !string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add($"Expected [{section}] {key}={expected} in wsl.conf");
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// GATEWAY INSTALL STEPS
+// ═══════════════════════════════════════════════════════════════════
+
+public sealed class InstallCliStep : SetupStep
+{
+ public override string Id => "install-cli";
+ public override string DisplayName => "Install OpenClaw CLI";
+ public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(5));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var user = ctx.Config.Wsl.User;
+
+ // Download and run install script (URL configurable)
+ var installUrl = ctx.Config.Gateway.InstallUrl ?? "https://openclaw.ai/install-cli.sh";
+
+ // Validate URL is HTTPS to prevent downgrade attacks
+ if (!Uri.TryCreate(installUrl, UriKind.Absolute, out var parsedUrl) ||
+ !string.Equals(parsedUrl.Scheme, "https", StringComparison.OrdinalIgnoreCase))
+ {
+ return StepResult.Fail($"Installer URL must be HTTPS: {installUrl}");
+ }
+
+ // Shell-quote the URL and enforce TLS
+ var escapedUrl = installUrl.Replace("'", "'\\''");
+ var installScript = $"curl -fsSL --proto '=https' --tlsv1.2 '{escapedUrl}' | bash";
+ var result = await ctx.Commands.RunInWslAsync(distro, installScript, TimeSpan.FromMinutes(5), ct: ct);
+
+ if (result.ExitCode != 0)
+ return StepResult.Fail($"CLI install failed (exit {result.ExitCode}): {result.Stderr}");
+
+ var verifyCommands = new (string Command, string? ExecutablePath)[]
+ {
+ ("openclaw --version", null),
+ ($"/home/{user}/.openclaw/bin/openclaw --version", $"/home/{user}/.openclaw/bin/openclaw"),
+ ("/opt/openclaw/bin/openclaw --version", "/opt/openclaw/bin/openclaw"),
+ ("/usr/local/bin/openclaw --version", "/usr/local/bin/openclaw")
+ };
+
+ foreach (var (cmd, executablePath) in verifyCommands)
+ {
+ var verify = await ctx.Commands.RunInWslAsync(distro, cmd, TimeSpan.FromSeconds(15), ct: ct);
+ if (verify.ExitCode == 0 && !string.IsNullOrWhiteSpace(verify.Stdout))
+ {
+ if (executablePath != null)
+ {
+ var pathResult = await EnsureCliOnDefaultPathAsync(ctx, distro, executablePath, ct);
+ if (!pathResult.IsSuccess)
+ return pathResult;
+ }
+
+ ctx.Logger.Info($"OpenClaw CLI version: {verify.Stdout.Trim()}");
+ return StepResult.Ok($"CLI installed: {verify.Stdout.Trim()}");
+ }
+ }
+
+ return StepResult.Fail("CLI installed but not found in any known location");
+ }
+
+ private static async Task EnsureCliOnDefaultPathAsync(
+ SetupContext ctx,
+ string distro,
+ string executablePath,
+ CancellationToken ct)
+ {
+ var user = ctx.Config.Wsl.User;
+
+ if (!executablePath.StartsWith("/", StringComparison.Ordinal) ||
+ executablePath.Contains('\'') ||
+ executablePath.Contains('\n'))
+ {
+ return StepResult.Fail($"Refusing to create openclaw PATH symlink for unexpected install path: {executablePath}");
+ }
+
+ if (!string.Equals(executablePath, "/usr/local/bin/openclaw", StringComparison.Ordinal))
+ {
+ var linkCommand = $"""
+ set -e
+ ln -sfn {executablePath} /usr/local/bin/openclaw
+ echo OPENCLAW_PATH_READY
+ """;
+
+ var link = await ctx.Commands.RunInWslAsync(
+ distro,
+ linkCommand,
+ TimeSpan.FromSeconds(15),
+ ct: ct,
+ user: "root");
+
+ if (link.ExitCode != 0 || !link.Stdout.Contains("OPENCLAW_PATH_READY", StringComparison.Ordinal))
+ return StepResult.Fail($"Failed to make openclaw available on default PATH: {link.Stderr}");
+ }
+
+ var bareVerify = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"env -i HOME=/home/{user} USER={user} PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin openclaw --version",
+ TimeSpan.FromSeconds(15),
+ ct: ct);
+
+ if (bareVerify.ExitCode != 0 || string.IsNullOrWhiteSpace(bareVerify.Stdout))
+ return StepResult.Fail($"openclaw PATH symlink verification failed: {bareVerify.Stderr}");
+
+ ctx.Logger.Info($"OpenClaw CLI available on default PATH: {bareVerify.Stdout.Trim()}");
+ return StepResult.Ok();
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var user = ctx.Config.Wsl.User;
+ await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"rm -rf /opt/openclaw /home/{user}/.openclaw /usr/local/bin/openclaw", TimeSpan.FromSeconds(30), ct: ct, user: "root");
+ }
+}
+
+public sealed class ConfigureGatewayStep : SetupStep
+{
+ internal const string DevicePairPublicUrlKey = "plugins.entries.device-pair.config.publicUrl";
+
+ public override string Id => "configure-gateway";
+ public override string DisplayName => "Configure gateway";
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var port = ctx.Config.GatewayPort;
+ var gw = ctx.Config.Gateway;
+
+ // Validate bind value — only "loopback" and "lan" are accepted
+ if (gw.Bind is not ("loopback" or "lan"))
+ return StepResult.Terminal($"Invalid Gateway.Bind value '{gw.Bind}'. Must be 'loopback' or 'lan'.");
+
+ // Generate a shared gateway token
+ var token = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N");
+ ctx.SharedGatewayToken = token;
+ var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token };
+
+ var allowedCommandsJson = JsonSerializer.Serialize(ctx.Config.Capabilities.GetEnabledCommandIds());
+ var escapedAllowedCommands = ShellEscape(allowedCommandsJson);
+ var extraConfigOverridesAllowCommands = gw.ExtraConfig?.ContainsKey("gateway.nodes.allowCommands") == true;
+ if (gw.ExtraConfig is { Count: > 0 })
+ {
+ foreach (var key in gw.ExtraConfig.Keys)
+ {
+ if (!IsSafeExtraConfigKey(key))
+ return StepResult.Fail($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.");
+ }
+ }
+
+ var configCommands = BuildConfigCommands(gw, port, escapedAllowedCommands);
+
+ ctx.Logger.Info($"Gateway node allowCommands derived from setup capabilities: {allowedCommandsJson}");
+ if (extraConfigOverridesAllowCommands)
+ ctx.Logger.Warn("Gateway.ExtraConfig overrides derived gateway.nodes.allowCommands");
+ if (GetDefaultDevicePairPublicUrl(gw, port) is { } defaultPublicUrl &&
+ gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true)
+ {
+ ctx.Logger.Info($"Configured device-pair public URL for loopback gateway: {defaultPublicUrl}");
+ }
+
+ var pathPrefix = ctx.WslPathPrefix;
+ var script = $"""
+ set -e
+ {pathPrefix}
+
+ {configCommands}
+
+ echo "GATEWAY_CONFIGURED"
+ """;
+
+ var result = await ctx.Commands.RunInWslAsync(distro, script, TimeSpan.FromSeconds(30), env, ct);
+
+ if (result.ExitCode != 0 || !result.Stdout.Contains("GATEWAY_CONFIGURED"))
+ return StepResult.Fail($"Gateway configuration failed (exit {result.ExitCode}): {result.Stderr}");
+
+ ctx.Logger.StateChange("shared_gateway_token", null, "[SET]");
+ return StepResult.Ok("Gateway configured");
+ }
+
+ internal static string BuildConfigCommands(GatewayConfig gw, int port, string escapedAllowedCommands)
+ {
+ var configCommands = $"""
+ openclaw config set gateway.mode local
+ openclaw config set gateway.port {port}
+ openclaw config set gateway.bind {gw.Bind}
+ openclaw config set gateway.auth.mode {gw.AuthMode}
+ openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN"
+ openclaw config set gateway.reload.mode {gw.ReloadMode}
+ openclaw config set gateway.nodes.allowCommands {escapedAllowedCommands}
+ """;
+
+ if (GetDefaultDevicePairPublicUrl(gw, port) is { } defaultPublicUrl &&
+ gw.ExtraConfig?.ContainsKey(DevicePairPublicUrlKey) != true)
+ {
+ configCommands += $"\n openclaw config set {DevicePairPublicUrlKey} {ShellEscape(defaultPublicUrl)}";
+ }
+
+ // Apply any extra config key/value pairs from config (shell-escape values)
+ if (gw.ExtraConfig is { Count: > 0 })
+ {
+ foreach (var (key, value) in gw.ExtraConfig)
+ {
+ if (!IsSafeExtraConfigKey(key))
+ throw new ArgumentException($"Invalid Gateway.ExtraConfig key '{key}'. Keys may contain only letters, digits, '.', '_', and '-'.", nameof(gw));
+
+ var escapedValue = ShellEscape(value);
+ configCommands += $"\n openclaw config set {key} {escapedValue}";
+ }
+ }
+
+ return configCommands;
+ }
+
+ internal static string? GetDefaultDevicePairPublicUrl(GatewayConfig gw, int port) =>
+ gw.Bind == "loopback" ? $"http://127.0.0.1:{port}" : null;
+
+ private static string ShellEscape(string value) => "'" + value.Replace("'", "'\\''") + "'";
+
+ internal static bool IsSafeExtraConfigKey(string value)
+ => System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9._-]+$");
+}
+
+public sealed class InstallGatewayServiceStep : SetupStep
+{
+ public override string Id => "install-service";
+ public override string DisplayName => "Install gateway service";
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+
+ var result = await ctx.Commands.RunInWslAsync(
+ distro, $"{ctx.WslPathPrefix} && openclaw gateway install --force", TimeSpan.FromSeconds(60), ct: ct);
+
+ if (result.ExitCode != 0)
+ return StepResult.Fail($"Service install failed (exit {result.ExitCode}): {result.Stderr}");
+
+ return StepResult.Ok("Gateway service installed");
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw gateway uninstall", TimeSpan.FromSeconds(30), ct: ct);
+ }
+}
+
+public sealed class StartGatewayStep : SetupStep
+{
+ public override string Id => "start-gateway";
+ public override string DisplayName => "Start gateway";
+ public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var pathCmd = ctx.WslPathPrefix;
+
+ // Check for port conflicts before starting
+ var portCheck = await ctx.Commands.RunInWslAsync(
+ distro, $"ss -tlnp 2>/dev/null | grep ':{ctx.Config.GatewayPort}\\b' || true",
+ TimeSpan.FromSeconds(10), ct: ct);
+
+ if (!string.IsNullOrWhiteSpace(portCheck.Stdout) && portCheck.Stdout.Contains($":{ctx.Config.GatewayPort}"))
+ {
+ if (!portCheck.Stdout.Contains("openclaw", StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.Logger.Warn($"Port {ctx.Config.GatewayPort} is in use by another process:\n{portCheck.Stdout.Trim()}");
+ return StepResult.Fail(
+ $"Port {ctx.Config.GatewayPort} is already in use by another process. Either stop the conflicting process or change GatewayPort in the setup config.");
+ }
+
+ ctx.Logger.Info($"Port {ctx.Config.GatewayPort} appears to be in use by openclaw — proceeding");
+ }
+
+ // Start the service
+ var start = await ctx.Commands.RunInWslAsync(
+ distro, $"{pathCmd} && openclaw gateway start", TimeSpan.FromSeconds(30), ct: ct);
+
+ if (start.ExitCode != 0)
+ {
+ // Check if systemd start-limit-hit
+ if (start.Stderr.Contains("start-limit", StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.Logger.Warn("Start-limit hit, resetting and retrying");
+ await ctx.Commands.RunInWslAsync(
+ distro,
+ "systemctl --user reset-failed openclaw-gateway.service",
+ TimeSpan.FromSeconds(10),
+ ct: ct);
+ await Task.Delay(2000, ct);
+ start = await ctx.Commands.RunInWslAsync(distro, $"{pathCmd} && openclaw gateway start", TimeSpan.FromSeconds(30), ct: ct);
+ if (start.ExitCode != 0)
+ return StepResult.Fail($"Gateway start failed after reset: {start.Stderr}");
+ }
+ else
+ {
+ return StepResult.Fail($"Gateway start failed (exit {start.ExitCode}): {start.Stderr}");
+ }
+ }
+
+ // Wait for health endpoint
+ ctx.Logger.Info("Waiting for gateway health endpoint...");
+ var healthDeadline = DateTimeOffset.UtcNow.Add(TimeSpan.FromSeconds(ctx.Config.Gateway.HealthTimeoutSeconds));
+
+ while (DateTimeOffset.UtcNow < healthDeadline)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var status = await ctx.Commands.RunInWslAsync(
+ distro, "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:" + ctx.Config.GatewayPort + "/ --max-time 3",
+ TimeSpan.FromSeconds(10), ct: ct);
+
+ if (status.ExitCode == 0 && status.Stdout.Trim() is "200" or "401" or "403")
+ {
+ ctx.Logger.Info($"Gateway is accepting connections (HTTP {status.Stdout.Trim()})");
+ return StepResult.Ok("Gateway running");
+ }
+
+ ctx.Logger.Debug($"Gateway not yet accepting connections (curl exit={status.ExitCode}, response={status.Stdout.Trim()})");
+
+ await Task.Delay(2000, ct);
+ }
+
+ // Capture service status and journal for diagnostics
+ var statusResult = await ctx.Commands.RunInWslAsync(
+ distro,
+ "systemctl --user status openclaw-gateway.service 2>&1 || true",
+ TimeSpan.FromSeconds(10),
+ ct: ct);
+
+ var journal = await ctx.Commands.RunInWslAsync(
+ distro,
+ "journalctl --user-unit openclaw-gateway.service --no-pager -n 30 2>&1 || true",
+ TimeSpan.FromSeconds(10),
+ ct: ct);
+
+ var redactedStatus = RedactTokens(statusResult.Stdout);
+ var redactedJournal = RedactTokens(journal.Stdout);
+
+ ctx.Logger.Error($"Gateway health timeout.\nService status:\n{redactedStatus}\nJournal:\n{redactedJournal}");
+
+ return StepResult.Fail($"Gateway did not become healthy within {ctx.Config.Gateway.HealthTimeoutSeconds}s");
+ }
+
+ internal static string RedactTokens(string text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return text;
+
+ return System.Text.RegularExpressions.Regex.Replace(
+ text,
+ @"[0-9a-fA-F]{32,}",
+ m => m.Value[..8] + "…[REDACTED]");
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+
+ // Check if distro is running before trying systemctl stop
+ var list = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--quiet"], TimeSpan.FromSeconds(15), ct: ct);
+ var distros = list.Stdout
+ .Replace("\0", "").Replace("\uFEFF", "")
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .Select(d => d.Trim()).Where(d => d.Length > 0).ToList();
+
+ if (!distros.Any(d => d.Equals(distro, StringComparison.OrdinalIgnoreCase)))
+ {
+ ctx.Logger.Info("[Uninstall] Distro not registered — skipping gateway stop");
+ return;
+ }
+
+ // Check distro state — only stop if Running
+ var verbose = await ctx.Commands.RunAsync(WslConstants.WslExePath, ["--list", "--verbose"], TimeSpan.FromSeconds(15), ct: ct);
+ var isRunning = verbose.Stdout
+ .Replace("\0", "").Replace("\uFEFF", "")
+ .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
+ .Any(line => line.Contains(distro, StringComparison.OrdinalIgnoreCase)
+ && line.Contains("Running", StringComparison.OrdinalIgnoreCase));
+
+ if (!isRunning)
+ {
+ ctx.Logger.Info("[Uninstall] Distro not running — skipping systemctl stop");
+ return;
+ }
+
+ // Stop gateway service with 5-second timeout (mirrors old uninstall step 3)
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ try
+ {
+ await ctx.Commands.RunInWslAsync(
+ distro, "bash -c 'systemctl --user stop openclaw-gateway 2>&1 || true'",
+ TimeSpan.FromSeconds(10), ct: cts.Token);
+ ctx.Logger.Info("[Uninstall] Stopped gateway service");
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ ctx.Logger.Warn("[Uninstall] systemctl stop timed out (5s); distro may be wedged — wsl --unregister will force-terminate");
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// PAIRING STEPS
+// ═══════════════════════════════════════════════════════════════════
+
+public sealed class MintBootstrapTokenStep : SetupStep
+{
+ public override string Id => "mint-token";
+ public override string DisplayName => "Mint bootstrap token";
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+
+ // Token was already set by ConfigureGatewayStep
+ if (string.IsNullOrWhiteSpace(ctx.SharedGatewayToken))
+ return StepResult.Fail("No shared gateway token set by previous step");
+
+ // Mint a bootstrap/QR token
+ var env = new Dictionary
+ {
+ ["OPENCLAW_GATEWAY_TOKEN"] = ctx.SharedGatewayToken
+ };
+
+ var mint = await ctx.Commands.RunInWslAsync(
+ distro, $"{ctx.WslPathPrefix} && openclaw qr --json", TimeSpan.FromSeconds(30), env, ct);
+
+ if (mint.ExitCode == 0 && !string.IsNullOrWhiteSpace(mint.Stdout))
+ {
+ // Parse bootstrap token from JSON output
+ try
+ {
+ if (TryReadBootstrapToken(mint.Stdout.Trim(), out var bootstrapToken, out var source))
+ {
+ ctx.BootstrapToken = bootstrapToken;
+ ctx.Logger.StateChange("bootstrap_token", null, "[SET]");
+ return StepResult.Ok($"Bootstrap token minted from {source}");
+ }
+ }
+ catch (JsonException ex)
+ {
+ ctx.Logger.Warn($"Failed to parse QR JSON: {ex.Message}");
+ }
+ }
+
+ ctx.Logger.Warn("QR/bootstrap token mint failed or did not return a bootstrapToken/setupCode");
+ return StepResult.Fail("Could not mint bootstrap token; refusing to use the shared gateway token as bootstrap.");
+ }
+
+ internal static bool TryReadBootstrapToken(string json, out string? token, out string? source)
+ {
+ using var doc = JsonDocument.Parse(json);
+ foreach (var propertyName in new[] { "bootstrapToken", "setupCode" })
+ {
+ if (doc.RootElement.TryGetProperty(propertyName, out var property) &&
+ property.ValueKind == JsonValueKind.String &&
+ !string.IsNullOrWhiteSpace(property.GetString()))
+ {
+ token = property.GetString();
+ source = propertyName;
+ return true;
+ }
+ }
+
+ token = null;
+ source = null;
+ return false;
+ }
+}
+
+public sealed class PairOperatorStep : SetupStep
+{
+ public override string Id => "pair-operator";
+ public override string DisplayName => "Pair operator connection";
+ public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var gatewayUrl = ctx.GatewayUrl!;
+ var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken;
+
+ if (string.IsNullOrEmpty(token))
+ return StepResult.Terminal("No credential available for operator pairing");
+
+ // Register gateway in registry (only once — reuse across retries)
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+
+ string identityPath;
+ if (!string.IsNullOrEmpty(ctx.GatewayRecordId))
+ {
+ var existing = registry.GetById(ctx.GatewayRecordId);
+ if (existing == null)
+ return StepResult.Fail($"Gateway record {ctx.GatewayRecordId} not found");
+ identityPath = registry.GetIdentityDirectory(existing.Id);
+ ctx.Logger.Info($"Reusing existing gateway record: id={existing.Id}");
+ }
+ else
+ {
+ var record = new GatewayRecord
+ {
+ Id = Guid.NewGuid().ToString("N")[..16],
+ Url = gatewayUrl,
+ FriendlyName = $"Local ({ctx.DistroName})",
+ SharedGatewayToken = ctx.SharedGatewayToken,
+ BootstrapToken = ctx.BootstrapToken,
+ IsLocal = true,
+ SetupManagedDistroName = ctx.DistroName,
+ LastConnected = DateTime.UtcNow
+ };
+
+ record = registry.AddOrUpdate(record);
+ registry.SetActive(record.Id);
+ registry.Save();
+ ctx.GatewayRecordId = record.Id;
+ identityPath = registry.GetIdentityDirectory(record.Id);
+ ctx.Logger.Info($"Gateway record created: id={record.Id}");
+ }
+
+ // Initialize device identity
+ Directory.CreateDirectory(identityPath);
+ var identity = new DeviceIdentity(identityPath);
+ identity.Initialize();
+ ctx.Logger.Info($"Device identity initialized: {identity.DeviceId[..16]}...");
+ ctx.OperatorDeviceId = identity.DeviceId;
+
+ // Connect operator WebSocket — handle pairing-required flow
+ var wsLogger = new SetupOpenClawLogger(ctx.Logger);
+ OpenClawGatewayClient? client = null;
+
+ try
+ {
+ // Phase 1: Initial connect (may get PAIRING_REQUIRED)
+ client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath);
+ client.UseV2Signature = true; // Local gateway uses v2 signature format
+ var phase1Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (phase1Result == ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Operator connected directly (no pairing needed)");
+ return StepResult.Ok("Operator connected and paired");
+ }
+
+ if (phase1Result == ConnectionOutcome.PairingRequired)
+ {
+ if (!ctx.Config.AutoApprovePairing)
+ return StepResult.Fail("Pairing required but auto-approve is disabled");
+
+ ctx.Logger.Info("Pairing required — auto-approving via CLI");
+ var requestId = client.PairingRequiredRequestId;
+ await client.DisconnectAsync();
+ client.Dispose();
+ client = null;
+
+ // Auto-approve the pending pairing request
+ var approveResult = await AutoApprovePairing(ctx, requestId, ct);
+ if (!approveResult.IsSuccess)
+ return approveResult;
+
+ // Wait for gateway to process the approval
+ await Task.Delay(2000, ct);
+
+ // Phase 2: Reconnect — the device should now be approved
+ client = new OpenClawGatewayClient(gatewayUrl, token, logger: wsLogger, identityPath: identityPath);
+ client.UseV2Signature = true;
+ var phase2Result = await WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(20), ct);
+
+ if (phase2Result == ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Operator paired successfully after approval");
+ // Disconnect before finalization
+ await client.DisconnectAsync();
+ client.Dispose();
+ client = null;
+
+ // Phase 3: Skip operator finalization here — it must happen AFTER node pairing.
+ // The node pairing changes the device's "current metadata" to node/node-host,
+ // so operator finalization (as cli/cli) must come last to match what the tray sends.
+ ctx.Logger.Info("Operator paired — finalization deferred to after node pairing");
+ return StepResult.Ok("Operator paired (finalization deferred)");
+ }
+
+ return StepResult.Fail($"Reconnection after approval failed: {phase2Result}");
+ }
+
+ return StepResult.Fail($"Operator connection failed: {phase1Result}");
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail($"Operator pairing failed: {ex.Message}", ex);
+ }
+ finally
+ {
+ if (client != null)
+ {
+ await client.DisconnectAsync();
+ client.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// After initial pairing, the gateway knows us via auth.token (shared gateway token).
+ /// The tray will connect using auth.deviceToken (the token we just received).
+ /// This "finalizes" the transition so the gateway doesn't flag it as metadata-upgrade.
+ ///
+ private static async Task FinalizeWithDeviceToken(
+ SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct)
+ {
+ ctx.Logger.Info("Finalizing: reconnect with device token (like tray will)");
+
+ // Read the device token we just stored
+ var identity = new DeviceIdentity(identityPath);
+ identity.Initialize();
+ var deviceToken = identity.DeviceToken;
+
+ if (string.IsNullOrEmpty(deviceToken))
+ {
+ ctx.Logger.Warn("No device token stored after pairing — skipping finalization");
+ return StepResult.Ok("Operator paired (no finalization needed)");
+ }
+
+ // Wait for the gateway's internal session grace period to expire.
+ // Without this delay, the gateway accepts the deviceToken connect within grace
+ // but would later reject the tray's identical connect as "metadata-upgrade".
+ ctx.Logger.Info("Waiting for gateway grace period to expire before finalization...");
+ await Task.Delay(TimeSpan.FromSeconds(5), ct);
+
+ // Connect exactly as the tray would: pass deviceToken as the credential
+ var finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath);
+ finalClient.UseV2Signature = true;
+
+ try
+ {
+ var result = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (result == ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Finalization connected — tray will connect seamlessly");
+ return StepResult.Ok("Operator paired and finalized for tray");
+ }
+
+ if (result == ConnectionOutcome.PairingRequired)
+ {
+ ctx.Logger.Info("Metadata-upgrade detected during finalization — auto-approving");
+ var requestId = finalClient.PairingRequiredRequestId;
+ await finalClient.DisconnectAsync();
+ finalClient.Dispose();
+ finalClient = null;
+
+ // Approve the metadata-upgrade
+ var approveResult = await AutoApprovePairing(ctx, requestId, ct);
+ if (!approveResult.IsSuccess)
+ return StepResult.Fail($"Finalization approval failed: {approveResult.Message}");
+
+ await Task.Delay(2000, ct);
+
+ // One more connect to confirm
+ finalClient = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath);
+ finalClient.UseV2Signature = true;
+ var finalResult = await WaitForConnectionOrPairing(finalClient, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (finalResult == ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Finalization approved — tray will connect seamlessly");
+ return StepResult.Ok("Operator paired and finalized for tray");
+ }
+
+ return StepResult.Fail($"Finalization failed after approval: {finalResult}");
+ }
+
+ return StepResult.Fail($"Finalization connect failed: {result}");
+ }
+ finally
+ {
+ if (finalClient != null)
+ {
+ await finalClient.DisconnectAsync();
+ finalClient.Dispose();
+ }
+ }
+ }
+
+ internal static async Task AutoApprovePairing(SetupContext ctx, CancellationToken ct)
+ => await AutoApprovePairing(ctx, requestId: null, ct);
+
+ internal static async Task AutoApprovePairing(SetupContext ctx, string? requestId, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve");
+
+ var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token };
+
+ if (string.IsNullOrWhiteSpace(requestId))
+ {
+ var preview = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{ctx.WslPathPrefix} && openclaw devices approve --latest --json""",
+ TimeSpan.FromSeconds(30), env, ct);
+
+ ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}");
+
+ var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim());
+ if (!parsed.Success)
+ {
+ ctx.Logger.Warn($"Could not select pairing request: {parsed.Error}");
+ return StepResult.Fail("Could not find a safe pending pairing request to approve");
+ }
+
+ requestId = parsed.RequestId;
+ }
+
+ if (!ApprovalRequestHelper.IsSafeRequestId(requestId))
+ {
+ ctx.Logger.Warn("Refusing to approve pairing request with unsafe request ID");
+ return StepResult.Fail("Pairing request ID contained unsafe characters");
+ }
+
+ ctx.Logger.Info($"Approving pairing request: {requestId}");
+ var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!);
+
+ var approve = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""",
+ TimeSpan.FromSeconds(30), approvalEnv, ct);
+
+ ctx.Logger.Info($"Approve result: exit={approve.ExitCode}");
+
+ if (approve.ExitCode != 0)
+ return StepResult.Fail($"Device approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}");
+
+ return StepResult.Ok($"Approved request {requestId}");
+ }
+
+ internal enum ConnectionOutcome { Connected, PairingRequired, Error, Timeout }
+
+ internal static async Task WaitForConnectionOrPairing(
+ OpenClawGatewayClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct)
+ {
+ var tcs = new TaskCompletionSource();
+
+ void OnStatusChanged(object? sender, ConnectionStatus status)
+ {
+ ctx.Logger.Debug($"Operator connection status: {status}");
+ if (status == ConnectionStatus.Connected)
+ tcs.TrySetResult(ConnectionOutcome.Connected);
+ else if (status == ConnectionStatus.Error)
+ tcs.TrySetResult(ConnectionOutcome.Error);
+ else if (status == ConnectionStatus.Disconnected)
+ {
+ // Check if pairing was required — client sets IsPairingRequired before disconnect
+ if (client.IsPairingRequired)
+ tcs.TrySetResult(ConnectionOutcome.PairingRequired);
+ else
+ tcs.TrySetResult(ConnectionOutcome.Error);
+ }
+ }
+
+ client.StatusChanged += OnStatusChanged;
+ EventHandler onDeviceToken = (_, _) => ctx.Logger.Info("Device token received from gateway");
+ client.DeviceTokenReceived += onDeviceToken;
+
+ try
+ {
+ await client.ConnectAsync();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeout);
+ return await tcs.Task.WaitAsync(cts.Token);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (OperationCanceledException)
+ {
+ return ConnectionOutcome.Timeout;
+ }
+ catch (Exception ex)
+ {
+ ctx.Logger.Warn($"Operator connection failed: {ex.Message}");
+ return ConnectionOutcome.Error;
+ }
+ finally
+ {
+ client.StatusChanged -= OnStatusChanged;
+ client.DeviceTokenReceived -= onDeviceToken;
+ }
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+
+ // Find all local gateway records to remove (mirrors old uninstall step 6a)
+ var localRecords = registry.GetAll()
+ .Where(r => IsSetupManagedLocalRecord(r, ctx))
+ .ToList();
+
+ if (localRecords.Count > 0)
+ {
+ foreach (var record in localRecords)
+ {
+ // Remove identity directory
+ var identityDir = registry.GetIdentityDirectory(record.Id);
+ if (Directory.Exists(identityDir))
+ {
+ Directory.Delete(identityDir, recursive: true);
+ ctx.Logger.Info($"[Uninstall] Deleted identity directory: {identityDir}");
+ }
+ registry.Remove(record.Id);
+ }
+ registry.Save();
+ ctx.Logger.Info($"[Uninstall] Removed {localRecords.Count} local gateway record(s)");
+ }
+ else
+ {
+ ctx.Logger.Info("[Uninstall] No local gateway records found");
+ }
+
+ // Null operator device token (mirrors old uninstall step 7)
+ // Check if external gateways remain — if so, preserve root device tokens
+ var hasExternalGateways = registry.GetAll().Any(r =>
+ !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url)));
+
+ if (hasExternalGateways)
+ {
+ ctx.Logger.Info("[Uninstall] Preserving root device tokens — external gateway records remain");
+ }
+ else
+ {
+ var operatorCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "operator");
+ ctx.Logger.Info(operatorCleared
+ ? "[Uninstall] Cleared operator device token"
+ : "[Uninstall] Operator device token already absent");
+ }
+
+ // Best-effort revoke operator token via gateway HTTP endpoint (mirrors old step 4)
+ await TryRevokeOperatorTokenAsync(ctx, ct);
+ }
+
+ internal static bool IsSetupManagedLocalRecord(GatewayRecord record, SetupContext ctx)
+ {
+ if (!record.IsLocal || record.SshTunnel != null)
+ return false;
+
+ if (string.Equals(record.SetupManagedDistroName, ctx.DistroName, StringComparison.Ordinal))
+ return true;
+
+ return string.IsNullOrWhiteSpace(record.SetupManagedDistroName)
+ && string.Equals(record.Url, ctx.GatewayUrl, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(record.FriendlyName, $"Local ({ctx.DistroName})", StringComparison.Ordinal);
+ }
+
+ private static async Task TryRevokeOperatorTokenAsync(SetupContext ctx, CancellationToken ct)
+ {
+ try
+ {
+ // Read settings.json for legacy token if available
+ var settingsPath = Path.Combine(ctx.DataDir, "settings.json");
+ if (!File.Exists(settingsPath)) return;
+
+ var settingsJson = await File.ReadAllTextAsync(settingsPath, ct);
+ using var doc = JsonDocument.Parse(settingsJson);
+
+ string? token = null;
+ if (doc.RootElement.TryGetProperty("Token", out var tokenProp))
+ token = tokenProp.GetString();
+
+ if (string.IsNullOrWhiteSpace(token)) return;
+
+ var gatewayUrl = ctx.GatewayUrl ?? "ws://localhost:18789";
+ var httpBase = gatewayUrl
+ .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase)
+ .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase)
+ .TrimEnd('/');
+
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ var response = await http.PostAsync($"{httpBase}/api/v1/operator/disconnect", content: null, cts.Token);
+ ctx.Logger.Info($"[Uninstall] Revoke operator token: HTTP {(int)response.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ ctx.Logger.Info($"[Uninstall] Best-effort token revoke failed ({ex.GetType().Name}); gateway may be down");
+ }
+ }
+}
+
+public sealed class PairNodeStep : SetupStep
+{
+ public override string Id => "pair-node";
+ public override string DisplayName => "Pair node connection";
+ public override RetryPolicy Retry => new(MaxAttempts: 3, InitialDelay: TimeSpan.FromSeconds(3));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var gatewayUrl = ctx.GatewayUrl!;
+ var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken;
+
+ if (string.IsNullOrEmpty(token))
+ return StepResult.Terminal("No credential available for node pairing");
+
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+ var record = registry.GetById(ctx.GatewayRecordId!);
+ if (record == null)
+ return StepResult.Fail("Gateway record not found in registry");
+
+ var identityPath = registry.GetIdentityDirectory(record.Id);
+
+ // Verify gateway is reachable before connecting
+ try
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ var resp = await http.GetAsync($"http://localhost:{ctx.Config.GatewayPort}/", ct);
+ ctx.Logger.Debug($"Gateway health check: HTTP {(int)resp.StatusCode}");
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail($"Gateway not reachable before node pairing: {ex.Message}");
+ }
+
+ var wsLogger = new SetupOpenClawLogger(ctx.Logger);
+ WindowsNodeClient? client = null;
+
+ try
+ {
+ // Phase 1: Connect (may get PAIRING_REQUIRED)
+ client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger);
+ client.UseV2Signature = true;
+
+ // Register capabilities BEFORE connect — gateway stores them from hello message
+ RegisterCapabilitiesFromConfig(client, ctx);
+
+ var outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (outcome.Outcome == NodeConnectionOutcome.Connected)
+ {
+ ctx.NodeDeviceId = client.ShortDeviceId;
+ ctx.Logger.Info($"Node connected directly: {ctx.NodeDeviceId}");
+ return StepResult.Ok("Node connected and paired");
+ }
+
+ if (outcome.Outcome == NodeConnectionOutcome.PairingRequired)
+ {
+ if (!ctx.Config.AutoApprovePairing)
+ return StepResult.Fail("Node pairing required but auto-approve is disabled");
+
+ ctx.Logger.Info("Node pairing required — auto-approving via CLI");
+ await client.DisconnectAsync();
+ client.Dispose();
+ client = null;
+
+ var approveResult = await AutoApproveNodePairing(ctx, outcome.RequestId, ct);
+ if (!approveResult.IsSuccess)
+ return approveResult;
+
+ await Task.Delay(2000, ct);
+
+ // Phase 2: Reconnect after approval
+ client = new WindowsNodeClient(gatewayUrl, token, identityPath, logger: wsLogger);
+ client.UseV2Signature = true;
+ RegisterCapabilitiesFromConfig(client, ctx);
+
+ outcome = await WaitForNodeConnection(client, ctx, TimeSpan.FromSeconds(20), ct);
+ if (outcome.Outcome == NodeConnectionOutcome.Connected)
+ {
+ ctx.NodeDeviceId = client.ShortDeviceId;
+ ctx.Logger.Info($"Node paired after approval: {ctx.NodeDeviceId}");
+ await client.DisconnectAsync();
+ client.Dispose();
+ client = null;
+
+ // Skip node finalization — the operator finalization in VerifyEndToEndStep
+ // will be the last connect, ensuring operator metadata is "current".
+ // Node finalization would rotate tokens and potentially invalidate the operator token.
+ ctx.Logger.Info("Node paired — skipping node finalization (operator finalization is last)");
+ return StepResult.Ok("Node paired successfully");
+ }
+
+ return StepResult.Fail($"Node reconnection after approval failed: {outcome.Outcome}");
+ }
+
+ return StepResult.Fail($"Node connection failed: {outcome.Outcome}");
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail($"Node pairing failed: {ex.Message}", ex);
+ }
+ finally
+ {
+ if (client != null)
+ {
+ await client.DisconnectAsync();
+ client.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// After node pairing, finalize by connecting with the node device token to avoid
+ /// metadata-upgrade when the tray reconnects.
+ ///
+ private static async Task FinalizeNodeWithDeviceToken(
+ SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct)
+ {
+ ctx.Logger.Info("Finalizing node: reconnect with node device token");
+
+ var identity = new DeviceIdentity(identityPath);
+ identity.Initialize();
+ var nodeToken = identity.NodeDeviceToken;
+
+ if (string.IsNullOrEmpty(nodeToken))
+ {
+ ctx.Logger.Warn("No node device token stored after pairing — skipping node finalization");
+ return StepResult.Ok("Node paired (no finalization needed)");
+ }
+
+ // Wait for grace period (same as operator finalization)
+ ctx.Logger.Info("Waiting for gateway grace period before node finalization...");
+ await Task.Delay(TimeSpan.FromSeconds(5), ct);
+
+ var finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger);
+ finalClient.UseV2Signature = true;
+
+ try
+ {
+ var result = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (result.Outcome == NodeConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Node finalization connected — tray will connect seamlessly");
+ return StepResult.Ok("Node paired and finalized for tray");
+ }
+
+ if (result.Outcome == NodeConnectionOutcome.PairingRequired)
+ {
+ ctx.Logger.Info("Node metadata-upgrade detected — auto-approving");
+ await finalClient.DisconnectAsync();
+ finalClient.Dispose();
+ finalClient = null;
+
+ var approveResult = await AutoApproveNodePairing(ctx, result.RequestId, ct);
+ if (!approveResult.IsSuccess)
+ return StepResult.Fail($"Node finalization approval failed: {approveResult.Message}");
+
+ await Task.Delay(2000, ct);
+
+ finalClient = new WindowsNodeClient(gatewayUrl, nodeToken, identityPath, logger: wsLogger);
+ finalClient.UseV2Signature = true;
+ var finalResult = await WaitForNodeConnection(finalClient, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (finalResult.Outcome == NodeConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Node finalization approved — tray will connect seamlessly");
+ return StepResult.Ok("Node paired and finalized for tray");
+ }
+
+ return StepResult.Fail($"Node finalization failed after approval: {finalResult.Outcome}");
+ }
+
+ return StepResult.Fail($"Node finalization failed: {result.Outcome}");
+ }
+ finally
+ {
+ if (finalClient != null)
+ {
+ await finalClient.DisconnectAsync();
+ finalClient.Dispose();
+ }
+ }
+ }
+
+ private enum NodeConnectionOutcome { Connected, PairingRequired, Error, Timeout }
+
+ private sealed record NodeConnectionResult(NodeConnectionOutcome Outcome, string? RequestId = null);
+
+ private static async Task WaitForNodeConnection(
+ WindowsNodeClient client, SetupContext ctx, TimeSpan timeout, CancellationToken ct)
+ {
+ var tcs = new TaskCompletionSource();
+ string? pairingRequestId = null;
+
+ void OnStatusChanged(object? sender, ConnectionStatus status)
+ {
+ ctx.Logger.Debug($"Node connection status: {status}");
+ if (status == ConnectionStatus.Connected)
+ tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Connected));
+ else if (status == ConnectionStatus.Error)
+ tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error));
+ else if (status == ConnectionStatus.Disconnected)
+ {
+ if (client.IsPendingApproval)
+ tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.PairingRequired, pairingRequestId));
+ else
+ tcs.TrySetResult(new NodeConnectionResult(NodeConnectionOutcome.Error));
+ }
+ }
+
+ void OnPairingStatusChanged(object? sender, PairingStatusEventArgs args)
+ {
+ if (args.Status == PairingStatus.Pending && ApprovalRequestHelper.IsSafeRequestId(args.RequestId))
+ pairingRequestId = args.RequestId;
+ }
+
+ client.StatusChanged += OnStatusChanged;
+ client.PairingStatusChanged += OnPairingStatusChanged;
+
+ try
+ {
+ await client.ConnectAsync();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeout);
+ return await tcs.Task.WaitAsync(cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ return new NodeConnectionResult(NodeConnectionOutcome.Timeout);
+ }
+ finally
+ {
+ client.StatusChanged -= OnStatusChanged;
+ client.PairingStatusChanged -= OnPairingStatusChanged;
+ }
+ }
+
+ internal static async Task AutoApproveNodePairing(SetupContext ctx, string? requestId, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken ?? throw new InvalidOperationException("No gateway token available for auto-approve");
+
+ var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token };
+ var approvalKind = ApprovalRequestKind.Device;
+
+ if (string.IsNullOrWhiteSpace(requestId))
+ {
+ approvalKind = ApprovalRequestKind.Node;
+ var pending = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{ctx.WslPathPrefix} && openclaw nodes list --json""",
+ TimeSpan.FromSeconds(30), env, ct);
+
+ ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}");
+
+ if (pending.ExitCode != 0)
+ return StepResult.Fail($"Could not list pending node pairing requests (exit {pending.ExitCode}): {pending.Stdout.Trim()}");
+
+ var parsed = ApprovalRequestHelper.TryReadSinglePendingRequestId(pending.Stdout.Trim());
+ if (!parsed.Success)
+ {
+ ctx.Logger.Warn($"Could not select node pairing request: {parsed.Error}");
+ return StepResult.Fail(parsed.Error ?? "Could not find a safe pending node pairing request");
+ }
+
+ requestId = parsed.RequestId;
+ }
+
+ if (!ApprovalRequestHelper.IsSafeRequestId(requestId))
+ return StepResult.Fail("Node pairing request ID contained unsafe characters");
+
+ ctx.Logger.Info($"Approving node pairing request: {requestId}");
+ var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId!);
+
+ var approve = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(approvalKind)}""",
+ TimeSpan.FromSeconds(30), approvalEnv, ct);
+
+ ctx.Logger.Info($"Node approve result: exit={approve.ExitCode}");
+
+ return approve.ExitCode == 0
+ ? StepResult.Ok($"Node approved: {requestId}")
+ : StepResult.Fail($"Node approval failed (exit {approve.ExitCode}): {approve.Stdout.Trim()}");
+ }
+
+ private static void RegisterCapabilitiesFromConfig(WindowsNodeClient client, SetupContext ctx)
+ {
+ var capabilities = ctx.Config.Capabilities.GetEnabledCapabilities();
+ foreach (var (category, commands) in capabilities)
+ {
+ client.RegisterCapability(new StubNodeCapability(category, commands));
+ }
+ if (ctx.Config.Settings.NodeCameraEnabled && ctx.Config.Capabilities.Camera)
+ client.SetPermission("camera.capture", true);
+ if (ctx.Config.Settings.NodeScreenEnabled && ctx.Config.Capabilities.Screen)
+ client.SetPermission("screen.record", true);
+
+ ctx.Logger.Info($"Registered {capabilities.Count} capability categories with {capabilities.Sum(c => c.Commands.Length)} total commands");
+ }
+
+ public override Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ // Null node device token (mirrors old uninstall step 7 for node role)
+ // Only clear if no external gateways remain (same logic as PairOperatorStep)
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+ var hasExternalGateways = registry.GetAll().Any(r =>
+ !r.IsLocal && !(r.SshTunnel is null && LocalGatewayUrlClassifier.IsLocalGatewayUrl(r.Url)));
+
+ if (hasExternalGateways)
+ {
+ ctx.Logger.Info("[Uninstall] Preserving node device token — external gateway records remain");
+ }
+ else
+ {
+ var nodeCleared = DeviceIdentity.TryClearDeviceTokenForRole(ctx.DataDir, "node");
+ ctx.Logger.Info(nodeCleared
+ ? "[Uninstall] Cleared node device token"
+ : "[Uninstall] Node device token already absent");
+ }
+
+ return Task.CompletedTask;
+ }
+}
+
+public sealed class VerifyEndToEndStep : SetupStep
+{
+ public override string Id => "verify-e2e";
+ public override string DisplayName => "Verify end-to-end connectivity";
+ public override RetryPolicy Retry => new(MaxAttempts: 2, InitialDelay: TimeSpan.FromSeconds(3));
+
+ public override async Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ // Verify gateway is still healthy
+ var distro = ctx.DistroName!;
+ var status = await ctx.Commands.RunInWslAsync(
+ distro, $"{ctx.WslPathPrefix} && openclaw gateway status --json", TimeSpan.FromSeconds(15), ct: ct);
+
+ if (status.ExitCode != 0 || !status.Stdout.Contains("running", StringComparison.OrdinalIgnoreCase))
+ return StepResult.Fail("Gateway is not running");
+
+ // Verify registry state
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+ var record = registry.GetById(ctx.GatewayRecordId!);
+ if (record == null)
+ return StepResult.Fail("Gateway record missing from registry");
+
+ var identityPath = registry.GetIdentityDirectory(record.Id);
+ if (!DeviceIdentity.HasStoredDeviceToken(identityPath))
+ {
+ ctx.Logger.Warn("No stored device token found — tray app may need to re-pair");
+ }
+ else
+ {
+ ctx.Logger.Info("Device token present — performing final operator handshake");
+
+ // CRITICAL: The operator finalization must happen AFTER node pairing.
+ // Node pairing changes the device's "current metadata" to node-host/node.
+ // The tray connects as operator (cli/cli), so we must re-establish operator
+ // as the device's last-seen metadata. This prevents "metadata-upgrade" errors.
+ var wsLogger = new SetupOpenClawLogger(ctx.Logger);
+ var finalResult = await FinalizeOperatorForTray(ctx, ctx.GatewayUrl!, identityPath, wsLogger, ct);
+ if (!finalResult.IsSuccess)
+ return finalResult;
+ }
+
+ // Write setup-state.json so tray knows the distro name for WSL keepalive
+ await WriteSetupStateAsync(ctx, ct);
+
+ // Write settings.json with EnableNodeMode + capability toggles from config
+ WriteSettingsJson(ctx);
+
+ // Drain any remaining pending approvals (device or node) so tray starts clean
+ var drainResult = await DrainPendingApprovalsAsync(ctx, ct);
+ if (!drainResult.IsSuccess)
+ return drainResult;
+
+ ClearPersistedBootstrapCredentials(ctx);
+
+ return StepResult.Ok("Gateway running; operator finalized; settings written for tray.");
+ }
+
+ private static async Task DrainPendingApprovalsAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ var token = ctx.SharedGatewayToken ?? ctx.BootstrapToken;
+ if (string.IsNullOrWhiteSpace(token))
+ return StepResult.Fail("No gateway token available to drain pending approvals");
+
+ var pathPrefix = ctx.WslPathPrefix;
+ var env = new Dictionary { ["OPENCLAW_GATEWAY_TOKEN"] = token };
+ const int maxDrainIterations = 10;
+
+ for (var i = 0; i < maxDrainIterations; i++)
+ {
+ var preview = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{pathPrefix} && openclaw devices approve --latest --json""",
+ TimeSpan.FromSeconds(15), env, ct);
+
+ if (preview.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) ||
+ preview.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+
+ var parsed = ApprovalRequestHelper.TryReadSelectedRequestId(preview.Stdout.Trim());
+ if (parsed.Success)
+ {
+ ctx.Logger.Info($"Draining pending device approval: {parsed.RequestId}");
+ var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, parsed.RequestId!);
+ var approve = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""",
+ TimeSpan.FromSeconds(15), approvalEnv, ct);
+
+ if (approve.ExitCode != 0)
+ return StepResult.Fail($"Device approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim());
+
+ if (i == maxDrainIterations - 1)
+ return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain");
+
+ continue;
+ }
+
+ if (preview.ExitCode == 0)
+ {
+ var approved = ApprovalRequestHelper.TryReadApprovedRequestId(preview.Stdout.Trim());
+ if (approved.Success)
+ {
+ ctx.Logger.Info($"Drained pending device approval via latest command: {approved.RequestId}");
+ if (i == maxDrainIterations - 1)
+ return StepResult.Fail("Device approval drain reached its iteration limit; pending approvals may remain");
+
+ continue;
+ }
+ }
+
+ return StepResult.Fail($"Could not select pending device approval for drain (exit {preview.ExitCode}): {parsed.Error ?? preview.Stderr.Trim()}");
+ }
+
+ for (var i = 0; i < maxDrainIterations; i++)
+ {
+ var nodeList = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{pathPrefix} && openclaw nodes list --json""",
+ TimeSpan.FromSeconds(15), env, ct);
+
+ var parsed = ApprovalRequestHelper.TryReadPendingRequestIds(nodeList.Stdout.Trim());
+ if (!parsed.Success)
+ {
+ if (nodeList.ExitCode != 0)
+ return StepResult.Fail($"Could not list pending node approvals (exit {nodeList.ExitCode}): {nodeList.Stdout.Trim()} {nodeList.Stderr.Trim()}".Trim());
+
+ return StepResult.Fail($"Could not parse pending node approvals: {parsed.Error}");
+ }
+
+ if (parsed.RequestIds.Count == 0)
+ break;
+
+ foreach (var requestId in parsed.RequestIds)
+ {
+ ctx.Logger.Info($"Draining pending node approval: {requestId}");
+ var approvalEnv = ApprovalRequestHelper.AddRequestIdEnvironment(env, requestId);
+ var approve = await ctx.Commands.RunInWslAsync(
+ distro,
+ $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""",
+ TimeSpan.FromSeconds(15), approvalEnv, ct);
+
+ if (approve.ExitCode != 0)
+ return StepResult.Fail($"Node approval drain failed for {requestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim());
+ }
+
+ if (i == maxDrainIterations - 1)
+ return StepResult.Fail("Node approval drain reached its iteration limit; pending approvals may remain");
+ }
+
+ return StepResult.Ok("Pending approvals drained");
+ }
+
+ private static void WriteSettingsJson(SetupContext ctx)
+ {
+ var settingsPath = Path.Combine(ctx.DataDir, "settings.json");
+ ctx.Config.Settings.MergeIntoSettingsFile(settingsPath);
+ ctx.Logger.Info($"Wrote settings.json: EnableNodeMode={ctx.Config.Settings.EnableNodeMode}");
+ }
+
+ private static void ClearPersistedBootstrapCredentials(SetupContext ctx)
+ {
+ if (string.IsNullOrWhiteSpace(ctx.GatewayRecordId))
+ return;
+
+ var registry = new GatewayRegistry(ctx.DataDir);
+ registry.Load();
+ var record = registry.GetById(ctx.GatewayRecordId);
+ if (record is null)
+ return;
+
+ if (string.IsNullOrWhiteSpace(record.BootstrapToken))
+ {
+ return;
+ }
+
+ registry.AddOrUpdate(record with
+ {
+ BootstrapToken = null
+ });
+ registry.Save();
+ ctx.Logger.Info("Cleared persisted bootstrap gateway credential after device pairing");
+ }
+
+ ///
+ /// Final operator connect using device token — establishes operator/cli/cli as the
+ /// device's "current metadata" so the tray can connect without metadata-upgrade.
+ ///
+ private static async Task FinalizeOperatorForTray(
+ SetupContext ctx, string gatewayUrl, string identityPath, IOpenClawLogger wsLogger, CancellationToken ct)
+ {
+ var identity = new DeviceIdentity(identityPath);
+ identity.Initialize();
+ var deviceToken = identity.DeviceToken;
+
+ if (string.IsNullOrEmpty(deviceToken))
+ return StepResult.Fail("No device token available for operator finalization");
+
+ // Wait for grace period to expire so this connect is treated as a real metadata change
+ ctx.Logger.Info("Waiting for grace period before final operator handshake...");
+ await Task.Delay(TimeSpan.FromSeconds(5), ct);
+
+ var client = new OpenClawGatewayClient(gatewayUrl, deviceToken, logger: wsLogger, identityPath: identityPath);
+ client.UseV2Signature = true;
+
+ try
+ {
+ var result = await PairOperatorStep.WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (result == PairOperatorStep.ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Final operator handshake succeeded — tray will connect seamlessly");
+ return StepResult.Ok("Operator finalized");
+ }
+
+ if (result == PairOperatorStep.ConnectionOutcome.PairingRequired)
+ {
+ ctx.Logger.Info("Metadata-upgrade detected — auto-approving for tray");
+ await client.DisconnectAsync();
+ client.Dispose();
+ client = null;
+
+ var approveResult = await PairOperatorStep.AutoApprovePairing(ctx, ct);
+ if (!approveResult.IsSuccess)
+ return StepResult.Fail($"Operator finalization approval failed: {approveResult.Message}");
+
+ await Task.Delay(2000, ct);
+
+ // After approval, the gateway rotates the device token. The old one is invalid.
+ // Clear the stale DeviceToken from the identity file so the client doesn't
+ // try to use it (OpenClawGatewayClient prefers stored DeviceToken over constructor token).
+ ctx.Logger.Info("Clearing stale operator device token from identity file");
+ DeviceIdentity.TryClearDeviceToken(identityPath);
+
+ // Reconnect with the SHARED GATEWAY TOKEN to get a fresh device token.
+ ctx.Logger.Info("Reconnecting with shared token to get fresh device token after approval");
+ client = new OpenClawGatewayClient(gatewayUrl, ctx.SharedGatewayToken!, logger: wsLogger, identityPath: identityPath);
+ client.UseV2Signature = true;
+ var confirmResult = await PairOperatorStep.WaitForConnectionOrPairing(client, ctx, TimeSpan.FromSeconds(15), ct);
+
+ if (confirmResult == PairOperatorStep.ConnectionOutcome.Connected)
+ {
+ ctx.Logger.Info("Operator finalization approved — fresh device token stored, tray will connect seamlessly");
+ return StepResult.Ok("Operator finalized after approval");
+ }
+
+ return StepResult.Fail($"Operator finalization failed after approval: {confirmResult}");
+ }
+
+ return StepResult.Fail($"Operator finalization failed: {result}");
+ }
+ finally
+ {
+ if (client != null)
+ {
+ await client.DisconnectAsync();
+ client.Dispose();
+ }
+ }
+ }
+
+ private static async Task WriteSetupStateAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var stateDir = ctx.LocalDataDir;
+ Directory.CreateDirectory(stateDir);
+
+ var statePath = Path.Combine(stateDir, "setup-state.json");
+ // Phase and Status must be integers matching the tray's LocalGatewaySetupPhase/Status enums.
+ // Phase.Complete = 13, Status.Complete = 7
+ var state = new
+ {
+ SchemaVersion = 1,
+ RunId = Guid.NewGuid().ToString("N"),
+ InstallId = GetStableInstallId(ctx),
+ Phase = 13,
+ Status = 7,
+ DistroName = ctx.DistroName,
+ GatewayUrl = ctx.GatewayUrl,
+ IsLocalOnly = true,
+ FailureCode = (string?)null,
+ UserMessage = (string?)null,
+ CreatedAtUtc = DateTimeOffset.UtcNow,
+ UpdatedAtUtc = DateTimeOffset.UtcNow,
+ Issues = Array.Empty(),
+ History = Array.Empty()
+ };
+
+ var json = System.Text.Json.JsonSerializer.Serialize(state, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ await AtomicFile.WriteAllTextAsync(statePath, json, ct);
+ ctx.Logger.Info($"Wrote setup-state.json: DistroName={ctx.DistroName}");
+ }
+
+ private static string GetStableInstallId(SetupContext ctx)
+ => !string.IsNullOrWhiteSpace(ctx.GatewayRecordId)
+ ? $"gateway:{ctx.GatewayRecordId}"
+ : $"distro:{ctx.DistroName}";
+}
+
+// ─── Step 16: Start WSL Keepalive ───
+
+public sealed class StartKeepaliveStep : SetupStep
+{
+ public override string Id => "start-keepalive";
+ public override string DisplayName => "Start WSL keepalive";
+
+ public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName!;
+ ctx.Logger.Info($"Launching persistent keepalive for distro: {distro}");
+
+ var markerPath = GetKeepaliveMarkerPath(ctx);
+ if (TryGetExistingKeepalive(markerPath, distro, out var existingPid))
+ {
+ ctx.Logger.Info($"Keepalive already running for distro '{distro}' (PID {existingPid})");
+ return Task.FromResult(StepResult.Ok("Keepalive already running"));
+ }
+
+ if (File.Exists(markerPath))
+ {
+ try { File.Delete(markerPath); } catch { }
+ }
+
+ // Launch detached keepalive process — keeps the distro alive so port forwarding
+ // remains stable until the tray starts its own keepalive.
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = WslConstants.WslExePath,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ psi.ArgumentList.Add("-d");
+ psi.ArgumentList.Add(distro);
+ psi.ArgumentList.Add("--");
+ psi.ArgumentList.Add("sleep");
+ psi.ArgumentList.Add("infinity");
+
+ var proc = System.Diagnostics.Process.Start(psi);
+ if (proc == null)
+ {
+ ctx.Logger.Warn("Failed to start keepalive process — tray will start its own");
+ return Task.FromResult(StepResult.Ok());
+ }
+
+ ctx.Logger.Info($"Keepalive process started (PID {proc.Id}), distro will stay alive for tray launch");
+
+ // Write keepalive marker so tray doesn't spawn a duplicate
+ WriteKeepaliveMarker(ctx, markerPath, proc.Id);
+
+ return Task.FromResult(StepResult.Ok());
+ }
+
+ private static void WriteKeepaliveMarker(SetupContext ctx, string markerPath, int pid)
+ {
+ var marker = new
+ {
+ DistroName = ctx.DistroName,
+ Pid = pid,
+ StartTimeUtc = DateTimeOffset.UtcNow,
+ ProcessName = "wsl"
+ };
+ var json = System.Text.Json.JsonSerializer.Serialize(marker, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ AtomicFile.WriteAllText(markerPath, json);
+ ctx.Logger.Info($"Wrote keepalive marker: {markerPath}");
+ }
+
+ internal static string GetKeepaliveMarkerPath(SetupContext ctx)
+ => Path.Combine(
+ ctx.LocalDataDir, "wsl-keepalive", $"{ctx.DistroName}.json");
+
+ internal static bool TryGetExistingKeepalive(string markerPath, string distro, out int pid)
+ {
+ pid = 0;
+ if (!File.Exists(markerPath))
+ return false;
+
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(markerPath));
+ if (!doc.RootElement.TryGetProperty("Pid", out var pidElement) || !pidElement.TryGetInt32(out pid))
+ return false;
+
+ var process = System.Diagnostics.Process.GetProcessById(pid);
+ using (process)
+ {
+ if (process.HasExited)
+ return false;
+
+ return IsKeepaliveCommandLine(GetProcessCommandLine(pid), distro);
+ }
+ }
+ catch
+ {
+ pid = 0;
+ return false;
+ }
+ }
+
+ public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var distro = ctx.DistroName;
+ if (string.IsNullOrEmpty(distro))
+ {
+ ctx.Logger.Info("[Uninstall] No distro name — skipping keepalive cleanup");
+ return;
+ }
+
+ // Kill keepalive wsl.exe processes for this distro.
+ // Pattern: wsl.exe -d -- sleep infinity
+ try
+ {
+ var procs = System.Diagnostics.Process.GetProcessesByName("wsl")
+ .Concat(System.Diagnostics.Process.GetProcessesByName("wsl.exe"));
+
+ foreach (var proc in procs)
+ {
+ try
+ {
+ // Read command line via WMI/CIM
+ var cmdLine = GetProcessCommandLine(proc.Id);
+ if (IsKeepaliveCommandLine(cmdLine, distro))
+ {
+ proc.Kill(entireProcessTree: true);
+ proc.WaitForExit(5000);
+ ctx.Logger.Info($"[Uninstall] Killed keepalive process tree PID {proc.Id}");
+ }
+ }
+ catch { /* process may have exited */ }
+ finally { proc.Dispose(); }
+ }
+ }
+ catch (Exception ex)
+ {
+ ctx.Logger.Warn($"[Uninstall] Error enumerating keepalive processes: {ex.Message}");
+ }
+
+ // Delete keepalive marker file
+ var markerPath = GetKeepaliveMarkerPath(ctx);
+ var markerDir = Path.GetDirectoryName(markerPath)!;
+
+ if (File.Exists(markerPath))
+ {
+ File.Delete(markerPath);
+ ctx.Logger.Info($"[Uninstall] Deleted keepalive marker: {markerPath}");
+ }
+
+ // Clean up empty marker directory
+ if (Directory.Exists(markerDir) && !Directory.EnumerateFileSystemEntries(markerDir).Any())
+ {
+ Directory.Delete(markerDir);
+ ctx.Logger.Info("[Uninstall] Deleted empty wsl-keepalive directory");
+ }
+
+ await Task.CompletedTask;
+ }
+
+ internal static bool IsKeepaliveCommandLine(string? commandLine, string distro)
+ {
+ if (string.IsNullOrWhiteSpace(commandLine) || string.IsNullOrWhiteSpace(distro))
+ return false;
+
+ return commandLine.Contains(distro, StringComparison.OrdinalIgnoreCase)
+ && commandLine.Contains("sleep", StringComparison.OrdinalIgnoreCase)
+ && commandLine.Contains("infinity", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string? GetProcessCommandLine(int pid)
+ {
+ try
+ {
+ // Use WMI to get the command line
+ var result = new System.Diagnostics.Process();
+ var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe",
+ $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"")
+ {
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ using var p = System.Diagnostics.Process.Start(psi);
+ if (p == null) return null;
+ var output = p.StandardOutput.ReadToEnd();
+ p.WaitForExit(5000);
+ return output.Trim();
+ }
+ catch { return null; }
+ }
+}
+
+public sealed class RunGatewayWizardStep : SetupStep
+{
+ public override string Id => "run-wizard";
+ public override string DisplayName => "Run gateway wizard";
+ public override bool CanRetry => false;
+
+ public override bool CanSkip(SetupContext ctx) => ctx.Config.SkipWizard;
+
+ public override Task ExecuteAsync(SetupContext ctx, CancellationToken ct)
+ {
+ var runner = new SetupWizardRunner(ctx);
+ return runner.RunAsync(ct);
+ }
+}
diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs
new file mode 100644
index 000000000..0e45820fd
--- /dev/null
+++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs
@@ -0,0 +1,589 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using OpenClaw.Connection;
+using OpenClaw.Shared;
+
+namespace OpenClaw.SetupEngine;
+
+public sealed class SetupWizardRunner
+{
+ private const int MaxWizardSteps = 50;
+ private const int MaxSameStepVisits = 3;
+ private static readonly Regex s_normalizeKeyRegex = new("[^a-z0-9]+", RegexOptions.Compiled);
+ private readonly SetupContext _ctx;
+
+ public SetupWizardRunner(SetupContext ctx)
+ {
+ _ctx = ctx;
+ }
+
+ public async Task RunAsync(CancellationToken ct)
+ {
+ var registry = new GatewayRegistry(_ctx.DataDir);
+ registry.Load();
+
+ var record = !string.IsNullOrWhiteSpace(_ctx.GatewayRecordId)
+ ? registry.GetById(_ctx.GatewayRecordId)
+ : registry.GetActive();
+
+ if (record == null)
+ return StepResult.Fail("Cannot run gateway wizard because no active gateway record was found.");
+
+ var identityPath = registry.GetIdentityDirectory(record.Id);
+ var storedDeviceToken = DeviceIdentity.TryReadStoredDeviceToken(identityPath, new SetupOpenClawLogger(_ctx.Logger));
+ var credential = storedDeviceToken
+ ?? _ctx.SharedGatewayToken
+ ?? record.SharedGatewayToken
+ ?? _ctx.BootstrapToken
+ ?? record.BootstrapToken;
+
+ if (string.IsNullOrWhiteSpace(credential))
+ return StepResult.Fail("Cannot run gateway wizard because no operator credential is available.");
+
+ _ctx.SharedGatewayToken ??= record.SharedGatewayToken;
+ _ctx.BootstrapToken ??= record.BootstrapToken;
+
+ if (string.IsNullOrWhiteSpace(storedDeviceToken)
+ && !string.IsNullOrWhiteSpace(record.SharedGatewayToken)
+ && string.Equals(credential, record.SharedGatewayToken, StringComparison.Ordinal))
+ identityPath = Path.Combine(identityPath, "setup-wizard");
+
+ var wsLogger = new SetupOpenClawLogger(_ctx.Logger);
+ OpenClawGatewayClient? client = null;
+
+ var sessionId = "";
+ var wizardStarted = false;
+ var wizardCompleted = false;
+ var discoveredSteps = new List();
+
+ try
+ {
+ client = CreateWizardClient(credential, identityPath, wsLogger);
+ var connection = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(20), ct);
+ if (connection == PairOperatorStep.ConnectionOutcome.PairingRequired && _ctx.Config.AutoApprovePairing)
+ {
+ _ctx.Logger.Info("Wizard operator pairing required — auto-approving");
+ await client.DisconnectAsync();
+ client.Dispose();
+
+ var approval = await PairOperatorStep.AutoApprovePairing(_ctx, ct);
+ if (!approval.IsSuccess)
+ return approval;
+
+ await Task.Delay(2000, ct);
+ client = CreateWizardClient(credential, identityPath, wsLogger);
+ connection = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(20), ct);
+ }
+
+ if (connection != PairOperatorStep.ConnectionOutcome.Connected)
+ return StepResult.Fail($"Cannot run gateway wizard because operator connection failed: {connection}");
+
+ _ctx.Logger.Info("Starting gateway wizard");
+ var payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000);
+ wizardStarted = true;
+
+ var visits = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var restartAttempts = 0;
+ for (var i = 0; i < MaxWizardSteps; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ var parsed = WizardPayload.Parse(payload);
+ if (parsed.IsDone)
+ {
+ if (!string.IsNullOrWhiteSpace(parsed.Error))
+ {
+ if (IsKnownGatewayFinalizationPromptBug(parsed.Error))
+ {
+ wizardCompleted = true;
+ _ctx.Logger.Warn($"Gateway wizard ended after applying setup but hit known finalization prompt bug: {parsed.Error}");
+ return StepResult.Ok("Gateway wizard completed with non-fatal finalization prompt warning");
+ }
+
+ return StepResult.Fail($"Gateway wizard failed: {parsed.Error}");
+ }
+
+ if (discoveredSteps.Count > 0)
+ WriteAnswerTemplate(discoveredSteps, missingStep: null);
+
+ wizardCompleted = true;
+ _ctx.Logger.Info("Gateway wizard completed");
+ return StepResult.Ok("Gateway wizard completed");
+ }
+
+ if (!string.IsNullOrWhiteSpace(parsed.Error))
+ return StepResult.Fail($"Gateway wizard returned an invalid step: {parsed.Error}");
+
+ if (!string.IsNullOrWhiteSpace(parsed.SessionId))
+ sessionId = parsed.SessionId;
+
+ if (string.IsNullOrWhiteSpace(sessionId))
+ return StepResult.Fail("Gateway wizard did not provide a session id.");
+
+ if (string.IsNullOrWhiteSpace(parsed.StepId))
+ return StepResult.Fail("Gateway wizard step is missing an id.");
+
+ var visitKey = $"{parsed.StepId}:{parsed.StepIndex}";
+ visits.TryGetValue(visitKey, out var visitCount);
+ visits[visitKey] = visitCount + 1;
+ if (visits[visitKey] > MaxSameStepVisits)
+ {
+ var templatePath = WriteAnswerTemplate(discoveredSteps, parsed);
+ return StepResult.Fail($"Gateway wizard repeated step '{parsed.StepId}' too many times. A wizard answer template was written to: {templatePath}");
+ }
+
+ discoveredSteps.Add(WizardTemplateStep.From(parsed));
+ var answerResult = ResolveAnswer(parsed, _ctx.Config.WizardAnswers);
+ if (!answerResult.Success)
+ {
+ var templatePath = WriteAnswerTemplate(discoveredSteps, parsed);
+ return StepResult.Fail($"{answerResult.Error} A wizard answer template was written to: {templatePath}");
+ }
+
+ _ctx.Logger.Info(answerResult.HasAnswer
+ ? $"Wizard step '{parsed.StepId}' ({parsed.StepType}, key={StableAnswerKey(parsed.Title, parsed.Message, parsed.StepId)}) answered with {(parsed.Sensitive ? "[sensitive]" : $"'{answerResult.Answer}'")}"
+ : $"Wizard step '{parsed.StepId}' ({parsed.StepType}) continuing without explicit answer");
+
+ var parameters = answerResult.HasAnswer
+ ? new
+ {
+ sessionId,
+ answer = new
+ {
+ stepId = parsed.StepId,
+ value = AnswerValueForWire(parsed, answerResult.Answer)
+ }
+ }
+ : (object)new { sessionId };
+
+ try
+ {
+ payload = await client.SendWizardRequestAsync("wizard.next", parameters, timeoutMs: TimeoutFor(parsed));
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested && IsRestartLikeWizardDisconnect(ex) && restartAttempts < 2)
+ {
+ restartAttempts++;
+ _ctx.Logger.Warn($"Gateway restarted during wizard; reconnecting and replaying answers (attempt {restartAttempts}/2): {ex.Message}");
+
+ try { await client.DisconnectAsync(); } catch { }
+ client.Dispose();
+
+ await Task.Delay(TimeSpan.FromSeconds(3), ct);
+ client = CreateWizardClient(credential, identityPath, wsLogger);
+ var reconnect = await PairOperatorStep.WaitForConnectionOrPairing(client, _ctx, TimeSpan.FromSeconds(30), ct);
+ if (reconnect != PairOperatorStep.ConnectionOutcome.Connected)
+ return StepResult.Fail($"Gateway wizard reconnect failed after restart: {reconnect}");
+
+ sessionId = "";
+ visits.Clear();
+ discoveredSteps.Clear();
+ payload = await client.SendWizardRequestAsync("wizard.start", timeoutMs: 30_000);
+ }
+ }
+
+ return StepResult.Fail($"Gateway wizard exceeded {MaxWizardSteps} steps.");
+ }
+ catch (OperationCanceledException)
+ {
+ if (client is not null && wizardStarted && !string.IsNullOrWhiteSpace(sessionId))
+ await TryCancelWizardAsync(client, sessionId);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return StepResult.Fail($"Gateway wizard failed: {ex.Message}", ex);
+ }
+ finally
+ {
+ if (client is not null && wizardStarted && !wizardCompleted && !string.IsNullOrWhiteSpace(sessionId))
+ await TryCancelWizardAsync(client, sessionId);
+
+ if (wizardStarted)
+ await TryResetReloadModeAsync();
+
+ if (client != null)
+ {
+ await client.DisconnectAsync();
+ client.Dispose();
+ }
+ }
+ }
+
+ private OpenClawGatewayClient CreateWizardClient(string credential, string identityPath, IOpenClawLogger wsLogger)
+ {
+ return new OpenClawGatewayClient(_ctx.GatewayUrl!, credential, logger: wsLogger, identityPath: identityPath)
+ {
+ UseV2Signature = true
+ };
+ }
+
+ private async Task TryCancelWizardAsync(OpenClawGatewayClient client, string sessionId)
+ {
+ try
+ {
+ _ctx.Logger.Warn("Cancelling gateway wizard session");
+ await client.SendWizardRequestAsync("wizard.cancel", new { sessionId }, timeoutMs: 10_000);
+ }
+ catch (Exception ex)
+ {
+ _ctx.Logger.Warn($"Failed to cancel gateway wizard session: {ex.Message}");
+ }
+ }
+
+ private async Task TryResetReloadModeAsync()
+ {
+ try
+ {
+ var result = await _ctx.Commands.RunInWslAsync(
+ _ctx.DistroName!,
+ $"{_ctx.WslPathPrefix} && openclaw config set gateway.reload.mode hybrid",
+ TimeSpan.FromSeconds(15),
+ ct: CancellationToken.None);
+
+ if (result.ExitCode == 0)
+ _ctx.Logger.Info("Reset gateway.reload.mode to hybrid after wizard");
+ else
+ _ctx.Logger.Warn($"Failed to reset gateway.reload.mode after wizard (exit {result.ExitCode}): {result.Stderr.Trim()}");
+ }
+ catch (Exception ex)
+ {
+ _ctx.Logger.Warn($"Failed to reset gateway.reload.mode after wizard: {ex.Message}");
+ }
+ }
+
+ private string WriteAnswerTemplate(IReadOnlyList discoveredSteps, WizardPayload? missingStep)
+ {
+ var logPath = _ctx.Config.LogPath;
+ var basePath = !string.IsNullOrWhiteSpace(logPath)
+ ? Path.ChangeExtension(logPath, ".wizard-answers.template.json")
+ : Path.Combine(_ctx.DataDir, "Logs", "Setup", $"setup-engine-{_ctx.Logger.RunId}.wizard-answers.template.json");
+
+ Directory.CreateDirectory(Path.GetDirectoryName(basePath)!);
+
+ var answers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var step in discoveredSteps)
+ {
+ var key = StableAnswerKey(step.Title, step.Message, step.StepId);
+ if (string.IsNullOrWhiteSpace(key))
+ continue;
+
+ answers.TryAdd(key, missingStep != null && step.StepId == missingStep.StepId
+ ? AnswerPlaceholderFor(step)
+ : step.SuggestedAnswer ?? AnswerPlaceholderFor(step));
+ }
+
+ var template = new
+ {
+ _instructions = "Copy WizardAnswers into your setup config, fill required values, then rerun setup.",
+ WizardAnswers = answers,
+ Steps = discoveredSteps
+ };
+
+ var json = JsonSerializer.Serialize(template, new JsonSerializerOptions { WriteIndented = true });
+ AtomicFile.WriteAllText(basePath, json);
+ _ctx.Logger.Info($"Wizard answer template written: {basePath}");
+ return basePath;
+ }
+
+ private static AnswerResolution ResolveAnswer(WizardPayload step, Dictionary? configuredAnswers)
+ {
+ if (TryGetConfiguredAnswer(step, configuredAnswers, out var configured))
+ return ValidateAnswer(step, configured, configuredAnswer: true);
+
+ var inferred = step.StepType switch
+ {
+ "note" => "true",
+ "confirm" => InferConfirmAnswer(step),
+ "select" => InferOptionAnswer(step),
+ "multiselect" => InferOptionAnswer(step),
+ "text" => InferTextAnswer(step),
+ _ => !string.IsNullOrWhiteSpace(step.InitialValue) ? step.InitialValue : null
+ };
+
+ if (inferred == null)
+ {
+ return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' ({step.StepType}) requires a text answer.");
+ }
+
+ return ValidateAnswer(step, inferred, configuredAnswer: false);
+ }
+
+ private static string? InferOptionAnswer(WizardPayload step)
+ {
+ if (!string.IsNullOrWhiteSpace(step.InitialValue))
+ return step.InitialValue;
+
+ var preferred = new[] { "__skip__", "skip", "__keep__", "keep" };
+ foreach (var value in preferred)
+ {
+ if (step.Options.Any(o => string.Equals(o.Value, value, StringComparison.Ordinal)))
+ return value;
+ }
+
+ return step.Options.FirstOrDefault()?.Value;
+ }
+
+ private static string InferConfirmAnswer(WizardPayload step)
+ {
+ var text = $"{step.Title} {step.Message}";
+ if (text.Contains("skill", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("API_KEY", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("API key", StringComparison.OrdinalIgnoreCase))
+ return "false";
+
+ return "true";
+ }
+
+ private static object AnswerValueForWire(WizardPayload step, string answer)
+ {
+ if (step.StepType == "confirm" && bool.TryParse(answer, out var booleanAnswer))
+ return booleanAnswer;
+
+ if (step.StepType == "multiselect")
+ {
+ if (string.Equals(answer, "__skip__", StringComparison.Ordinal))
+ return new[] { "__skip__" };
+
+ return SplitMultiSelect(answer);
+ }
+
+ return answer;
+ }
+
+ private static string? InferTextAnswer(WizardPayload step)
+ {
+ if (!string.IsNullOrWhiteSpace(step.InitialValue))
+ return step.InitialValue;
+
+ if (step.Sensitive && step.Message.Contains("API_KEY", StringComparison.OrdinalIgnoreCase))
+ return "";
+
+ return null;
+ }
+
+ private static AnswerResolution ValidateAnswer(WizardPayload step, string answer, bool configuredAnswer)
+ {
+ if (step.StepType == "select")
+ {
+ if (!step.Options.Any(o => string.Equals(o.Value, answer, StringComparison.Ordinal)))
+ {
+ var source = configuredAnswer ? "configured" : "default";
+ return AnswerResolution.Fail($"The {source} answer for wizard step '{step.StepId}' is not one of the gateway-provided options.");
+ }
+ }
+ else if (step.StepType == "multiselect")
+ {
+ var values = SplitMultiSelect(answer);
+ if (values.Length == 0)
+ return AnswerResolution.Fail($"Gateway wizard step '{step.StepId}' requires at least one selected value.");
+
+ var validValues = step.Options.Select(o => o.Value).ToHashSet(StringComparer.Ordinal);
+ var invalid = values.FirstOrDefault(v => !validValues.Contains(v));
+ if (invalid != null)
+ return AnswerResolution.Fail($"Wizard step '{step.StepId}' has invalid selected value '{invalid}'.");
+ }
+
+ return AnswerResolution.Ok(answer);
+ }
+
+ private static bool TryGetConfiguredAnswer(WizardPayload step, Dictionary