From 3a18779d81a2137ea60ba52a2812d023470cc40b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:14:05 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20wire=20WebView2=20bidirectional=20n?= =?UTF-8?q?ative=E2=86=94SPA=20bridge=20in=20WebChatWindow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the WebView2 bridge proposed in issue #191. ## Changes ### OpenClaw.Shared — WebBridgeMessage - New `WebBridgeMessage` record: wire format `{"type":"...","payload":{...}}` - Well-known type constants: recording-start/stop, voice-start/stop, draft-text, ready - `TryParse(string?)` — null-safe parser, returns null on malformed input - `ToJson(object?)` — serialiser, optional runtime payload overrides stored one ### WebChatWindow.xaml.cs - Subscribe `CoreWebView2.WebMessageReceived` → fire `BridgeMessageReceived` event (SPA sends via `window.chrome.webview.postMessage({type, payload})`) - Add `PostBridgeMessage(string type, object? payload = null)` for native→SPA (SPA receives via `window.chrome.webview.addEventListener('message', ...)`) - Handler stored and unregistered in `OnWindowClosed` (no leak) - Uses existing `using OpenClaw.Shared;` import — no new dependencies ### Tests (+15) - `WebBridgeMessageTests`: parse valid/invalid/edge cases; ToJson; round-trip for all well-known types; payload override ## Test Status - `dotnet test OpenClaw.Shared.Tests` — **648 passed, 20 skipped** (↑15 from 633) - `dotnet test OpenClaw.Tray.Tests` — 122 passed (unchanged) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/WebBridgeMessage.cs | 97 +++++++++++++ .../Windows/WebChatWindow.xaml.cs | 42 +++++- .../WebBridgeMessageTests.cs | 136 ++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 src/OpenClaw.Shared/WebBridgeMessage.cs create mode 100644 tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs diff --git a/src/OpenClaw.Shared/WebBridgeMessage.cs b/src/OpenClaw.Shared/WebBridgeMessage.cs new file mode 100644 index 00000000..6d7d6912 --- /dev/null +++ b/src/OpenClaw.Shared/WebBridgeMessage.cs @@ -0,0 +1,97 @@ +using System; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// A JSON message exchanged over the WebView2 native↔SPA bridge. +/// +/// Wire format: { "type": "<string>", "payload": { ... } } +/// +/// Native → SPA: CoreWebView2.PostWebMessageAsJson(msg.ToJson()) +/// SPA → Native: CoreWebView2.WebMessageReceivedWebBridgeMessage.TryParse(e.WebMessageAsJson) +/// +public sealed record WebBridgeMessage(string Type, string? PayloadJson = null) +{ + // ── well-known type constants ────────────────────────────────────────── + + /// Sent native→SPA when a screen-recording session starts. + public const string TypeRecordingStart = "recording-start"; + + /// Sent native→SPA when a screen-recording session ends. + public const string TypeRecordingStop = "recording-stop"; + + /// Sent native→SPA when voice listening becomes active. + public const string TypeVoiceStart = "voice-start"; + + /// Sent native→SPA when voice listening becomes inactive. + public const string TypeVoiceStop = "voice-stop"; + + /// Sent native→SPA to push draft text into the chat input. + public const string TypeDraftText = "draft-text"; + + /// Sent SPA→native when the SPA is fully initialised and ready for messages. + public const string TypeReady = "ready"; + + // ── parsing ──────────────────────────────────────────────────────────── + + /// + /// Tries to parse a from a JSON string. + /// Returns if the JSON is malformed or missing the required "type" field. + /// + public static WebBridgeMessage? TryParse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (!root.TryGetProperty("type", out var typeEl) || typeEl.ValueKind != JsonValueKind.String) + return null; + + var type = typeEl.GetString(); + if (string.IsNullOrWhiteSpace(type)) + return null; + + string? payloadJson = null; + if (root.TryGetProperty("payload", out var payloadEl) + && payloadEl.ValueKind != JsonValueKind.Null + && payloadEl.ValueKind != JsonValueKind.Undefined) + { + payloadJson = payloadEl.GetRawText(); + } + + return new WebBridgeMessage(type!, payloadJson); + } + catch (JsonException) + { + return null; + } + } + + // ── serialisation ────────────────────────────────────────────────────── + + /// + /// Serialises the message to JSON, suitable for passing to + /// CoreWebView2.PostWebMessageAsJson. + /// If is supplied it overrides . + /// + public string ToJson(object? payload = null) + { + var typeEncoded = JsonSerializer.Serialize(Type); + + if (payload != null) + { + var payloadEncoded = JsonSerializer.Serialize(payload); + return $"{{\"type\":{typeEncoded},\"payload\":{payloadEncoded}}}"; + } + + if (!string.IsNullOrEmpty(PayloadJson)) + return $"{{\"type\":{typeEncoded},\"payload\":{PayloadJson}}}"; + + return $"{{\"type\":{typeEncoded},\"payload\":{{}}}}"; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs index 8a6bc4b4..21a5de5b 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs @@ -21,7 +21,14 @@ public sealed partial class WebChatWindow : WindowEx // Store event handlers for cleanup private TypedEventHandler? _navigationCompletedHandler; private TypedEventHandler? _navigationStartingHandler; - + private TypedEventHandler? _webMessageReceivedHandler; + + /// + /// Fired when the SPA sends a message to the native side via + /// window.chrome.webview.postMessage(...). + /// + public event EventHandler? BridgeMessageReceived; + public bool IsClosed { get; private set; } public WebChatWindow(string gatewayUrl, string token) @@ -56,6 +63,8 @@ private void OnWindowClosed(object sender, WindowEventArgs e) WebView.CoreWebView2.NavigationCompleted -= _navigationCompletedHandler; if (_navigationStartingHandler != null) WebView.CoreWebView2.NavigationStarting -= _navigationStartingHandler; + if (_webMessageReceivedHandler != null) + WebView.CoreWebView2.WebMessageReceived -= _webMessageReceivedHandler; } } @@ -85,6 +94,23 @@ private async Task InitializeWebViewAsync() WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; WebView.CoreWebView2.Settings.IsZoomControlEnabled = true; + // Wire the bidirectional native↔SPA bridge + // SPA → native: window.chrome.webview.postMessage({ type, payload }) + _webMessageReceivedHandler = (s, e) => + { + var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson); + if (msg != null) + { + Logger.Debug($"WebChatWindow: bridge message from SPA, type={msg.Type}"); + BridgeMessageReceived?.Invoke(this, msg); + } + else + { + Logger.Warn($"WebChatWindow: received unrecognised bridge message"); + } + }; + WebView.CoreWebView2.WebMessageReceived += _webMessageReceivedHandler; + // Handle navigation events (store for cleanup) _navigationCompletedHandler = (s, e) => { @@ -162,6 +188,20 @@ private async Task InitializeWebViewAsync() // Set to a test URL to bypass gateway (e.g., "https://www.bing.com"), or null for normal operation private const string? DEBUG_TEST_URL = null; + /// + /// Sends a bridge message to the SPA via the WebView2 native→web channel. + /// The SPA receives this via window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... }). + /// This method is a no-op if the WebView2 core is not yet initialised. + /// + public void PostBridgeMessage(string type, object? payload = null) + { + if (WebView.CoreWebView2 == null) return; + var msg = new WebBridgeMessage(type); + var json = msg.ToJson(payload); + Logger.Debug($"WebChatWindow: posting bridge message, type={type}"); + WebView.CoreWebView2.PostWebMessageAsJson(json); + } + private static bool IsLocalHost(Uri uri) { return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); diff --git a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs new file mode 100644 index 00000000..b6b285fa --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs @@ -0,0 +1,136 @@ +using Xunit; +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public class WebBridgeMessageTests +{ + // ── TryParse ───────────────────────────────────────────────────────── + + [Fact] + public void TryParse_ValidTypeOnly_ReturnsMsgWithNullPayload() + { + var msg = WebBridgeMessage.TryParse("""{"type":"ready","payload":{}}"""); + Assert.NotNull(msg); + Assert.Equal("ready", msg!.Type); + } + + [Fact] + public void TryParse_ValidWithStringPayload_ReturnsMsgWithPayload() + { + var msg = WebBridgeMessage.TryParse("""{"type":"draft-text","payload":{"text":"hello"}}"""); + Assert.NotNull(msg); + Assert.Equal("draft-text", msg!.Type); + Assert.Equal("""{"text":"hello"}""", msg.PayloadJson); + } + + [Fact] + public void TryParse_MissingTypeField_ReturnsNull() + { + var msg = WebBridgeMessage.TryParse("""{"payload":{}}"""); + Assert.Null(msg); + } + + [Fact] + public void TryParse_EmptyTypeValue_ReturnsNull() + { + var msg = WebBridgeMessage.TryParse("""{"type":"","payload":{}}"""); + Assert.Null(msg); + } + + [Fact] + public void TryParse_NullOrEmptyInput_ReturnsNull() + { + Assert.Null(WebBridgeMessage.TryParse(null)); + Assert.Null(WebBridgeMessage.TryParse("")); + Assert.Null(WebBridgeMessage.TryParse(" ")); + } + + [Fact] + public void TryParse_InvalidJson_ReturnsNull() + { + Assert.Null(WebBridgeMessage.TryParse("not-json")); + Assert.Null(WebBridgeMessage.TryParse("{bad json")); + } + + [Fact] + public void TryParse_TypeIsNotString_ReturnsNull() + { + var msg = WebBridgeMessage.TryParse("""{"type":42,"payload":{}}"""); + Assert.Null(msg); + } + + [Fact] + public void TryParse_NullPayload_IgnoresPayload() + { + var msg = WebBridgeMessage.TryParse("""{"type":"voice-start","payload":null}"""); + Assert.NotNull(msg); + Assert.Equal("voice-start", msg!.Type); + Assert.Null(msg.PayloadJson); + } + + // ── ToJson ─────────────────────────────────────────────────────────── + + [Fact] + public void ToJson_NoPayload_EmitsEmptyObject() + { + var msg = new WebBridgeMessage(WebBridgeMessage.TypeRecordingStart); + var json = msg.ToJson(); + Assert.Contains("\"type\":\"recording-start\"", json); + Assert.Contains("\"payload\":{}", json); + } + + [Fact] + public void ToJson_WithAnonymousPayload_SerializesPayload() + { + var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText); + var json = msg.ToJson(new { text = "hello world" }); + Assert.Contains("\"type\":\"draft-text\"", json); + Assert.Contains("\"text\":\"hello world\"", json); + } + + [Fact] + public void ToJson_WithStoredPayloadJson_EmbeddedVerbatim() + { + var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"hi"}"""); + var json = msg.ToJson(); + Assert.Contains("\"payload\":{\"text\":\"hi\"}", json); + } + + [Fact] + public void ToJson_PassedPayloadOverridesStoredPayloadJson() + { + var msg = new WebBridgeMessage(WebBridgeMessage.TypeDraftText, """{"text":"old"}"""); + var json = msg.ToJson(new { text = "new" }); + Assert.Contains("\"text\":\"new\"", json); + Assert.DoesNotContain("old", json); + } + + // ── round-trip ─────────────────────────────────────────────────────── + + [Theory] + [InlineData(WebBridgeMessage.TypeRecordingStart)] + [InlineData(WebBridgeMessage.TypeRecordingStop)] + [InlineData(WebBridgeMessage.TypeVoiceStart)] + [InlineData(WebBridgeMessage.TypeVoiceStop)] + [InlineData(WebBridgeMessage.TypeReady)] + public void RoundTrip_WellKnownTypes_PreserveType(string type) + { + var original = new WebBridgeMessage(type); + var json = original.ToJson(); + var parsed = WebBridgeMessage.TryParse(json); + Assert.NotNull(parsed); + Assert.Equal(type, parsed!.Type); + } + + [Fact] + public void RoundTrip_DraftText_PreservesPayload() + { + var original = new WebBridgeMessage(WebBridgeMessage.TypeDraftText); + var json = original.ToJson(new { text = "round trip" }); + var parsed = WebBridgeMessage.TryParse(json); + Assert.NotNull(parsed); + Assert.Equal(WebBridgeMessage.TypeDraftText, parsed!.Type); + Assert.Contains("round trip", parsed.PayloadJson ?? ""); + } +} From 946b0bab5aed9f4f6dfe0cdd8befc271ee9b61fd Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Mon, 27 Apr 2026 20:17:22 -0700 Subject: [PATCH 2/2] fix: harden WebView bridge transport Validate incoming WebView bridge origins, marshal native-to-SPA posts onto the WebView dispatcher, guard closed windows, and validate stored payload JSON before embedding it in bridge messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/WebBridgeMessage.cs | 35 ++++- .../Windows/WebChatWindow.xaml.cs | 136 +++++++++++++++++- .../WebBridgeMessageTests.cs | 15 ++ .../TrayMenuWindowMarkupTests.cs | 20 +++ 4 files changed, 198 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Shared/WebBridgeMessage.cs b/src/OpenClaw.Shared/WebBridgeMessage.cs index 6d7d6912..64d9308c 100644 --- a/src/OpenClaw.Shared/WebBridgeMessage.cs +++ b/src/OpenClaw.Shared/WebBridgeMessage.cs @@ -11,8 +11,21 @@ namespace OpenClaw.Shared; /// Native → SPA: CoreWebView2.PostWebMessageAsJson(msg.ToJson()) /// SPA → Native: CoreWebView2.WebMessageReceivedWebBridgeMessage.TryParse(e.WebMessageAsJson) /// -public sealed record WebBridgeMessage(string Type, string? PayloadJson = null) +public sealed record WebBridgeMessage { + public WebBridgeMessage(string type, string? payloadJson = null) + { + if (string.IsNullOrWhiteSpace(type)) + throw new ArgumentException("Bridge message type is required.", nameof(type)); + + Type = type.Trim(); + PayloadJson = NormalizePayloadJson(payloadJson); + } + + public string Type { get; init; } + + public string? PayloadJson { get; init; } + // ── well-known type constants ────────────────────────────────────────── /// Sent native→SPA when a screen-recording session starts. @@ -70,6 +83,10 @@ public sealed record WebBridgeMessage(string Type, string? PayloadJson = null) { return null; } + catch (ArgumentException) + { + return null; + } } // ── serialisation ────────────────────────────────────────────────────── @@ -94,4 +111,20 @@ public string ToJson(object? payload = null) return $"{{\"type\":{typeEncoded},\"payload\":{{}}}}"; } + + private static string? NormalizePayloadJson(string? payloadJson) + { + if (string.IsNullOrWhiteSpace(payloadJson)) + return null; + + try + { + using var doc = JsonDocument.Parse(payloadJson); + return doc.RootElement.GetRawText(); + } + catch (JsonException ex) + { + throw new ArgumentException("PayloadJson must be a valid JSON value.", nameof(payloadJson), ex); + } + } } diff --git a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs index 21a5de5b..72e7082f 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml.cs @@ -17,6 +17,7 @@ public sealed partial class WebChatWindow : WindowEx { private readonly string _gatewayUrl; private readonly string _token; + private readonly Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue; // Store event handlers for cleanup private TypedEventHandler? _navigationCompletedHandler; @@ -38,6 +39,7 @@ public WebChatWindow(string gatewayUrl, string token) _token = token; InitializeComponent(); + _dispatcherQueue = DispatcherQueue; // Window configuration this.SetWindowSize(520, 750); @@ -98,10 +100,16 @@ private async Task InitializeWebViewAsync() // SPA → native: window.chrome.webview.postMessage({ type, payload }) _webMessageReceivedHandler = (s, e) => { + if (!IsTrustedBridgeSource(e.Source)) + { + Logger.Warn($"WebChatWindow: rejected bridge message from untrusted source {SanitizeBridgeLogValue(e.Source)}"); + return; + } + var msg = WebBridgeMessage.TryParse(e.WebMessageAsJson); if (msg != null) { - Logger.Debug($"WebChatWindow: bridge message from SPA, type={msg.Type}"); + Logger.Debug($"WebChatWindow: bridge message from SPA, type={SanitizeBridgeLogValue(msg.Type)}"); BridgeMessageReceived?.Invoke(this, msg); } else @@ -191,15 +199,53 @@ private async Task InitializeWebViewAsync() /// /// Sends a bridge message to the SPA via the WebView2 native→web channel. /// The SPA receives this via window.chrome.webview.addEventListener('message', e => { const msg = e.data; ... }). - /// This method is a no-op if the WebView2 core is not yet initialised. + /// This method is safe to call from background threads and is a no-op if the WebView2 core is not yet initialised. /// public void PostBridgeMessage(string type, object? payload = null) { - if (WebView.CoreWebView2 == null) return; - var msg = new WebBridgeMessage(type); - var json = msg.ToJson(payload); - Logger.Debug($"WebChatWindow: posting bridge message, type={type}"); - WebView.CoreWebView2.PostWebMessageAsJson(json); + if (IsClosed) + return; + + if (_dispatcherQueue == null) + { + Logger.Warn("WebChatWindow: cannot post bridge message because DispatcherQueue is unavailable"); + return; + } + + if (!_dispatcherQueue.TryEnqueue(() => PostBridgeMessageOnUiThread(type, payload))) + { + Logger.Warn($"WebChatWindow: failed to enqueue bridge message, type={SanitizeBridgeLogValue(type)}"); + } + } + + private void PostBridgeMessageOnUiThread(string type, object? payload) + { + if (IsClosed || WebView.CoreWebView2 == null) + return; + + try + { + var msg = new WebBridgeMessage(type); + var json = msg.ToJson(payload); + Logger.Debug($"WebChatWindow: posting bridge message, type={SanitizeBridgeLogValue(type)}"); + WebView.CoreWebView2.PostWebMessageAsJson(json); + } + catch (ArgumentException ex) + { + Logger.Warn($"WebChatWindow: invalid bridge message payload: {ex.Message}"); + } + catch (COMException ex) + { + Logger.Warn($"WebChatWindow: bridge message post failed: {ex.Message}"); + } + catch (ObjectDisposedException ex) + { + Logger.Warn($"WebChatWindow: bridge message post skipped after disposal: {ex.Message}"); + } + catch (InvalidOperationException ex) + { + Logger.Warn($"WebChatWindow: bridge message post failed: {ex.Message}"); + } } private static bool IsLocalHost(Uri uri) @@ -207,6 +253,82 @@ private static bool IsLocalHost(Uri uri) return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); } + private bool IsTrustedBridgeSource(string? source) + { + return TryGetUriOrigin(source, out var sourceOrigin) && + TryGetExpectedBridgeOrigin(out var expectedOrigin) && + UriOriginsEqual(sourceOrigin, expectedOrigin); + } + + private bool TryGetExpectedBridgeOrigin(out Uri origin) + { + origin = null!; + + if (!GatewayUrlHelper.TryNormalizeWebSocketUrl(_gatewayUrl, out var normalizedGatewayUrl) || + !Uri.TryCreate(normalizedGatewayUrl, UriKind.Absolute, out var gatewayUri)) + { + return false; + } + + var webScheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) + ? "https" + : "http"; + + var builder = new UriBuilder(gatewayUri) + { + Scheme = webScheme, + Path = string.Empty, + Query = string.Empty, + Fragment = string.Empty + }; + + origin = builder.Uri; + return true; + } + + private static bool TryGetUriOrigin(string? uriText, out Uri origin) + { + origin = null!; + if (!Uri.TryCreate(uriText, UriKind.Absolute, out var uri)) + return false; + + var builder = new UriBuilder(uri) + { + Path = string.Empty, + Query = string.Empty, + Fragment = string.Empty + }; + + origin = builder.Uri; + return true; + } + + private static bool UriOriginsEqual(Uri left, Uri right) + { + return string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(left.IdnHost, right.IdnHost, StringComparison.OrdinalIgnoreCase) && + left.Port == right.Port; + } + + private static string SanitizeBridgeLogValue(string? value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + Span buffer = stackalloc char[Math.Min(value.Length, 80)]; + 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 bool TryBuildChatUrl(out string url, out string errorMessage) { url = string.Empty; diff --git a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs index b6b285fa..f99cc9ad 100644 --- a/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs +++ b/tests/OpenClaw.Shared.Tests/WebBridgeMessageTests.cs @@ -97,6 +97,21 @@ public void ToJson_WithStoredPayloadJson_EmbeddedVerbatim() Assert.Contains("\"payload\":{\"text\":\"hi\"}", json); } + [Fact] + public void Constructor_InvalidStoredPayloadJson_Throws() + { + Assert.Throws(() => + new WebBridgeMessage(WebBridgeMessage.TypeDraftText, "{bad json")); + } + + [Fact] + public void Constructor_BlankStoredPayloadJson_TreatedAsNoPayload() + { + var msg = new WebBridgeMessage(WebBridgeMessage.TypeReady, " "); + Assert.Null(msg.PayloadJson); + Assert.Contains("\"payload\":{}", msg.ToJson()); + } + [Fact] public void ToJson_PassedPayloadOverridesStoredPayloadJson() { diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index c6ec3130..1ceebef7 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -21,6 +21,26 @@ public void TrayMenuWindow_UsesVisibleVerticalScrollbar() xaml); } + [Fact] + public void WebChatWindow_BridgeValidatesOriginAndPostsOnDispatcher() + { + var sourcePath = Path.Combine( + GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Windows", + "WebChatWindow.xaml.cs"); + + var source = File.ReadAllText(sourcePath); + + Assert.Contains("IsTrustedBridgeSource(e.Source)", source); + Assert.Contains("rejected bridge message from untrusted source", source); + Assert.Contains("DispatcherQueue", source); + Assert.Contains("TryEnqueue(() => PostBridgeMessageOnUiThread", source); + Assert.Contains("PostWebMessageAsJson(json)", source); + Assert.Contains("SanitizeBridgeLogValue", source); + } + [Fact] public void SettingsWindow_HasCommandCenterEntryPoint() {