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 01/52] 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 02/52] 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 03/52] 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 04/52] 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 05/52] 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 06/52] 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 07/52] 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 08/52] 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 09/52] 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 10/52] 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 11/52] 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 12/52] 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 13/52] 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 14/52] 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 15/52] 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 16/52] 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 17/52] 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 19/52] 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 20/52] 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 21/52] 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 22/52] 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 class HostEnvSecurityPolicyTests
+{
+ [Theory]
+ // Common credential vars (in blockedEverywhereKeys or blockedOverrideOnlyKeys).
+ [InlineData("GITHUB_TOKEN")]
+ [InlineData("AWS_ACCESS_KEY_ID")]
+ [InlineData("AWS_SECRET_ACCESS_KEY")]
+ [InlineData("AZURE_CLIENT_SECRET")]
+ [InlineData("NPM_TOKEN")]
+ [InlineData("GH_TOKEN")]
+ // Code-injection vectors.
+ [InlineData("NODE_OPTIONS")]
+ [InlineData("NODE_PATH")]
+ [InlineData("PYTHONPATH")]
+ [InlineData("PYTHONSTARTUP")]
+ [InlineData("RUBYOPT")]
+ [InlineData("PERL5OPT")]
+ [InlineData("BASH_ENV")]
+ [InlineData("ENV")]
+ // Git command-overrides.
+ [InlineData("GIT_SSH_COMMAND")]
+ [InlineData("GIT_EXTERNAL_DIFF")]
+ [InlineData("GIT_ASKPASS")]
+ public void IsBlocked_True_ForCanonicalListedVars(string name)
+ {
+ Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name),
+ $"Expected {name} to be in the canonical openclaw blocklist.");
+ }
+
+ [Theory]
+ // Prefix-based vectors (case-insensitive).
+ [InlineData("LD_PRELOAD")]
+ [InlineData("LD_LIBRARY_PATH")]
+ [InlineData("DYLD_INSERT_LIBRARIES")]
+ [InlineData("BASH_FUNC_foo%%")]
+ [InlineData("ld_preload")] // lowercase should still match
+ public void IsBlocked_True_ForBlockedPrefixes(string name)
+ {
+ Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name));
+ }
+
+ [Theory]
+ // Malformed names — must not allow smuggling KEY=VAL pairs in.
+ [InlineData("")]
+ [InlineData("FOO=BAR")]
+ [InlineData("FOO\0BAR")]
+ [InlineData("FOO\nBAR")]
+ [InlineData("FOO\rBAR")]
+ public void IsBlocked_True_ForMalformedNames(string name)
+ {
+ Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name));
+ }
+
+ [Theory]
+ // Names that should NOT be blocked — passed through to the sandbox.
+ [InlineData("FOO_BAR")]
+ [InlineData("MY_APP_CONFIG")]
+ [InlineData("BUILD_NUMBER")]
+ public void IsBlocked_False_ForBenignNames(string name)
+ {
+ Assert.False(HostEnvSecurityPolicy.Default.IsBlocked(name));
+ }
+
+ [Fact]
+ public void Default_LoadsAllPolicyCategories()
+ {
+ // The canonical JSON has 90+ blockedEverywhereKeys and 144+ blockedOverrideOnlyKeys;
+ // expect at minimum ~200 entries combined and at least 3 blocked prefixes.
+ var policy = HostEnvSecurityPolicy.Default;
+ Assert.True(policy.BlockedKeys.Count >= 200,
+ $"Expected at least 200 blocked keys, got {policy.BlockedKeys.Count}");
+ Assert.True(policy.BlockedPrefixes.Count >= 3,
+ $"Expected at least 3 blocked prefixes, got {policy.BlockedPrefixes.Count}");
+ }
+}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
index be5923091..bb727f074 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
@@ -199,6 +199,34 @@ public void Build_AddsScratchDirToReadwritePaths()
Assert.Contains(P.Scratch, config.Filesystem!.ReadwritePaths!);
}
+ [Fact]
+ public void Build_BlocksDangerousAgentEnv_PerCanonicalOpenclawPolicy()
+ {
+ // Agent attempts to inject env vars on the canonical openclaw blocklist
+ // (NODE_OPTIONS, GITHUB_TOKEN, LD_PRELOAD). The builder must drop them.
+ var request = RequestFor(BalancedPolicy()) with
+ {
+ Env = new Dictionary
+ {
+ ["NODE_OPTIONS"] = "--inspect-brk=0.0.0.0:1234",
+ ["GITHUB_TOKEN"] = "ghp_FAKE",
+ ["LD_PRELOAD"] = "/tmp/evil.so",
+ ["DYLD_INSERT_LIBRARIES"] = "/tmp/evil.dylib",
+ ["GIT_SSH_COMMAND"] = "ssh -o ProxyCommand=evil",
+ ["MY_OK_VAR"] = "passthrough",
+ },
+ };
+ var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
+ var envKeys = config.Process.Env!.Select(s => s.Split('=', 2)[0]).ToArray();
+
+ Assert.DoesNotContain("NODE_OPTIONS", envKeys);
+ Assert.DoesNotContain("GITHUB_TOKEN", envKeys);
+ Assert.DoesNotContain("LD_PRELOAD", envKeys);
+ Assert.DoesNotContain("DYLD_INSERT_LIBRARIES", envKeys);
+ Assert.DoesNotContain("GIT_SSH_COMMAND", envKeys);
+ Assert.Contains("MY_OK_VAR", envKeys);
+ }
+
[Fact]
public void Build_OverridesTempEnvVarsToScratch()
{
From 0adbc84e683cd7519b77ea8e0b2f8efbd4f67b93 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 12:15:11 -0700
Subject: [PATCH 39/52] docs(mxc): document provenance + macOS-parity for env
scrub and PATH walking
Adds explicit provenance comments so readers don't have to trace git
history to understand why these pieces exist:
- HostEnvSecurityPolicy.cs: expanded XML doc spells out the source
(openclaw/openclaw:src/infra/host-env-security-policy.json), the
macOS analog (HostEnvSanitizer.swift + HostEnvSecurityPolicy.generated.swift),
why we enforce at the wxc-exec boundary (defense-in-depth, not gateway
centralization), and the threat-model rationale for merging the
'everywhere' and 'override-only' buckets in our agent-env context.
- MxcConfigBuilder.BuildEnv: comment cites HostEnvSanitizer.sanitize as
the direct macOS counterpart and explains the scrubbing rationale.
- MxcConfigBuilder.ResolvePathDirsForReadonly: comment cites the SDK
function we mirror (getAvailableToolsPolicy in
@microsoft/mxc-sdk:dist/policy.js) plus the drive-root SDK bug
workaround.
- HostEnvSecurityPolicy.md: new sibling README next to the JSON that
describes the update workflow, schema, and how/why we differ from
the macOS code-generation approach.
No behavior change; comments + docs only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Mxc/HostEnvSecurityPolicy.cs | 34 ++++++++++----
.../Mxc/HostEnvSecurityPolicy.md | 47 +++++++++++++++++++
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 36 +++++++++-----
3 files changed, 98 insertions(+), 19 deletions(-)
create mode 100644 src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
diff --git a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
index 8e11955b5..8c2fd8d11 100644
--- a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
+++ b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
@@ -5,16 +5,34 @@
namespace OpenClaw.Shared.Mxc;
///
-/// Loads the canonical host env security policy from
-/// openclaw/openclaw:src/infra/host-env-security-policy.json, embedded
-/// in this assembly as Mxc/HostEnvSecurityPolicy.json. Mirrors the
-/// macOS consumer HostEnvSecurityPolicy.generated.swift.
+/// Host env security policy: which environment variables an executor must
+/// refuse to set on a spawned child process.
///
///
-/// We treat the agent-supplied env in
-/// as untrusted, so for our sandbox-boundary purposes a key is "blocked" if
-/// it appears in any of the policy's block sets, OR if it starts with any
-/// blocked prefix (case-insensitive on both sides).
+/// Provenance. The policy data lives upstream in
+/// openclaw/openclaw at
+/// src/infra/host-env-security-policy.json. We keep a byte-identical
+/// copy at src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json embedded
+/// as an assembly resource. When the upstream JSON changes, re-copy and rerun
+/// the HostEnvSecurityPolicyTests tests.
+/// Why we enforce this on the Windows node side. openclaw does
+/// not centralize env scrubbing at "the gateway"; it scrubs at every exec
+/// boundary as defense-in-depth (see CHANGELOG references like *"on both
+/// node host and macOS companion paths"*). This class is the Windows-node
+/// analog of the macOS consumer
+/// apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +
+/// HostEnvSecurityPolicy.generated.swift. The macOS code is generated
+/// from the same JSON via scripts/generate-host-env-security-policy-swift.mjs;
+/// we just load the JSON directly at runtime instead of code-generating.
+/// Threat model. Agent-supplied env in
+/// is untrusted. For our sandbox-boundary
+/// purposes, a key is "blocked" if it appears in any of the policy's block
+/// sets (blockedEverywhereKeys ∪ blockedOverrideOnlyKeys) OR if
+/// it starts with any blocked prefix (blockedPrefixes ∪
+/// blockedOverridePrefixes), all case-insensitive. We merge the
+/// "everywhere" and "override-only" buckets because for the agent we are the
+/// override path — they're setting env explicitly, not inheriting it from
+/// host process state.
///
public sealed class HostEnvSecurityPolicy
{
diff --git a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
new file mode 100644
index 000000000..4706f11cb
--- /dev/null
+++ b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
@@ -0,0 +1,47 @@
+# HostEnvSecurityPolicy
+
+## What this is
+
+`HostEnvSecurityPolicy.json` is a **byte-identical copy** of
+`openclaw/openclaw:src/infra/host-env-security-policy.json` — the canonical
+list of environment variables an executor must refuse to set on a spawned
+child process. It is consumed by `HostEnvSecurityPolicy.cs` (embedded as an
+assembly resource) and used in `MxcConfigBuilder.BuildEnv` to filter
+agent-supplied env before it reaches `wxc-exec.exe`.
+
+## Why we ship a copy
+
+openclaw enforces env scrubbing at every spawn boundary as defense-in-depth,
+not centrally at the gateway. The macOS app does this via
+`apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift` (consumer) +
+`HostEnvSecurityPolicy.generated.swift` (data, generated from the same JSON
+by `scripts/generate-host-env-security-policy-swift.mjs`). We are the
+Windows-node analog: same role, same data source. Loading the JSON directly
+at runtime instead of code-generating is the only divergence.
+
+## Update workflow
+
+When the upstream JSON changes:
+
+```powershell
+# 1. Copy the latest from openclaw/openclaw
+cp /src/infra/host-env-security-policy.json `
+ src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json
+
+# 2. Re-run the policy tests (catch truncation / drift)
+dotnet test ./tests/OpenClaw.Shared.Tests --filter "FullyQualifiedName~HostEnvSecurityPolicy"
+```
+
+`HostEnvSecurityPolicyTests` asserts a minimum size (≥200 blocked keys,
+≥3 prefixes) plus the presence of well-known entries (`GITHUB_TOKEN`,
+`LD_PRELOAD`, etc.) so an accidentally truncated or stale copy fails fast.
+
+## Schema reference
+
+| Key | Used by us | Meaning |
+|---|---|---|
+| `blockedEverywhereKeys` | ✅ blocked | always block, in both host inheritance and agent overrides |
+| `blockedOverrideOnlyKeys` | ✅ blocked | block when set explicitly by the caller (we always treat agent env as an "override") |
+| `blockedPrefixes` | ✅ blocked | block keys matching these prefixes (`LD_`, `DYLD_`, `BASH_FUNC_`) |
+| `blockedOverridePrefixes` | ✅ blocked | block override-only prefixes (`GIT_CONFIG_`, `NPM_CONFIG_`, `CARGO_REGISTRIES_`, `TF_VAR_`) |
+| `allowedInheritedOverrideOnlyKeys` | ❌ not used | narrow allow-list for vars that override-blocked but inheritance-allowed; only meaningful when a process inherits host env (we don't inherit, agent supplies env explicitly) |
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
index 011379b9c..7fffbd981 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -158,9 +158,12 @@ public static MxcConfig Build(
///
/// Walk PATH and return each existing directory as a readonly grant
- /// candidate. Mirrors the SDK's getAvailableToolsPolicy approach
- /// (every PATH dir, dedup'd, minus drive roots which the SDK has a bug
- /// around). No tool-name whitelist — the SDK doesn't have one either.
+ /// candidate. Mirrors the SDK's getAvailableToolsPolicy
+ /// (@microsoft/mxc-sdk:dist/policy.js): every existing PATH dir
+ /// is granted, drive roots are skipped (the SDK has a documented bug
+ /// where pwsh.exe on PATH would otherwise grant the entire system
+ /// drive — we strip that the same way the legacy JS bridge did).
+ /// No tool-name whitelist — the SDK doesn't have one either.
///
public static List ResolvePathDirsForReadonly(string? pathEnvVar = null)
{
@@ -208,15 +211,26 @@ private static bool IsDriveRoot(string dir)
}
///
- /// Build the env array (KEY=VALUE strings). Only agent-supplied env from
- /// is passed through, filtered against the
- /// canonical openclaw so the agent
- /// can't smuggle in dangerous vars (NODE_OPTIONS, GITHUB_TOKEN, LD_PRELOAD,
- /// GIT_SSH_COMMAND, etc.). TEMP/TMP/TMPDIR are forced to
- /// so commands inside the sandbox write
- /// into our scratch, not the user's real %TEMP%. The host env is not
- /// allow-listed in — the agent owns env-var policy.
+ /// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit.
///
+ ///
+ /// What flows in: only the agent-supplied
+ /// . The host env is intentionally NOT
+ /// allow-listed in — the agent owns env-var policy and decides what to
+ /// pass. TEMP/TMP/TMPDIR are then forced to
+ /// so any tool inside the sandbox writes scratch files into our
+ /// throwaway dir, not the user's real %TEMP%.
+ /// Why we scrub: openclaw's exec security model sanitizes
+ /// at every spawn boundary, not just at "the gateway". This mirrors
+ /// the macOS consumer HostEnvSanitizer.sanitize
+ /// (apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift) that runs
+ /// inside ExecApprovalEvaluation.swift. We are the Windows-node
+ /// analog: every system.run we forward to wxc-exec gets the
+ /// canonical openclaw blocklist applied so the agent can't smuggle in
+ /// vars like NODE_OPTIONS, GITHUB_TOKEN, LD_PRELOAD,
+ /// GIT_SSH_COMMAND, or
+ /// BASH_FUNC_*/DYLD_*/LD_* prefixes.
+ ///
public static IReadOnlyList BuildEnv(
IReadOnlyDictionary? requestEnv,
string scratchDir,
From 190a53fba49cc788bf79df9d16b6b38e20cb98a9 Mon Sep 17 00:00:00 2001
From: AlexAlves87
Date: Thu, 21 May 2026 21:56:31 +0200
Subject: [PATCH 40/52] 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 41/52] 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 42/52] 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 43/52] 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 values => values.Select(SanitizeValue).ToArray(),
+ _ => value
+ };
+ }
+
+ private static bool IsSecretFlag(string flagName)
+ {
+ foreach (var flag in SecretValueFlags)
+ {
+ if (flagName.Equals(flag, StringComparison.OrdinalIgnoreCase)
+ || flagName.Contains("token", StringComparison.OrdinalIgnoreCase)
+ || flagName.Contains("secret", StringComparison.OrdinalIgnoreCase)
+ || flagName.Contains("password", StringComparison.OrdinalIgnoreCase)
+ || flagName.Contains("private", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs
new file mode 100644
index 000000000..fd05f223c
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/LocalGatewaySetupDiagnosticsTests.cs
@@ -0,0 +1,154 @@
+using OpenClawTray.Services.LocalGatewaySetup;
+
+namespace OpenClaw.Tray.Tests;
+
+public class LocalGatewaySetupDiagnosticsTests
+{
+ [Fact]
+ public void Diagnostics_WritesFailureJsonlAndHumanSummary()
+ {
+ using var temp = new LocalGatewaySetupTests.TempDirectory();
+ var service = new LocalGatewaySetupDiagnosticsService(temp.Path);
+ var options = new LocalGatewaySetupOptions();
+ var state = LocalGatewaySetupState.Create(options);
+ state.RunId = "run123";
+ state.InstallId = "install456";
+
+ service.RunStarted(state, options);
+ state.StartPhase(LocalGatewaySetupPhase.Preflight, "Checking your PC");
+ service.PhaseStarted(state, LocalGatewaySetupPhase.Preflight, "Checking your PC");
+ state.Block(
+ "wsl_unavailable",
+ "WSL is unavailable. bootstrapToken: secret-token",
+ retryable: true,
+ detail: "gateway-token=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
+ service.PhaseCompleted(state, LocalGatewaySetupPhase.Preflight, "Checking your PC", TimeSpan.FromMilliseconds(42));
+ service.RunCompleted(state, TimeSpan.FromMilliseconds(50));
+
+ var jsonl = File.ReadAllText(service.LatestTracePath!);
+ var summary = File.ReadAllText(service.LatestSummaryPath!);
+
+ Assert.Contains("\"schema_version\":1", jsonl);
+ Assert.Contains("\"event\":\"phase_failed\"", jsonl);
+ Assert.Contains("\"failure_code\":\"wsl_unavailable\"", jsonl);
+ Assert.DoesNotContain("secret-token", jsonl);
+ Assert.DoesNotContain("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", jsonl);
+ Assert.Contains("Outcome: FAILED", summary);
+ Assert.Contains("Failed phase: Preflight", summary);
+ Assert.Contains("Failure code: wsl_unavailable", summary);
+ Assert.Contains("easy-setup-latest.jsonl", summary);
+ }
+
+ [Fact]
+ public void Diagnostics_RedactsCommandArgumentsAndOutputOnDisk()
+ {
+ using var temp = new LocalGatewaySetupTests.TempDirectory();
+ var service = new LocalGatewaySetupDiagnosticsService(temp.Path);
+ var options = new LocalGatewaySetupOptions();
+ var state = LocalGatewaySetupState.Create(options);
+ state.RunId = "run-redact";
+ state.InstallId = "install-redact";
+ service.RunStarted(state, options);
+
+ var secretHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
+ var commandId = service.CommandStarted(
+ "openclaw",
+ ["gateway", "status", "--token", "super-secret-token", "--password=super-secret-password"],
+ TimeSpan.FromSeconds(5));
+ service.CommandCompleted(
+ commandId,
+ "openclaw",
+ ["gateway", "status", "--token", "super-secret-token", "--password=super-secret-password"],
+ TimeSpan.FromMilliseconds(10),
+ new WslCommandResult(
+ 1,
+ $"bootstrapToken: secret-token\nraw token {secretHex}",
+ "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----"),
+ timedOut: false);
+
+ var jsonl = File.ReadAllText(service.LatestTracePath!);
+
+ Assert.DoesNotContain("super-secret-token", jsonl);
+ Assert.DoesNotContain("super-secret-password", jsonl);
+ Assert.DoesNotContain("secret-token", jsonl);
+ Assert.DoesNotContain(secretHex, jsonl);
+ Assert.DoesNotContain("BEGIN PRIVATE KEY", jsonl);
+ Assert.Contains("", jsonl);
+ }
+
+ [Fact]
+ public async Task Engine_WritesRunAndPhaseDiagnostics_ForSuccessfulSetup()
+ {
+ using var temp = new LocalGatewaySetupTests.TempDirectory();
+ var statePath = Path.Combine(temp.Path, "setup-state.json");
+ var wsl = new LocalGatewaySetupTests.FakeWslCommandRunner();
+ var provisioning = new FakeProvisioner();
+ var diagnostics = new LocalGatewaySetupDiagnosticsService(temp.Path);
+ var engine = new LocalGatewaySetupEngine(
+ new LocalGatewaySetupOptions { InstanceInstallLocation = Path.Combine(temp.Path, "OpenClawGateway") },
+ new LocalGatewaySetupStateStore(statePath),
+ new LocalGatewayPreflightProbe(wsl, new LocalGatewaySetupTests.FixedPortProbe(available: true)),
+ wsl,
+ new LocalGatewaySetupTests.SuccessfulHealthProbe(),
+ provisioning,
+ provisioning,
+ provisioning,
+ wslInstanceInstaller: new WslStoreInstanceInstaller(wsl, createDirectory: _ => { }),
+ wslInstanceConfigurator: new LocalGatewaySetupTests.FakeWslInstanceConfigurator(),
+ openClawLinuxInstaller: new LocalGatewaySetupTests.FakeOpenClawLinuxInstaller(),
+ gatewayConfigurationPreparer: new LocalGatewaySetupTests.FakeGatewayConfigurationPreparer(),
+ gatewayServiceManager: new LocalGatewaySetupTests.FakeGatewayServiceManager(),
+ diagnosticsSink: diagnostics);
+
+ var state = await engine.RunLocalOnlyAsync();
+
+ Assert.Equal(LocalGatewaySetupStatus.Complete, state.Status);
+ var jsonl = File.ReadAllText(diagnostics.LatestTracePath!);
+ Assert.Contains("\"event\":\"run_started\"", jsonl);
+ Assert.Contains("\"event\":\"phase_started\"", jsonl);
+ Assert.Contains("\"event\":\"phase_succeeded\"", jsonl);
+ Assert.Contains("\"event\":\"run_completed\"", jsonl);
+ Assert.Contains("\"phase\":\"CreateWslInstance\"", jsonl);
+ Assert.Contains("Outcome: COMPLETE", File.ReadAllText(diagnostics.LatestSummaryPath!));
+ }
+
+ [Fact]
+ public async Task LifecycleManager_WritesGatewayLifecycleFailureDiagnostics()
+ {
+ using var temp = new LocalGatewaySetupTests.TempDirectory();
+ var diagnostics = new LocalGatewaySetupDiagnosticsService(temp.Path);
+ var manager = new LocalGatewayLifecycleManager(
+ new LocalGatewaySetupOptions(),
+ new LocalGatewaySetupTests.FakeWslCommandRunner(),
+ new LocalGatewaySetupTests.SuccessfulHealthProbe(),
+ diagnosticsSink: diagnostics);
+
+ var result = await manager.RemoveAsync(new LocalGatewayRemoveRequest(ConfirmRemove: false, ClearLocalCredentials: false));
+
+ Assert.False(result.Success);
+ var jsonl = File.ReadAllText(diagnostics.LatestTracePath!);
+ var summary = File.ReadAllText(diagnostics.LatestSummaryPath!);
+ Assert.Contains("\"event\":\"lifecycle_started\"", jsonl);
+ Assert.Contains("\"event\":\"lifecycle_step_failed\"", jsonl);
+ Assert.Contains("\"event\":\"lifecycle_failed\"", jsonl);
+ Assert.Contains("\"failure_code\":\"confirmation_required\"", jsonl);
+ Assert.Contains("Gateway lifecycle operation: remove", summary);
+ Assert.Contains("Failure code: confirmation_required", summary);
+ }
+
+ private sealed class FakeProvisioner :
+ IBootstrapTokenProvisioner, IOperatorPairingService, IWindowsTrayNodeProvisioner
+ {
+ public Task MintAsync(LocalGatewaySetupState state, CancellationToken cancellationToken = default) =>
+ Task.FromResult(new ProvisioningResult(true));
+
+ Task IOperatorPairingService.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) =>
+ Task.FromResult(new ProvisioningResult(true));
+
+ Task IWindowsTrayNodeProvisioner.CheckReadinessAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) =>
+ Task.FromResult(new ProvisioningResult(true));
+
+ Task IWindowsTrayNodeProvisioner.PairAsync(LocalGatewaySetupState state, CancellationToken cancellationToken) =>
+ Task.FromResult(new ProvisioningResult(true));
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
index 8165914cd..629e5eaea 100644
--- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
+++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj
@@ -65,6 +65,7 @@
+
From 6946b7272597d4613a0ec284743c6b8ae44dbedb 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:20:14 -0700
Subject: [PATCH 44/52] fix(uninstall): clean up empty WSL parent after VHD
removal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After Step 5a removes the distro-specific VHD directory
(e.g. %LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway\), the
parent wsl\ directory was left as an empty orphan, which also
prevented the %LOCALAPPDATA%\OpenClawTray\ directory from being
removed by the MSIX uninstaller.
Changes:
- Add Step 5b in LocalGatewayUninstall.cs: after VHD parent dir
cleanup, delete the wsl\ parent directory if it is empty.
Non-empty wsl\ dirs (e.g. other distros still present) are
preserved safely.
- Add WslParentDirAbsent postcondition; include it in
AllRequiredPostconditionsMet and AppendPostconditionErrors.
- Update validate-wsl-gateway-uninstall.ps1: add wsl_parent_dir
path constant, include wsl_parent_dir_absent in postconditions
and required-checks list, and capture it in the pre-state
snapshot.
- Add 3 unit tests (WslParentDirCleanup_*) covering empty→deleted,
non-empty→skipped, and already-absent→skipped scenarios.
Closes #467
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
scripts/validate-wsl-gateway-uninstall.ps1 | 6 +-
.../LocalGatewayUninstall.cs | 42 +++++++++-
.../LocalGatewayUninstallTests.cs | 78 +++++++++++++++++++
3 files changed, 124 insertions(+), 2 deletions(-)
diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1
index 2eaab1c89..fc94a6a32 100644
--- a/scripts/validate-wsl-gateway-uninstall.ps1
+++ b/scripts/validate-wsl-gateway-uninstall.ps1
@@ -233,6 +233,7 @@ $settingsPath = Join-Path $appData "OpenClawTray\settings.json"
$logsDir = Join-Path $localAppData "OpenClawTray\Logs"
$execPolicyPath = Join-Path $localAppData "OpenClawTray\exec-policy.json"
$vhdDirPath = Join-Path $localAppData "OpenClawTray\wsl\$DistroName"
+$wslParentDirPath = Join-Path $localAppData "OpenClawTray\wsl"
$autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
$autoStartAppName = "OpenClawTray"
@@ -374,6 +375,7 @@ function Get-StateSnapshot {
settings_exists = (Test-Path -LiteralPath $settingsPath)
exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath)
vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath)
+ wsl_parent_dir_exists = (Test-Path -LiteralPath $wslParentDirPath)
}
processes_openclaw = @()
}
@@ -477,6 +479,7 @@ function Get-Postconditions {
mcp_token_preserved = $mcpTokenPreserved
keepalives_absent = $keepalivesAbsent
vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath))
+ wsl_parent_dir_absent = (-not (Test-Path -LiteralPath $wslParentDirPath))
}
}
@@ -488,7 +491,8 @@ function Get-Verdict {
# Required postconditions (device_key_file_preserved and mcp_token_preserved are advisory).
$required = @('wsl_distro_absent', 'autostart_cleared', 'setup_state_absent',
- 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent')
+ 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent',
+ 'wsl_parent_dir_absent')
$failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true })
$errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count }
diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
index 8261562aa..33bd02a6a 100644
--- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
@@ -50,6 +50,9 @@ public sealed record LocalGatewayUninstallPostconditions
/// VHD parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\<DistroName>.
public bool VhdDirAbsent { get; init; }
+ /// WSL parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\
+ public bool WslParentDirAbsent { get; init; }
+
/// No gateway records matching local predicate remain in gateways.json.
public bool LocalGatewayRecordsAbsent { get; init; }
@@ -420,6 +423,35 @@ await RunStepAsync("VHD parent dir cleanup", options, ct, () =>
return Task.CompletedTask;
});
+ // ------------------------------------------------------------------
+ // Step 5b — WSL parent-dir cleanup (idempotent)
+ // After the distro-specific VHD dir is removed, clean up the empty
+ // wsl\ parent directory so the installer leaves no orphaned folders.
+ // ------------------------------------------------------------------
+ await RunStepAsync("WSL parent dir cleanup", options, ct, () =>
+ {
+ var wslDir = Path.Combine(_localDataPath, "wsl");
+ if (!Directory.Exists(wslDir))
+ {
+ RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped,
+ "Directory absent.");
+ return Task.CompletedTask;
+ }
+
+ if (!Directory.EnumerateFileSystemEntries(wslDir).Any())
+ {
+ Directory.Delete(wslDir);
+ RecordStep("WSL parent dir cleanup", UninstallStepStatus.Executed,
+ "Deleted empty wsl\\ parent directory.");
+ }
+ else
+ {
+ RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped,
+ "Directory not empty; preserved.");
+ }
+ return Task.CompletedTask;
+ });
+
// ------------------------------------------------------------------
// Step 6 — Reset autostart
// CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting registry.
@@ -783,6 +815,10 @@ private async Task ComputePostconditionsAsy
bool vhdDirAbsent = !Directory.Exists(
Path.Combine(_localDataPath, "wsl", options.DistroName));
+ // WSL parent dir absent?
+ bool wslParentDirAbsent = !Directory.Exists(
+ Path.Combine(_localDataPath, "wsl"));
+
// Local gateway records absent? Reload from disk — fresh instance, not mutated in-memory.
bool localRecordsAbsent;
try
@@ -806,6 +842,7 @@ private async Task ComputePostconditionsAsy
McpTokenPreserved = mcpTokenPreserved,
KeepalivesAbsent = keepalivesAbsent,
VhdDirAbsent = vhdDirAbsent,
+ WslParentDirAbsent = wslParentDirAbsent,
LocalGatewayRecordsAbsent = localRecordsAbsent,
LocalGatewayIdentityDirsAbsent = localIdentityDirsAbsent
};
@@ -819,7 +856,8 @@ private static bool AllRequiredPostconditionsMet(LocalGatewayUninstallPostcondit
&& p.LocalGatewayRecordsAbsent
&& p.LocalGatewayIdentityDirsAbsent
&& p.KeepalivesAbsent
- && p.VhdDirAbsent;
+ && p.VhdDirAbsent
+ && p.WslParentDirAbsent;
private static bool IsLocalGatewayRecordForUninstall(GatewayRecord record)
{
@@ -855,6 +893,8 @@ private void AppendPostconditionErrors(LocalGatewayUninstallPostconditions p)
_errors.Add("Postcondition failed: keepalive process still running.");
if (!p.VhdDirAbsent)
_errors.Add("Postcondition failed: VHD directory still present.");
+ if (!p.WslParentDirAbsent)
+ _errors.Add("Postcondition failed: wsl\\ parent directory still present (may contain unexpected files).");
}
private LocalGatewayUninstallResult BuildResult(
diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs
index b46937468..f2b07fc15 100644
--- a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs
+++ b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs
@@ -1435,4 +1435,82 @@ public async Task DryRun_SuccessTrue_PostconditionsSkipped()
Assert.True(result.Success);
Assert.Empty(result.Errors);
}
+
+ // -----------------------------------------------------------------------
+ // Test: WslParentDirCleanup — wsl\ dir removed when empty after VHD cleanup
+ // -----------------------------------------------------------------------
+
+ [WindowsFact]
+ public async Task WslParentDirCleanup_EmptyAfterVhdCleanup_ExecutedAndDeleted()
+ {
+ using var env = new UninstallTestEnv();
+ var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway");
+ Directory.CreateDirectory(vhdDir);
+ File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake vhd");
+
+ var engine = env.BuildEngine();
+ var result = await engine.RunAsync(new LocalGatewayUninstallOptions
+ {
+ DryRun = false,
+ ConfirmDestructive = true
+ });
+
+ var wslDir = Path.Combine(env.LocalDataDir, "wsl");
+ Assert.False(Directory.Exists(wslDir));
+ var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup");
+ Assert.NotNull(step);
+ Assert.Equal(UninstallStepStatus.Executed, step.Status);
+ Assert.True(result.Postconditions.WslParentDirAbsent);
+ }
+
+ // -----------------------------------------------------------------------
+ // Test: WslParentDirCleanup — wsl\ dir preserved when non-empty
+ // -----------------------------------------------------------------------
+
+ [WindowsFact]
+ public async Task WslParentDirCleanup_NonEmpty_Skipped()
+ {
+ using var env = new UninstallTestEnv();
+ var wslDir = Path.Combine(env.LocalDataDir, "wsl");
+ Directory.CreateDirectory(wslDir);
+ // Put an unrelated file in wsl\ to make it non-empty after VHD dir is gone
+ File.WriteAllText(Path.Combine(wslDir, "other-distro-marker.txt"), "preserved");
+
+ var engine = env.BuildEngine();
+ var result = await engine.RunAsync(new LocalGatewayUninstallOptions
+ {
+ DryRun = false,
+ ConfirmDestructive = true
+ });
+
+ Assert.True(Directory.Exists(wslDir), "wsl\\ dir should be preserved when non-empty");
+ var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup");
+ Assert.NotNull(step);
+ Assert.Equal(UninstallStepStatus.Skipped, step.Status);
+ Assert.False(result.Postconditions.WslParentDirAbsent);
+ }
+
+ // -----------------------------------------------------------------------
+ // Test: WslParentDirCleanup — wsl\ dir already absent → Skipped (idempotent)
+ // -----------------------------------------------------------------------
+
+ [WindowsFact]
+ public async Task WslParentDirCleanup_AlreadyAbsent_Skipped()
+ {
+ using var env = new UninstallTestEnv();
+ var wslDir = Path.Combine(env.LocalDataDir, "wsl");
+ Assert.False(Directory.Exists(wslDir));
+
+ var engine = env.BuildEngine();
+ var result = await engine.RunAsync(new LocalGatewayUninstallOptions
+ {
+ DryRun = false,
+ ConfirmDestructive = true
+ });
+
+ var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup");
+ Assert.NotNull(step);
+ Assert.Equal(UninstallStepStatus.Skipped, step.Status);
+ Assert.True(result.Postconditions.WslParentDirAbsent);
+ }
}
From 8914780f3d2ca62b2d4abecf75a09f9d09250886 Mon Sep 17 00:00:00 2001
From: Scott Hanselman
Date: Thu, 21 May 2026 13:24:41 -0700
Subject: [PATCH 45/52] fix(uninstall): allow preserved non-empty WSL parent
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Services/LocalGatewaySetup/LocalGatewayUninstall.cs | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
index 33bd02a6a..17c3bdebd 100644
--- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs
@@ -856,8 +856,7 @@ private static bool AllRequiredPostconditionsMet(LocalGatewayUninstallPostcondit
&& p.LocalGatewayRecordsAbsent
&& p.LocalGatewayIdentityDirsAbsent
&& p.KeepalivesAbsent
- && p.VhdDirAbsent
- && p.WslParentDirAbsent;
+ && p.VhdDirAbsent;
private static bool IsLocalGatewayRecordForUninstall(GatewayRecord record)
{
@@ -893,8 +892,6 @@ private void AppendPostconditionErrors(LocalGatewayUninstallPostconditions p)
_errors.Add("Postcondition failed: keepalive process still running.");
if (!p.VhdDirAbsent)
_errors.Add("Postcondition failed: VHD directory still present.");
- if (!p.WslParentDirAbsent)
- _errors.Add("Postcondition failed: wsl\\ parent directory still present (may contain unexpected files).");
}
private LocalGatewayUninstallResult BuildResult(
From eeab616957c6f51bd0f934af607706153ae68f88 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:34:52 -0700
Subject: [PATCH 46/52] test(shared): add DeepLinkParser unit tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DeepLinkParser had no test coverage despite being pure testable logic
(no I/O, no Windows dependencies). This adds 27 tests covering:
- ParseDeepLink: null/whitespace/wrong-scheme → null
- ParseDeepLink: path extraction (with/without trailing slash)
- ParseDeepLink: Windows-canonicalized form (slash before query)
- ParseDeepLink: single and multi-parameter extraction
- ParseDeepLink: case-insensitive parameter lookup
- ParseDeepLink: URL-encoded value decoding
- ParseDeepLink: empty query → empty Parameters dict
- GetQueryParam: null/empty query or empty key → null
- GetQueryParam: value retrieval, case-insensitivity, URL decoding
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../DeepLinkParserTests.cs | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs
diff --git a/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs b/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs
new file mode 100644
index 000000000..891064d22
--- /dev/null
+++ b/tests/OpenClaw.Shared.Tests/DeepLinkParserTests.cs
@@ -0,0 +1,156 @@
+using System.Collections.Generic;
+using OpenClaw.Shared;
+using Xunit;
+
+namespace OpenClaw.Shared.Tests;
+
+public class DeepLinkParserTests
+{
+ // ─── ParseDeepLink ────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ParseDeepLink_ReturnsNull_ForNullOrWhitespace(string? uri)
+ {
+ Assert.Null(DeepLinkParser.ParseDeepLink(uri));
+ }
+
+ [Theory]
+ [InlineData("https://example.com/send")]
+ [InlineData("opencla://send")]
+ [InlineData("OPENCLAW//send")]
+ public void ParseDeepLink_ReturnsNull_ForNonOpenClawScheme(string uri)
+ {
+ Assert.Null(DeepLinkParser.ParseDeepLink(uri));
+ }
+
+ [Theory]
+ [InlineData("openclaw://send", "send")]
+ [InlineData("OPENCLAW://send", "send")]
+ [InlineData("openclaw://send/", "send")]
+ [InlineData("openclaw://send/?text=hello", "send")]
+ [InlineData("openclaw://send?text=hello", "send")]
+ [InlineData("openclaw://pair/setup", "pair/setup")]
+ [InlineData("openclaw://", "")]
+ public void ParseDeepLink_ExtractsPath(string uri, string expectedPath)
+ {
+ var result = DeepLinkParser.ParseDeepLink(uri);
+
+ Assert.NotNull(result);
+ Assert.Equal(expectedPath, result.Path);
+ }
+
+ [Fact]
+ public void ParseDeepLink_ExtractsQueryString()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello&target=channel");
+
+ Assert.NotNull(result);
+ Assert.Equal("text=hello&target=channel", result.Query);
+ }
+
+ [Fact]
+ public void ParseDeepLink_ParsesSingleParameter()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello");
+
+ Assert.NotNull(result);
+ Assert.Equal("hello", result.Parameters["text"]);
+ }
+
+ [Fact]
+ public void ParseDeepLink_ParsesMultipleParameters()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello&target=channel&urgent=true");
+
+ Assert.NotNull(result);
+ Assert.Equal("hello", result.Parameters["text"]);
+ Assert.Equal("channel", result.Parameters["target"]);
+ Assert.Equal("true", result.Parameters["urgent"]);
+ }
+
+ [Fact]
+ public void ParseDeepLink_ParameterLookupIsCaseInsensitive()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send?Text=hello");
+
+ Assert.NotNull(result);
+ Assert.Equal("hello", result.Parameters["text"]);
+ Assert.Equal("hello", result.Parameters["TEXT"]);
+ }
+
+ [Fact]
+ public void ParseDeepLink_DecodesUrlEncodedParameters()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send?text=hello%20world&key=a%2Bb");
+
+ Assert.NotNull(result);
+ Assert.Equal("hello world", result.Parameters["text"]);
+ Assert.Equal("a+b", result.Parameters["key"]);
+ }
+
+ [Fact]
+ public void ParseDeepLink_ReturnsEmptyParameters_WhenNoQuery()
+ {
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send");
+
+ Assert.NotNull(result);
+ Assert.Empty(result.Parameters);
+ Assert.Equal(string.Empty, result.Query);
+ }
+
+ [Fact]
+ public void ParseDeepLink_HandlesWindowsCanonicalizedForm_SlashBeforeQuery()
+ {
+ // Windows may canonicalize openclaw://send/?args=... — path should be "send", not "send/"
+ var result = DeepLinkParser.ParseDeepLink("openclaw://send/?text=hello");
+
+ Assert.NotNull(result);
+ Assert.Equal("send", result.Path);
+ Assert.Equal("hello", result.Parameters["text"]);
+ }
+
+ // ─── GetQueryParam ────────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(null, "key")]
+ [InlineData("", "key")]
+ public void GetQueryParam_ReturnsNull_ForNullOrEmptyQuery(string? query, string key)
+ {
+ Assert.Null(DeepLinkParser.GetQueryParam(query, key));
+ }
+
+ [Theory]
+ [InlineData("text=hello", "")]
+ public void GetQueryParam_ReturnsNull_ForEmptyKey(string query, string key)
+ {
+ Assert.Null(DeepLinkParser.GetQueryParam(query, key));
+ }
+
+ [Fact]
+ public void GetQueryParam_ReturnsValue_ForMatchingKey()
+ {
+ Assert.Equal("hello", DeepLinkParser.GetQueryParam("text=hello&target=chan", "text"));
+ }
+
+ [Fact]
+ public void GetQueryParam_LookupIsCaseInsensitive()
+ {
+ Assert.Equal("hello", DeepLinkParser.GetQueryParam("Text=hello", "text"));
+ Assert.Equal("hello", DeepLinkParser.GetQueryParam("text=hello", "TEXT"));
+ }
+
+ [Fact]
+ public void GetQueryParam_DecodesUrlEncodedValue()
+ {
+ Assert.Equal("hello world", DeepLinkParser.GetQueryParam("text=hello%20world", "text"));
+ }
+
+ [Fact]
+ public void GetQueryParam_ReturnsNull_ForMissingKey()
+ {
+ Assert.Null(DeepLinkParser.GetQueryParam("text=hello", "missing"));
+ }
+}
From 650a058e27d255a7b4ca9f425d1bc6ff6bbcf1fe 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:53:01 -0700
Subject: [PATCH 47/52] fix(mcp): add missing Windows node tool descriptions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Issue #351 was closed but the four undescribed tools were never added to
CommandDescriptions in McpToolBridge.cs or skill.md. MCP clients and the
local winnode.exe CLI now see the correct parameter shapes and return
types for all four tools.
Changes:
- McpToolBridge.cs: add location.*, device.*, browser.* CommandDescriptions
- skill.md: add Location, Device, and Browser control sections with full
parameter docs and privacy notes
The existing SkillMdDriftTests suite verifies that every command in
CommandDescriptions has a matching H3 heading in skill.md — all 115
tests pass with 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>
---
src/OpenClaw.Shared/Mcp/McpToolBridge.cs | 14 ++++++
src/OpenClaw.WinNode.Cli/skill.md | 56 ++++++++++++++++++++++++
2 files changed, 70 insertions(+)
diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
index 2bae20005..91379e095 100644
--- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
+++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs
@@ -270,6 +270,20 @@ private object HandleToolsList()
"Get tray menu state (status, session count, node count). Returns array of menu items.",
["app.search"] =
"Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.",
+
+ // location.*
+ ["location.get"] =
+ "Get the current device location via Windows.Devices.Geolocation. Args: accuracy ('default'|'high', optional, default 'default'), maxAge (int ms, optional, default 30000 — return a cached fix if it is younger than this), locationTimeout (int ms, optional, default 10000). Returns { latitude, longitude, accuracy (meters), timestamp (ms since epoch) }. Requires Location capability to be enabled and the user to have granted location permission to the app.",
+
+ // device.*
+ ["device.info"] =
+ "Get static device metadata. No args. Returns { deviceName, modelIdentifier, systemName, systemVersion, appVersion, appBuild, locale }.",
+ ["device.status"] =
+ "Get live system health data. Args: sections (string[], optional — subset of ['os','cpu','memory','disk','battery']; omit for all). Returns a map with a 'collectedAt' timestamp and one key per requested section. Each section may contain an 'error' field if collection failed. Also includes legacy fields: thermal, storage, network, uptimeSeconds.",
+
+ // browser.*
+ ["browser.proxy"] =
+ "Proxy an HTTP request to the local OpenClaw browser control host (CDP server) running on gateway port + 2. Args: path (string, required — a local control path like '/json/list' or '/json/activate/'), method ('GET'|'POST'|'DELETE', default 'GET'), body (JSON object, POST/DELETE only), query (object, appended as query params), profile (string, optional browser profile), timeoutMs (int, default 20000, max 120000). Returns { result, files? } where files is present if the response included local file paths. Requires the gateway URL to have an explicit port and the browser control host to be running.",
};
private async Task HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken)
diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md
index d7373fa9b..58a85237d 100644
--- a/src/OpenClaw.WinNode.Cli/skill.md
+++ b/src/OpenClaw.WinNode.Cli/skill.md
@@ -339,6 +339,62 @@ Search the command palette and return matching commands.
```
Returns array of `{ Title, Subtitle, Icon }`.
+## Location (location.*)
+
+### location.get
+Get the device's current geographic location.
+```
+{
+ "accuracy": "default|high", // optional, default "default"
+ "maxAge": 30000, // ms; return a cached fix if younger than this
+ "locationTimeout": 10000 // ms; fail if no fix within this time
+}
+```
+Returns `{ latitude, longitude, accuracy (meters), timestamp (ms) }`.
+Requires the Location capability to be enabled and OS location permission granted to the app.
+Error `LOCATION_PERMISSION_REQUIRED` if the user has not granted location access.
+
+## Device (device.*)
+
+### device.info
+Get static device metadata. No params.
+Returns `{ deviceName, modelIdentifier, systemName, systemVersion, appVersion, appBuild, locale }`.
+
+### device.status
+Get live system health data.
+```
+{
+ "sections": ["os","cpu","memory","disk","battery"] // optional; omit for all
+}
+```
+Returns a map with `collectedAt` (ISO-8601 string) and one key per section.
+Each section may contain `{ error: "collection failed" }` if data was unavailable.
+Legacy fields always present: `thermal`, `storage`, `network`, `uptimeSeconds`.
+
+Battery sub-object: `{ level, state ("charging"|"discharging"|"unknown"), lowPowerModeEnabled }`.
+
+**Privacy note**: `device.status` reveals battery level, network type, and disk usage.
+Agents should request only the sections they need.
+
+## Browser control proxy (browser.*)
+
+### browser.proxy
+Proxy an HTTP request to the local OpenClaw browser control host (Chrome DevTools Protocol server) running on gateway port + 2.
+```
+{
+ "path": "/json/list", // required — local control path
+ "method": "GET", // optional, default GET; allowed: GET|POST|DELETE
+ "body": {}, // JSON object, for POST/DELETE
+ "query": {}, // appended as query-string params
+ "profile": "Default", // optional browser profile name
+ "timeoutMs": 20000 // optional, max 120000
+}
+```
+Returns `{ result, files? }` — `files` is an array of `{ path, base64, mimeType }` if the response referenced local file paths.
+
+Requires the gateway URL to have an explicit port (e.g. `ws://localhost:8080`).
+The browser control host must be running locally on `127.0.0.1:`.
+
---
## A2UI v0.8 grammar (for canvas.a2ui.push)
From ea0c95afdf4e9cf99ae21d0cd500e0974eb20408 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 13:53:56 -0700
Subject: [PATCH 48/52] mxc: revert added env scrub from BuildEnv (redundant
with ExecEnvSanitizer)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The second env scrub I added at the wxc-exec boundary was redundant.
SystemCapability.HandleRunAsync already runs ExecEnvSanitizer.Sanitize
at the front door for every system.run and rejects the whole command
if any dangerous env var is present — by the time MxcConfigBuilder.BuildEnv
sees the env, it's already been validated. Adding a second filter was
dead code and conflated two separate questions (which list to use, and
where in the pipeline to scrub).
Reverted:
- MxcConfigBuilder.BuildEnv: drops HostEnvSecurityPolicy dependency,
back to agent-env pass-through + structural validity (NUL/CR/LF/'=')
+ TEMP/TMP/TMPDIR scratch override.
- Removed src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.{cs,json,md}
(not used anywhere else).
- Removed tests/OpenClaw.Shared.Tests/Mxc/HostEnvSecurityPolicyTests.cs.
- Removed the redundant Build_BlocksDangerousAgentEnv assertion from
MxcConfigBuilderTests.
Net architecture: one env scrub in the chain, in the existing place
(ExecEnvSanitizer at SystemCapability boundary). Whether to upgrade
ExecEnvSanitizer's hand-curated list to the canonical openclaw policy
is a separate question that doesn't need to land in this PR.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Mxc/HostEnvSecurityPolicy.cs | 123 --------
.../Mxc/HostEnvSecurityPolicy.json | 274 ------------------
.../Mxc/HostEnvSecurityPolicy.md | 47 ---
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 43 +--
src/OpenClaw.Shared/OpenClaw.Shared.csproj | 7 -
.../Mxc/HostEnvSecurityPolicyTests.cs | 85 ------
.../Mxc/MxcConfigBuilderTests.cs | 28 --
7 files changed, 22 insertions(+), 585 deletions(-)
delete mode 100644 src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
delete mode 100644 src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json
delete mode 100644 src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
delete mode 100644 tests/OpenClaw.Shared.Tests/Mxc/HostEnvSecurityPolicyTests.cs
diff --git a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
deleted file mode 100644
index 8c2fd8d11..000000000
--- a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.cs
+++ /dev/null
@@ -1,123 +0,0 @@
-using System.Reflection;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace OpenClaw.Shared.Mxc;
-
-///
-/// Host env security policy: which environment variables an executor must
-/// refuse to set on a spawned child process.
-///
-///
-/// Provenance. The policy data lives upstream in
-/// openclaw/openclaw at
-/// src/infra/host-env-security-policy.json. We keep a byte-identical
-/// copy at src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json embedded
-/// as an assembly resource. When the upstream JSON changes, re-copy and rerun
-/// the HostEnvSecurityPolicyTests tests.
-/// Why we enforce this on the Windows node side. openclaw does
-/// not centralize env scrubbing at "the gateway"; it scrubs at every exec
-/// boundary as defense-in-depth (see CHANGELOG references like *"on both
-/// node host and macOS companion paths"*). This class is the Windows-node
-/// analog of the macOS consumer
-/// apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift +
-/// HostEnvSecurityPolicy.generated.swift. The macOS code is generated
-/// from the same JSON via scripts/generate-host-env-security-policy-swift.mjs;
-/// we just load the JSON directly at runtime instead of code-generating.
-/// Threat model. Agent-supplied env in
-/// is untrusted. For our sandbox-boundary
-/// purposes, a key is "blocked" if it appears in any of the policy's block
-/// sets (blockedEverywhereKeys ∪ blockedOverrideOnlyKeys) OR if
-/// it starts with any blocked prefix (blockedPrefixes ∪
-/// blockedOverridePrefixes), all case-insensitive. We merge the
-/// "everywhere" and "override-only" buckets because for the agent we are the
-/// override path — they're setting env explicitly, not inheriting it from
-/// host process state.
-///
-public sealed class HostEnvSecurityPolicy
-{
- public static HostEnvSecurityPolicy Default { get; } = LoadEmbedded();
-
- private readonly HashSet _blocked;
- private readonly string[] _blockedPrefixes;
-
- public IReadOnlyCollection BlockedKeys => _blocked;
- public IReadOnlyList BlockedPrefixes => _blockedPrefixes;
-
- private HostEnvSecurityPolicy(HashSet blocked, string[] blockedPrefixes)
- {
- _blocked = blocked;
- _blockedPrefixes = blockedPrefixes;
- }
-
- ///
- /// True if the agent must not be allowed to set
- /// in the sandbox env. Combines blockedEverywhereKeys,
- /// blockedOverrideOnlyKeys, blockedPrefixes, and
- /// blockedOverridePrefixes from the canonical JSON.
- ///
- public bool IsBlocked(string name)
- {
- if (string.IsNullOrEmpty(name)) return true;
- foreach (var ch in name)
- {
- if (ch == '=' || ch == '\0' || ch == '\r' || ch == '\n') return true;
- }
- if (_blocked.Contains(name)) return true;
- foreach (var prefix in _blockedPrefixes)
- {
- if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- return true;
- }
- return false;
- }
-
- private static HostEnvSecurityPolicy LoadEmbedded()
- {
- var asm = typeof(HostEnvSecurityPolicy).Assembly;
- var resourceName = asm.GetManifestResourceNames()
- .FirstOrDefault(n => n.EndsWith("HostEnvSecurityPolicy.json", StringComparison.Ordinal))
- ?? throw new InvalidOperationException("HostEnvSecurityPolicy.json embedded resource not found.");
- using var stream = asm.GetManifestResourceStream(resourceName)
- ?? throw new InvalidOperationException($"Failed to open embedded resource {resourceName}.");
- var doc = JsonSerializer.Deserialize(stream, JsonOpts)
- ?? throw new InvalidOperationException("HostEnvSecurityPolicy.json was empty or malformed.");
-
- // Agent env scrub merges the everywhere-blocked set with the
- // override-blocked set (for our purposes, the agent is the override
- // path — they are setting env explicitly, not inheriting it).
- var blocked = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var k in doc.BlockedEverywhereKeys ?? Array.Empty()) blocked.Add(k);
- foreach (var k in doc.BlockedOverrideOnlyKeys ?? Array.Empty()) blocked.Add(k);
-
- // Same logic for prefixes: agent override path blocks both prefix sets.
- var prefixSet = new HashSet(StringComparer.OrdinalIgnoreCase);
- foreach (var p in doc.BlockedPrefixes ?? Array.Empty()) prefixSet.Add(p);
- foreach (var p in doc.BlockedOverridePrefixes ?? Array.Empty()) prefixSet.Add(p);
-
- return new HostEnvSecurityPolicy(blocked, prefixSet.ToArray());
- }
-
- private static readonly JsonSerializerOptions JsonOpts = new()
- {
- PropertyNameCaseInsensitive = true,
- };
-
- private sealed record PolicyDocument
- {
- [JsonPropertyName("blockedEverywhereKeys")]
- public string[]? BlockedEverywhereKeys { get; init; }
-
- [JsonPropertyName("blockedOverrideOnlyKeys")]
- public string[]? BlockedOverrideOnlyKeys { get; init; }
-
- [JsonPropertyName("blockedPrefixes")]
- public string[]? BlockedPrefixes { get; init; }
-
- [JsonPropertyName("blockedOverridePrefixes")]
- public string[]? BlockedOverridePrefixes { get; init; }
-
- [JsonPropertyName("allowedInheritedOverrideOnlyKeys")]
- public string[]? AllowedInheritedOverrideOnlyKeys { get; init; }
- }
-}
diff --git a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json
deleted file mode 100644
index e1eedafbd..000000000
--- a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json
+++ /dev/null
@@ -1,274 +0,0 @@
-{
- "blockedEverywhereKeys": [
- "NODE_OPTIONS",
- "NODE_PATH",
- "PYTHONHOME",
- "PYTHONPATH",
- "PERL5LIB",
- "PERL5OPT",
- "RUBYLIB",
- "RUBYOPT",
- "BASH_ENV",
- "ENV",
- "BROWSER",
- "GIT_EDITOR",
- "GIT_EXTERNAL_DIFF",
- "GIT_DIR",
- "GIT_WORK_TREE",
- "GIT_COMMON_DIR",
- "GIT_EXEC_PATH",
- "GIT_INDEX_FILE",
- "GIT_OBJECT_DIRECTORY",
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
- "GIT_NAMESPACE",
- "GIT_SEQUENCE_EDITOR",
- "GIT_TEMPLATE_DIR",
- "GIT_SSL_NO_VERIFY",
- "GIT_SSL_CAINFO",
- "GIT_SSL_CAPATH",
- "CC",
- "CXX",
- "CARGO_BUILD_RUSTC",
- "CARGO_BUILD_RUSTC_WRAPPER",
- "RUSTC_WRAPPER",
- "CMAKE_C_COMPILER",
- "CMAKE_CXX_COMPILER",
- "SHELL",
- "SHELLOPTS",
- "PS4",
- "GCONV_PATH",
- "IFS",
- "SSLKEYLOGFILE",
- "JAVA_OPTS",
- "JAVA_TOOL_OPTIONS",
- "_JAVA_OPTIONS",
- "JDK_JAVA_OPTIONS",
- "PYTHONBREAKPOINT",
- "DOTNET_STARTUP_HOOKS",
- "DOTNET_ADDITIONAL_DEPS",
- "GLIBC_TUNABLES",
- "MAVEN_OPTS",
- "MAKEFLAGS",
- "MFLAGS",
- "SBT_OPTS",
- "GRADLE_OPTS",
- "ANT_OPTS",
- "HGRCPATH",
- "EXINIT",
- "VIMINIT",
- "MYVIMRC",
- "GVIMINIT",
- "LUA_INIT",
- "LUA_INIT_5_1",
- "LUA_INIT_5_2",
- "LUA_INIT_5_3",
- "LUA_INIT_5_4",
- "EMACSLOADPATH",
- "RUBYSHELL",
- "GIT_HOOK_PATH",
- "SVN_EDITOR",
- "SVN_SSH",
- "BZR_EDITOR",
- "BZR_SSH",
- "BZR_PLUGIN_PATH",
- "SUDO_ASKPASS",
- "JULIA_EDITOR",
- "CONFIG_SITE",
- "CONFIG_SHELL",
- "CMAKE_TOOLCHAIN_FILE",
- "CATALINA_OPTS",
- "CORECLR_PROFILER",
- "HELM_PLUGINS",
- "PACKER_PLUGIN_PATH",
- "VAGRANT_VAGRANTFILE",
- "ERL_AFLAGS",
- "ERL_FLAGS",
- "ERL_ZFLAGS",
- "ELIXIR_ERL_OPTIONS",
- "R_ENVIRON",
- "R_PROFILE",
- "R_ENVIRON_USER",
- "R_PROFILE_USER",
- "HOSTALIASES"
- ],
- "blockedOverrideOnlyKeys": [
- "HOME",
- "GRADLE_USER_HOME",
- "ZDOTDIR",
- "GIT_DIR",
- "GIT_WORK_TREE",
- "GIT_COMMON_DIR",
- "GIT_INDEX_FILE",
- "GIT_OBJECT_DIRECTORY",
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
- "GIT_NAMESPACE",
- "GIT_SSH_COMMAND",
- "GIT_SSH",
- "GIT_PROXY_COMMAND",
- "GIT_ASKPASS",
- "GIT_SSL_NO_VERIFY",
- "GIT_SSL_CAINFO",
- "GIT_SSL_CAPATH",
- "SSH_ASKPASS",
- "LESSOPEN",
- "LESSCLOSE",
- "PAGER",
- "MANPAGER",
- "GIT_PAGER",
- "EDITOR",
- "VISUAL",
- "FCEDIT",
- "SUDO_EDITOR",
- "PROMPT_COMMAND",
- "HISTFILE",
- "PERL5DB",
- "PERL5DBCMD",
- "OPENSSL_CONF",
- "OPENSSL_ENGINES",
- "PYTHONSTARTUP",
- "WGETRC",
- "CURL_HOME",
- "CLASSPATH",
- "CFLAGS",
- "CGO_CFLAGS",
- "CGO_LDFLAGS",
- "GOFLAGS",
- "MAKEFLAGS",
- "MFLAGS",
- "CORECLR_PROFILER_PATH",
- "PHPRC",
- "PHP_INI_SCAN_DIR",
- "DENO_DIR",
- "BUN_CONFIG_REGISTRY",
- "YARN_RC_FILENAME",
- "HTTP_PROXY",
- "HTTPS_PROXY",
- "ALL_PROXY",
- "NO_PROXY",
- "NODE_TLS_REJECT_UNAUTHORIZED",
- "NODE_EXTRA_CA_CERTS",
- "SSL_CERT_FILE",
- "SSL_CERT_DIR",
- "REQUESTS_CA_BUNDLE",
- "CURL_CA_BUNDLE",
- "DOCKER_HOST",
- "DOCKER_TLS_VERIFY",
- "DOCKER_CERT_PATH",
- "PIP_INDEX_URL",
- "PIP_PYPI_URL",
- "PIP_EXTRA_INDEX_URL",
- "PIP_CONFIG_FILE",
- "PIP_FIND_LINKS",
- "PIP_TRUSTED_HOST",
- "UV_INDEX",
- "UV_INDEX_URL",
- "UV_PYTHON",
- "UV_EXTRA_INDEX_URL",
- "UV_DEFAULT_INDEX",
- "DOCKER_CONTEXT",
- "LIBRARY_PATH",
- "LDFLAGS",
- "CPATH",
- "C_INCLUDE_PATH",
- "CPLUS_INCLUDE_PATH",
- "OBJC_INCLUDE_PATH",
- "GOPROXY",
- "GONOSUMCHECK",
- "GONOSUMDB",
- "GONOPROXY",
- "GOPRIVATE",
- "GOENV",
- "GOPATH",
- "HGRCPATH",
- "PYTHONUSERBASE",
- "RUSTC_WRAPPER",
- "RUSTFLAGS",
- "CARGO_HOME",
- "VIRTUAL_ENV",
- "LUA_PATH",
- "LUA_CPATH",
- "GEM_HOME",
- "GEM_PATH",
- "BUNDLE_GEMFILE",
- "COMPOSER_HOME",
- "CARGO_BUILD_RUSTC_WRAPPER",
- "XDG_CONFIG_HOME",
- "XDG_CONFIG_DIRS",
- "AWS_CONFIG_FILE",
- "KUBECONFIG",
- "GOOGLE_APPLICATION_CREDENTIALS",
- "AWS_SHARED_CREDENTIALS_FILE",
- "AWS_WEB_IDENTITY_TOKEN_FILE",
- "AZURE_AUTH_LOCATION",
- "HELM_HOME",
- "ANSIBLE_CONFIG",
- "ANSIBLE_LIBRARY",
- "ANSIBLE_CALLBACK_PLUGINS",
- "ANSIBLE_COLLECTIONS_PATH",
- "ANSIBLE_CONNECTION_PLUGINS",
- "ANSIBLE_FILTER_PLUGINS",
- "ANSIBLE_INVENTORY_PLUGINS",
- "ANSIBLE_LOOKUP_PLUGINS",
- "ANSIBLE_MODULE_UTILS",
- "ANSIBLE_REMOTE_TEMP",
- "ANSIBLE_ROLES_PATH",
- "ANSIBLE_STRATEGY_PLUGINS",
- "R_LIBS_USER",
- "TF_CLI_CONFIG_FILE",
- "TF_PLUGIN_CACHE_DIR",
- "AMQP_URL",
- "AWS_ACCESS_KEY_ID",
- "AWS_CONTAINER_CREDENTIALS_FULL_URI",
- "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
- "AWS_SECRET_ACCESS_KEY",
- "AWS_SECURITY_TOKEN",
- "AWS_SESSION_TOKEN",
- "AZURE_CLIENT_ID",
- "AZURE_CLIENT_SECRET",
- "DATABASE_URL",
- "GH_TOKEN",
- "GITHUB_TOKEN",
- "GITLAB_TOKEN",
- "MONGODB_URI",
- "NODE_AUTH_TOKEN",
- "NPM_TOKEN",
- "REDIS_URL",
- "SSH_AUTH_SOCK",
- "SYSTEMROOT",
- "WINDIR"
- ],
- "allowedInheritedOverrideOnlyKeys": [
- "ALL_PROXY",
- "AWS_CONFIG_FILE",
- "AWS_SHARED_CREDENTIALS_FILE",
- "AWS_WEB_IDENTITY_TOKEN_FILE",
- "AZURE_AUTH_LOCATION",
- "CURL_CA_BUNDLE",
- "DOCKER_CERT_PATH",
- "DOCKER_CONTEXT",
- "DOCKER_HOST",
- "DOCKER_TLS_VERIFY",
- "GIT_PAGER",
- "GOOGLE_APPLICATION_CREDENTIALS",
- "GRADLE_USER_HOME",
- "HISTFILE",
- "HOME",
- "HTTPS_PROXY",
- "HTTP_PROXY",
- "KUBECONFIG",
- "MANPAGER",
- "NODE_EXTRA_CA_CERTS",
- "NODE_TLS_REJECT_UNAUTHORIZED",
- "NO_PROXY",
- "PAGER",
- "REQUESTS_CA_BUNDLE",
- "SSH_AUTH_SOCK",
- "SSL_CERT_DIR",
- "SSL_CERT_FILE",
- "SYSTEMROOT",
- "WINDIR",
- "ZDOTDIR"
- ],
- "blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_", "CARGO_REGISTRIES_", "TF_VAR_"],
- "blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"]
-}
diff --git a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md b/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
deleted file mode 100644
index 4706f11cb..000000000
--- a/src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# HostEnvSecurityPolicy
-
-## What this is
-
-`HostEnvSecurityPolicy.json` is a **byte-identical copy** of
-`openclaw/openclaw:src/infra/host-env-security-policy.json` — the canonical
-list of environment variables an executor must refuse to set on a spawned
-child process. It is consumed by `HostEnvSecurityPolicy.cs` (embedded as an
-assembly resource) and used in `MxcConfigBuilder.BuildEnv` to filter
-agent-supplied env before it reaches `wxc-exec.exe`.
-
-## Why we ship a copy
-
-openclaw enforces env scrubbing at every spawn boundary as defense-in-depth,
-not centrally at the gateway. The macOS app does this via
-`apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift` (consumer) +
-`HostEnvSecurityPolicy.generated.swift` (data, generated from the same JSON
-by `scripts/generate-host-env-security-policy-swift.mjs`). We are the
-Windows-node analog: same role, same data source. Loading the JSON directly
-at runtime instead of code-generating is the only divergence.
-
-## Update workflow
-
-When the upstream JSON changes:
-
-```powershell
-# 1. Copy the latest from openclaw/openclaw
-cp /src/infra/host-env-security-policy.json `
- src/OpenClaw.Shared/Mxc/HostEnvSecurityPolicy.json
-
-# 2. Re-run the policy tests (catch truncation / drift)
-dotnet test ./tests/OpenClaw.Shared.Tests --filter "FullyQualifiedName~HostEnvSecurityPolicy"
-```
-
-`HostEnvSecurityPolicyTests` asserts a minimum size (≥200 blocked keys,
-≥3 prefixes) plus the presence of well-known entries (`GITHUB_TOKEN`,
-`LD_PRELOAD`, etc.) so an accidentally truncated or stale copy fails fast.
-
-## Schema reference
-
-| Key | Used by us | Meaning |
-|---|---|---|
-| `blockedEverywhereKeys` | ✅ blocked | always block, in both host inheritance and agent overrides |
-| `blockedOverrideOnlyKeys` | ✅ blocked | block when set explicitly by the caller (we always treat agent env as an "override") |
-| `blockedPrefixes` | ✅ blocked | block keys matching these prefixes (`LD_`, `DYLD_`, `BASH_FUNC_`) |
-| `blockedOverridePrefixes` | ✅ blocked | block override-only prefixes (`GIT_CONFIG_`, `NPM_CONFIG_`, `CARGO_REGISTRIES_`, `TF_VAR_`) |
-| `allowedInheritedOverrideOnlyKeys` | ❌ not used | narrow allow-list for vars that override-blocked but inheritance-allowed; only meaningful when a process inherits host env (we don't inherit, agent supplies env explicitly) |
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
index 7fffbd981..a3367ca04 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -214,29 +214,19 @@ private static bool IsDriveRoot(string dir)
/// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit.
///
///
- /// What flows in: only the agent-supplied
- /// . The host env is intentionally NOT
- /// allow-listed in — the agent owns env-var policy and decides what to
- /// pass. TEMP/TMP/TMPDIR are then forced to
- /// so any tool inside the sandbox writes scratch files into our
- /// throwaway dir, not the user's real %TEMP%.
- /// Why we scrub: openclaw's exec security model sanitizes
- /// at every spawn boundary, not just at "the gateway". This mirrors
- /// the macOS consumer HostEnvSanitizer.sanitize
- /// (apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift) that runs
- /// inside ExecApprovalEvaluation.swift. We are the Windows-node
- /// analog: every system.run we forward to wxc-exec gets the
- /// canonical openclaw blocklist applied so the agent can't smuggle in
- /// vars like NODE_OPTIONS, GITHUB_TOKEN, LD_PRELOAD,
- /// GIT_SSH_COMMAND, or
- /// BASH_FUNC_*/DYLD_*/LD_* prefixes.
+ /// What flows in: agent-supplied
+ /// only — by the time we get here, SystemCapability.HandleRunAsync
+ /// has already run env through ExecEnvSanitizer.Sanitize at the
+ /// front door and rejected the command if any dangerous vars were present.
+ /// We don't scrub again. TEMP/TMP/TMPDIR are then forced to
+ /// so any tool inside the sandbox writes
+ /// scratch files into our throwaway dir, not the user's real
+ /// %TEMP%.
///
public static IReadOnlyList BuildEnv(
IReadOnlyDictionary? requestEnv,
- string scratchDir,
- HostEnvSecurityPolicy? policy = null)
+ string scratchDir)
{
- policy ??= HostEnvSecurityPolicy.Default;
// 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);
@@ -245,8 +235,19 @@ public static IReadOnlyList BuildEnv(
{
foreach (var (name, value) in requestEnv)
{
- if (value is null) continue;
- if (policy.IsBlocked(name)) continue;
+ 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;
}
}
diff --git a/src/OpenClaw.Shared/OpenClaw.Shared.csproj b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
index be0d40746..82b057ea2 100644
--- a/src/OpenClaw.Shared/OpenClaw.Shared.csproj
+++ b/src/OpenClaw.Shared/OpenClaw.Shared.csproj
@@ -11,13 +11,6 @@
-
-
-
-
-
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/HostEnvSecurityPolicyTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/HostEnvSecurityPolicyTests.cs
deleted file mode 100644
index fac3fecaf..000000000
--- a/tests/OpenClaw.Shared.Tests/Mxc/HostEnvSecurityPolicyTests.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using Xunit;
-using OpenClaw.Shared.Mxc;
-
-namespace OpenClaw.Shared.Tests.Mxc;
-
-///
-/// Tests for . The policy is loaded from
-/// the embedded canonical JSON copied from
-/// openclaw/openclaw:src/infra/host-env-security-policy.json.
-///
-public class HostEnvSecurityPolicyTests
-{
- [Theory]
- // Common credential vars (in blockedEverywhereKeys or blockedOverrideOnlyKeys).
- [InlineData("GITHUB_TOKEN")]
- [InlineData("AWS_ACCESS_KEY_ID")]
- [InlineData("AWS_SECRET_ACCESS_KEY")]
- [InlineData("AZURE_CLIENT_SECRET")]
- [InlineData("NPM_TOKEN")]
- [InlineData("GH_TOKEN")]
- // Code-injection vectors.
- [InlineData("NODE_OPTIONS")]
- [InlineData("NODE_PATH")]
- [InlineData("PYTHONPATH")]
- [InlineData("PYTHONSTARTUP")]
- [InlineData("RUBYOPT")]
- [InlineData("PERL5OPT")]
- [InlineData("BASH_ENV")]
- [InlineData("ENV")]
- // Git command-overrides.
- [InlineData("GIT_SSH_COMMAND")]
- [InlineData("GIT_EXTERNAL_DIFF")]
- [InlineData("GIT_ASKPASS")]
- public void IsBlocked_True_ForCanonicalListedVars(string name)
- {
- Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name),
- $"Expected {name} to be in the canonical openclaw blocklist.");
- }
-
- [Theory]
- // Prefix-based vectors (case-insensitive).
- [InlineData("LD_PRELOAD")]
- [InlineData("LD_LIBRARY_PATH")]
- [InlineData("DYLD_INSERT_LIBRARIES")]
- [InlineData("BASH_FUNC_foo%%")]
- [InlineData("ld_preload")] // lowercase should still match
- public void IsBlocked_True_ForBlockedPrefixes(string name)
- {
- Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name));
- }
-
- [Theory]
- // Malformed names — must not allow smuggling KEY=VAL pairs in.
- [InlineData("")]
- [InlineData("FOO=BAR")]
- [InlineData("FOO\0BAR")]
- [InlineData("FOO\nBAR")]
- [InlineData("FOO\rBAR")]
- public void IsBlocked_True_ForMalformedNames(string name)
- {
- Assert.True(HostEnvSecurityPolicy.Default.IsBlocked(name));
- }
-
- [Theory]
- // Names that should NOT be blocked — passed through to the sandbox.
- [InlineData("FOO_BAR")]
- [InlineData("MY_APP_CONFIG")]
- [InlineData("BUILD_NUMBER")]
- public void IsBlocked_False_ForBenignNames(string name)
- {
- Assert.False(HostEnvSecurityPolicy.Default.IsBlocked(name));
- }
-
- [Fact]
- public void Default_LoadsAllPolicyCategories()
- {
- // The canonical JSON has 90+ blockedEverywhereKeys and 144+ blockedOverrideOnlyKeys;
- // expect at minimum ~200 entries combined and at least 3 blocked prefixes.
- var policy = HostEnvSecurityPolicy.Default;
- Assert.True(policy.BlockedKeys.Count >= 200,
- $"Expected at least 200 blocked keys, got {policy.BlockedKeys.Count}");
- Assert.True(policy.BlockedPrefixes.Count >= 3,
- $"Expected at least 3 blocked prefixes, got {policy.BlockedPrefixes.Count}");
- }
-}
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
index bb727f074..be5923091 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcConfigBuilderTests.cs
@@ -199,34 +199,6 @@ public void Build_AddsScratchDirToReadwritePaths()
Assert.Contains(P.Scratch, config.Filesystem!.ReadwritePaths!);
}
- [Fact]
- public void Build_BlocksDangerousAgentEnv_PerCanonicalOpenclawPolicy()
- {
- // Agent attempts to inject env vars on the canonical openclaw blocklist
- // (NODE_OPTIONS, GITHUB_TOKEN, LD_PRELOAD). The builder must drop them.
- var request = RequestFor(BalancedPolicy()) with
- {
- Env = new Dictionary
- {
- ["NODE_OPTIONS"] = "--inspect-brk=0.0.0.0:1234",
- ["GITHUB_TOKEN"] = "ghp_FAKE",
- ["LD_PRELOAD"] = "/tmp/evil.so",
- ["DYLD_INSERT_LIBRARIES"] = "/tmp/evil.dylib",
- ["GIT_SSH_COMMAND"] = "ssh -o ProxyCommand=evil",
- ["MY_OK_VAR"] = "passthrough",
- },
- };
- var config = MxcConfigBuilder.Build(request, P.Scratch, pathEnvVar: "");
- var envKeys = config.Process.Env!.Select(s => s.Split('=', 2)[0]).ToArray();
-
- Assert.DoesNotContain("NODE_OPTIONS", envKeys);
- Assert.DoesNotContain("GITHUB_TOKEN", envKeys);
- Assert.DoesNotContain("LD_PRELOAD", envKeys);
- Assert.DoesNotContain("DYLD_INSERT_LIBRARIES", envKeys);
- Assert.DoesNotContain("GIT_SSH_COMMAND", envKeys);
- Assert.Contains("MY_OK_VAR", envKeys);
- }
-
[Fact]
public void Build_OverridesTempEnvVarsToScratch()
{
From 7900589472bfc20eb8d68a25da641d2a0902cea3 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 14:03:47 -0700
Subject: [PATCH 49/52] docs(mxc): clean up stale comments
The doc comments in MxcConfigBuilder, DirectAppContainerExecutor,
ISandboxExecutor, and ShellCommandLine still referenced things that no
longer exist after the recent revert: the env scrub at this layer (now
in ExecEnvSanitizer at the front door), the JS bridge (deleted), the
old tool-name whitelist (replaced with 'walk all PATH dirs'), and the
OrcaCore.Services namespace (collapsed into OpenClaw.Shared.Mxc).
Rewrites:
- MxcConfigBuilder class summary: lists what the builder actually does
today (translate SandboxPolicy + request -> MxcConfig, with the four
necessary additions: PATH dirs, scratch, cwd auto-grant, deny
re-filter). Explicitly notes env scrub happens upstream.
- MxcConfigBuilder.Build inline comments: trim and reword to be clear
about WHY each addition exists.
- ResolvePathDirsForReadonly doc: short version, no SDK-bug archeology.
- BuildEnv doc: short version, points at ExecEnvSanitizer for the actual
scrub.
- DirectAppContainerExecutor: drop OrcaCore.Services and 'previously
split with the JS bridge' references.
- ShellCommandLine: describe what it does instead of citing the deleted
JS implementation.
- SandboxExecutionRequest.MaxOutputBytes: drop run-command.cjs mention.
No behavior change; doc comments only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Mxc/DirectAppContainerExecutor.cs | 8 +-
src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs | 5 +-
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 82 +++++++++----------
3 files changed, 45 insertions(+), 50 deletions(-)
diff --git a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
index 026e289db..7818393ff 100644
--- a/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
+++ b/src/OpenClaw.Shared/Mxc/DirectAppContainerExecutor.cs
@@ -6,13 +6,11 @@
namespace OpenClaw.Shared.Mxc;
///
-/// Implements by invoking wxc-exec.exe
-/// directly via the from OrcaCore.Services.
-/// Replaces OneShotAppContainerExecutor + tools/mxc/run-command.cjs;
-/// no Node.js runtime required.
+/// Implements by spawning wxc-exec.exe
+/// directly via . No Node.js runtime required.
///
///
-/// Responsibilities owned here (previously split with the JS bridge):
+/// Responsibilities:
///
/// - Per-invocation scratch dir lifecycle.
/// - Logging the final before sending — structured
diff --git a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
index d78315e5f..69a59f53b 100644
--- a/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
+++ b/src/OpenClaw.Shared/Mxc/ISandboxExecutor.cs
@@ -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/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
index a3367ca04..2f421a544 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -5,29 +5,32 @@ namespace OpenClaw.Shared.Mxc;
///
/// Pure function: + scratch directory →
/// for direct invocation of wxc-exec.exe.
-/// Replaces the SDK's createConfigFromPolicy + JS-bridge merge pipeline.
///
///
-/// What this class owns (previously split between SDK + JS bridge):
+/// What this class does:
///
-/// - — PATH-derived tool dirs (git/pwsh/python/...)
-/// that replace the SDK's getAvailableToolsPolicy. Whitelist-only —
-/// never adds drive roots.
-/// - Scratch dir injection — overrides TEMP/TMP/TMPDIR so commands write into
-/// our per-invocation directory, not the user's real %TEMP%.
-/// - Env allowlist + credential / shell-injection scrub.
+/// - 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).
-/// - Cwd auto-grant — adds request.Cwd to readonly paths when not
-/// already nested in a grant. AppContainer does NOT auto-grant the cwd.
-/// - Defensive re-filter of allow-lists against deny-list.
///
+/// Env scrubbing happens upstream in SystemCapability.HandleRunAsync
+/// via ExecEnvSanitizer.Sanitize; this class doesn't scrub env.
///
public static class MxcConfigBuilder
{
///
- /// Default per-process timeout in milliseconds when the caller doesn't
- /// supply one. Mirrors the MXC SDK's SandboxPolicy.timeoutMs default.
+ /// Default per-process timeout when the caller doesn't supply one.
///
public const int DefaultProcessTimeoutMs = 30_000;
@@ -53,16 +56,16 @@ public static MxcConfig Build(
// commandLine — shell-quoted.
var commandLine = ShellCommandLine.Build(args.Shell, args.Command, args.Argv);
- // readonly = user-configured grants + every existing PATH dir (mirrors
- // the MXC SDK's getAvailableToolsPolicy, minus drive roots which the
- // SDK itself has a documented bug around when pwsh.exe is on PATH).
+ // readonly = UI grants + every existing PATH dir (so tools like git,
+ // node, python can be read inside the sandbox). Drive roots are
+ // skipped — see ResolvePathDirsForReadonly.
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 = user grants + scratch dir.
+ // readwrite = UI grants + scratch dir.
var rwFromPolicy = (policy?.Filesystem?.ReadwritePaths ?? Array.Empty()).ToList();
if (!rwFromPolicy.Contains(scratchDir, StringComparer.OrdinalIgnoreCase))
rwFromPolicy.Add(scratchDir);
@@ -70,29 +73,28 @@ public static MxcConfig Build(
// 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,
- // so without this every command run inside the user's repo would fail
- // with permission errors. Added to readonly when not already covered by
- // an explicit allow; skipped if the cwd overlaps a deny.
+ // cwd auto-grant — AppContainer does not auto-grant the working
+ // directory. Without this, commands run inside a user's repo fail
+ // with permission errors. Skipped when cwd overlaps a denied path.
if (!string.IsNullOrWhiteSpace(request.Cwd) && !IsCoveredBy(request.Cwd, roFromPolicy.Concat(rwFromPolicy)))
{
if (!IsCoveredBy(request.Cwd, denied))
roFromPolicy.Add(request.Cwd);
}
- // Re-apply deny precedence after every merge (defense-in-depth — keeps
- // a SDK or caller bug from accidentally granting a denied subtree).
+ // 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 only, plus TEMP/TMP/TMPDIR forced to scratch.
- // No host env allow-list — the agent owns what env to pass in.
+ // env — agent-supplied vars (already scrubbed upstream by
+ // ExecEnvSanitizer in SystemCapability) plus TEMP/TMP/TMPDIR forced
+ // to scratch.
var env = BuildEnv(request.Env, scratchDir);
// timeout — caller-supplied or default.
var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : DefaultProcessTimeoutMs;
- // (8) capabilities + appContainer.ui — mirror SDK output exactly.
+ // capabilities — only network for now.
var capabilities = new List();
if (policy?.Network?.AllowOutbound == true)
capabilities.Add("internetClient");
@@ -158,12 +160,8 @@ public static MxcConfig Build(
///
/// Walk PATH and return each existing directory as a readonly grant
- /// candidate. Mirrors the SDK's getAvailableToolsPolicy
- /// (@microsoft/mxc-sdk:dist/policy.js): every existing PATH dir
- /// is granted, drive roots are skipped (the SDK has a documented bug
- /// where pwsh.exe on PATH would otherwise grant the entire system
- /// drive — we strip that the same way the legacy JS bridge did).
- /// No tool-name whitelist — the SDK doesn't have one either.
+ /// 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)
{
@@ -214,14 +212,12 @@ private static bool IsDriveRoot(string dir)
/// Build the env array (KEY=VALUE strings) the wxc-exec sandbox will inherit.
///
///
- /// What flows in: agent-supplied
- /// only — by the time we get here, SystemCapability.HandleRunAsync
- /// has already run env through ExecEnvSanitizer.Sanitize at the
- /// front door and rejected the command if any dangerous vars were present.
- /// We don't scrub again. TEMP/TMP/TMPDIR are then forced to
- /// so any tool inside the sandbox writes
- /// scratch files into our throwaway dir, not the user's real
- /// %TEMP%.
+ /// 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,
@@ -344,8 +340,10 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList<
}
///
-/// Shell command-line construction for the contained payload.
-/// Mirrors buildShellCommandLine from the legacy run-command.cjs.
+/// 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.
///
public static class ShellCommandLine
{
From 0e3b97082d85680bb5b09303e5a14c339e1a433a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 21 May 2026 14:05:30 -0700
Subject: [PATCH 50/52] loc: add x:Uid for chat and instances strings
- ChatPage.xaml: add x:Uid to WaitingPanel title/status TextBlocks and
RetryChatButton; reuse existing WebChatErrorTitle / WebChatOpenBrowserButton
keys for the error panel controls
- InstancesPage.xaml: add x:Uid to BackToConnectionLink TextBlock
- Resources.resw (all 5 locales): add ChatPage_WaitingTitle.Text,
ChatPage_WaitingStatusText.Text, and InstancesPage_BackToConnectionText.Text
Reduces hard-coded XAML string warnings from 286 to 280.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml | 11 ++++++-----
src/OpenClaw.Tray.WinUI/Pages/InstancesPage.xaml | 2 +-
src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw | 9 +++++++++
src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw | 9 +++++++++
src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw | 9 +++++++++
src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw | 9 +++++++++
src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw | 9 +++++++++
7 files changed, 52 insertions(+), 6 deletions(-)
diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
index 2b0ec8fcd..cdb5b7189 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
+++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml
@@ -72,15 +72,16 @@
Visibility="Collapsed"
VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
-
-
-
@@ -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 927be39b4830ed18b7ea91316d966f3bcdea537c Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 14:26:28 -0700
Subject: [PATCH 51/52] mxc: delete UnavailableSandboxExecutor (dead since #494
fallback)
The runner's top-level !_isSandboxAvailable() guard already routes to
the host fallback for every call when MXC isn't available, so the
ISandboxExecutor passed in for that case is never invoked. The
UnavailableSandboxExecutor that always-throws-SandboxUnavailableException
was the placeholder injected before the #494 fix; it's now dead code.
- Deleted src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs.
- NodeService.BuildSystemRunRunner: always constructs
DirectAppContainerExecutor (one less branch, one less variable).
Diagnostic log still reports MXC unavailability when applicable.
- MxcCommandRunnerTests.RunAsync_UnavailableExecutor_FallsBackToHost:
switched from UnavailableSandboxExecutor to
FakeSandboxExecutor { ThrowsUnavailable=true }; same coverage.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Mxc/UnavailableSandboxExecutor.cs | 29 ----------------
.../Services/NodeService.cs | 34 +++++++++----------
.../Mxc/MxcCommandRunnerTests.cs | 6 +++-
3 files changed, 21 insertions(+), 48 deletions(-)
delete mode 100644 src/OpenClaw.Shared/Mxc/UnavailableSandboxExecutor.cs
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/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index 0845e94ee..50586b611 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -499,35 +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)
+ 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 = string.Join("; ", availability.UnsupportedReasons);
- executor = new UnavailableSandboxExecutor(reason);
- _logger.Info($"[mxc] system.run runner = MxcCommandRunner (MXC unavailable: {reason})");
- }
- else
- {
- executor = new DirectAppContainerExecutor(availability, _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/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
index 9d6cd232c..57a87d2a0 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
@@ -382,7 +382,11 @@ public async Task RunAsync_UnavailableExecutor_FallsBackToHost()
{
// Issue #494: executor reports unavailable at runtime → fall back to
// host runner with a warning, not a -1 deny.
- var executor = new UnavailableSandboxExecutor("test: MXC not installed");
+ var executor = new FakeSandboxExecutor
+ {
+ ThrowsUnavailable = true,
+ UnavailableReason = "test: MXC not installed",
+ };
var fallback = new FakeCommandRunner
{
Result = new CommandResult { ExitCode = 0, Stdout = "host" },
From 35c54c9e0c48522746ac8b9b0a26c6111b7fff23 Mon Sep 17 00:00:00 2001
From: "bakudies@microsoft.com"
Date: Thu, 21 May 2026 14:38:23 -0700
Subject: [PATCH 52/52] mxc: small simplifications across the directory
Six small cleanups that together drop ~95 lines of dead code, dead
fields, dead comments, and one-use helper types. No behavior change
except the diagnostic log no longer carries a redundant
securityLevel preset label.
1. MxcCommandRunner: delete DetectPreset + MatchesPreset (~50 lines).
They existed only to put a LockedDown|Balanced|Permissive|Custom
label on the diagnostic log, hardcoding preset thresholds that had
to track SandboxPage. The log already emits all the underlying
settings; the label was redundant.
2. ShellCommandLine class in MxcConfigBuilder.cs: changed from public
to internal -- only MxcConfigBuilder.Build calls it.
3. MxcAppContainer.Name field: deleted. Deprecated in the SDK since
0.4.0-alpha and never set on our side.
4. MxcFilesystem.ExecutablePath field: deleted. Not in any SDK schema
and never set on our side.
5. Stripped Additive (OpenClaw) comments throughout MxcConfig.cs.
They tagged fields as "additive vs the original OrcaCore seed
file"; since we deleted the OrcaCore claim the distinction is gone.
6. MxcArchHelper: inlined as a private static method of
MxcAvailability.
Tests: dropped the securityLevel:"Custom" assertion in
RunAsync_LogsSandboxSettingsSnapshotAndPolicy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/OpenClaw.Shared/Mxc/MxcAvailability.cs | 9 ++---
src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs | 34 -------------------
src/OpenClaw.Shared/Mxc/MxcConfig.cs | 28 +--------------
src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs | 2 +-
.../Mxc/MxcCommandRunnerTests.cs | 1 -
5 files changed, 5 insertions(+), 69 deletions(-)
diff --git a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
index eeae6eaa1..ea566b150 100644
--- a/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcAvailability.cs
@@ -153,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,
@@ -181,12 +181,9 @@ private static (bool resolvable, string? path) ResolveWxcExec()
return (false, 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 c18f7fce5..d6c117054 100644
--- a/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcCommandRunner.cs
@@ -188,11 +188,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,
@@ -209,38 +207,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
index 9cf6781e7..d7b690de5 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfig.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfig.cs
@@ -7,7 +7,6 @@ namespace OpenClaw.Shared.Mxc;
/// --config-base64 or --config <file>. Shape mirrors the
/// SDK's ContainerConfig (captured in tests/.../Mxc/Golden/*.json).
///
-
public sealed record MxcConfig
{
[JsonPropertyName("version")]
@@ -31,17 +30,14 @@ public sealed record MxcConfig
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MxcFilesystem? Filesystem { get; init; }
- // Additive (OpenClaw): network policy. Null = wxc-exec defaults.
[JsonPropertyName("network")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MxcNetwork? Network { get; init; }
- // Additive (OpenClaw): top-level UI policy (clipboard, injection, disable).
[JsonPropertyName("ui")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MxcUi? Ui { get; init; }
- // Additive (OpenClaw): lifecycle controls. Set only when golden capture proves SDK does.
[JsonPropertyName("lifecycle")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MxcLifecycle? Lifecycle { get; init; }
@@ -52,12 +48,10 @@ public sealed record MxcProcess
[JsonPropertyName("commandLine")]
public required string CommandLine { get; init; }
- // Additive (OpenClaw): explicit cwd inside the container.
[JsonPropertyName("cwd")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cwd { get; init; }
- // Additive (OpenClaw): environment as KEY=VALUE strings.
[JsonPropertyName("env")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList? Env { get; init; }
@@ -73,18 +67,10 @@ public sealed record MxcAppContainer
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? Capabilities { get; init; }
- // Additive (OpenClaw): mirror SDK fields when golden capture shows them set.
- [JsonPropertyName("name")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? Name { get; init; }
-
[JsonPropertyName("leastPrivilege")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? LeastPrivilege { get; init; }
- // Additive (OpenClaw): per-process BaseProcess UI block. wxc-exec accepts either
- // appContainer.ui or top-level ui depending on its mode; we serialize whichever
- // the golden capture confirms.
[JsonPropertyName("ui")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MxcBaseProcessUi? Ui { get; init; }
@@ -119,22 +105,15 @@ public sealed record MxcFilesystem
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? ReadwritePaths { get; init; }
- // Additive (OpenClaw): explicit deny list (wins over allow).
[JsonPropertyName("deniedPaths")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? DeniedPaths { get; init; }
- // Additive (OpenClaw): tear down policy on container exit.
[JsonPropertyName("clearPolicyOnExit")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ClearPolicyOnExit { get; init; }
-
- [JsonPropertyName("executablePath")]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- public string? ExecutablePath { get; init; }
}
-// Additive (OpenClaw): network policy block.
public sealed record MxcNetwork
{
[JsonPropertyName("enforcementMode")]
@@ -154,7 +133,6 @@ public sealed record MxcNetwork
public string[]? BlockedHosts { get; init; }
}
-// Additive (OpenClaw): top-level UI policy.
public sealed record MxcUi
{
[JsonPropertyName("disable")]
@@ -170,7 +148,6 @@ public sealed record MxcUi
public bool? Injection { get; init; }
}
-// Additive (OpenClaw): lifecycle block.
public sealed record MxcLifecycle
{
[JsonPropertyName("destroyOnExit")]
@@ -182,16 +159,13 @@ public sealed record MxcLifecycle
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; }
-
- // Additive (OpenClaw): true if WaitForExit cancelled via host-side timeout/cancellation.
public bool TimedOut { get; init; }
-
- // Additive (OpenClaw): wall-clock duration of the wxc-exec invocation.
public long DurationMs { get; init; }
}
diff --git a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
index 2f421a544..92f531ed0 100644
--- a/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
+++ b/src/OpenClaw.Shared/Mxc/MxcConfigBuilder.cs
@@ -345,7 +345,7 @@ private sealed record SystemRunArgs(string Command, string Shell, IReadOnlyList<
/// powershell.exe -EncodedCommand <utf16le-base64> so it can be
/// passed verbatim to CreateProcessW inside the AppContainer.
///
-public static class ShellCommandLine
+internal static class ShellCommandLine
{
public static string Build(string shell, string command, IReadOnlyList argv)
{
diff --git a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
index 57a87d2a0..a91621b3f 100644
--- a/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
+++ b/tests/OpenClaw.Shared.Tests/Mxc/MxcCommandRunnerTests.cs
@@ -351,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);