From 59841e1b62f82d8739150152590933b7a199fcba Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Fri, 29 May 2026 16:43:14 -0400 Subject: [PATCH] Fix canvas WebView2 surface auth via plugin-node cap URL The Windows tray's Canvas WebView2 was hitting 401 when navigating to /__openclaw__/canvas/* paths because it built URLs against the gateway origin only, omitting the plugin-node capability token the gateway requires for cross-node plugin surface routes. The gateway already advertises a cap-scoped surface URL in its hello-ok response (pluginSurfaceUrls.canvas), e.g. http:///__openclaw__/cap/ This change plumbs that URL through to CanvasWindow and uses it as the base for /__openclaw__/canvas/* rewrites, and as an additional trusted origin prefix in IsUrlSafe so a cap URL whose host doesn't match _trustedGatewayOrigin (e.g. 127.0.0.1 cap vs localhost origin) is not rejected by the safety check. Also clears the cached cap URL when a subsequent hello-ok/health update omits the canvas key, so a rotated/revoked token doesn't leave the node using a stale URL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/Models.cs | 24 +++++++ src/OpenClaw.Shared/WindowsNodeClient.cs | 26 ++++++- .../Services/NodeService.cs | 23 ++++++- .../Windows/CanvasWindow.xaml.cs | 69 ++++++++++++++++--- 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 7223077af..3a47ff485 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -635,6 +635,12 @@ public class GatewaySelfInfo public int? MaxPayload { get; set; } public int? MaxBufferedBytes { get; set; } public int? TickIntervalMs { get; set; } + /// + /// Per-surface base URLs sent by the gateway in hello-ok, already scoped + /// with the plugin-node capability token (oc_cap). Used by canvas/A2UI to + /// fetch hosted documents from /__openclaw__/canvas/... without 401. + /// + public Dictionary? PluginSurfaceUrls { get; set; } public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow; public bool HasAnyDetails => @@ -684,6 +690,23 @@ public static GatewaySelfInfo FromHelloOk(JsonElement payload) ApplySnapshot(info, snapshot); } + if (payload.TryGetProperty("pluginSurfaceUrls", out var surfaces) && + surfaces.ValueKind == JsonValueKind.Object) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prop in surfaces.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + { + var v = prop.Value.GetString(); + if (!string.IsNullOrWhiteSpace(v)) + map[prop.Name] = v!; + } + } + if (map.Count > 0) + info.PluginSurfaceUrls = map; + } + return info; } @@ -717,6 +740,7 @@ public GatewaySelfInfo Merge(GatewaySelfInfo update) MaxPayload = update.MaxPayload ?? MaxPayload, MaxBufferedBytes = update.MaxBufferedBytes ?? MaxBufferedBytes, TickIntervalMs = update.TickIntervalMs ?? TickIntervalMs, + PluginSurfaceUrls = update.PluginSurfaceUrls ?? PluginSurfaceUrls, LastUpdatedUtc = update.LastUpdatedUtc }; } diff --git a/src/OpenClaw.Shared/WindowsNodeClient.cs b/src/OpenClaw.Shared/WindowsNodeClient.cs index 28fac6285..0c7e5c37f 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -75,6 +75,17 @@ public class WindowsNodeClient : WebSocketClientBase public string? NodeId => _nodeId; public string GatewayUrl => GatewayUrlForDisplay; public IReadOnlyList Capabilities => _capabilities; + + /// + /// Per-surface base URL the gateway sent for the canvas plugin, already + /// scoped with a plugin-node capability token (oc_cap). Relative canvas URLs + /// from canvas.present must be prefixed with this, not the bare + /// gateway origin — otherwise the gateway returns 401 on + /// /__openclaw__/canvas/*. May be null before the first + /// hello-ok with a registered canvas surface. + /// + public string? CanvasSurfaceUrl => _canvasSurfaceUrl; + private volatile string? _canvasSurfaceUrl; /// True if connected but waiting for pairing approval on gateway public bool IsPendingApproval => _isPendingApproval; @@ -1173,9 +1184,22 @@ private async Task SendPongAsync(string? requestId) private void PublishGatewaySelf(GatewaySelfInfo info) { - if (!info.HasAnyDetails) + if (!info.HasAnyDetails && info.PluginSurfaceUrls == null) return; + if (info.PluginSurfaceUrls != null) + { + if (info.PluginSurfaceUrls.TryGetValue("canvas", out var canvasUrl) && + !string.IsNullOrWhiteSpace(canvasUrl)) + { + _canvasSurfaceUrl = canvasUrl.TrimEnd('/'); + } + else + { + _canvasSurfaceUrl = null; + } + } + GatewaySelfUpdated?.Invoke(this, info); } diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index e6e046f60..5a0b0b260 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -824,6 +824,25 @@ private void OnNodeHealthReceived(object? sender, JsonElement payload) private void OnGatewaySelfUpdated(object? sender, GatewaySelfInfo info) { + // Refresh the canvas window's cap-scoped surface URL when the gateway + // issues a new pluginSurfaceUrls.canvas (e.g. after reconnect or cap + // token rotation). Without this, an open CanvasWindow caches the + // stale URL captured at SetTrustedGatewayOrigin time and the next + // navigate would 401. + if (info.PluginSurfaceUrls != null && + info.PluginSurfaceUrls.TryGetValue("canvas", out var canvasUrl) && + !string.IsNullOrWhiteSpace(canvasUrl)) + { + _dispatcherQueue.TryEnqueue(() => + { + var window = _canvasWindow; + if (window != null && !window.IsClosed) + { + window.SetTrustedGatewayOrigin(GatewayUrl, _token, canvasUrl); + } + }); + } + GatewaySelfUpdated?.Invoke(this, info); } @@ -860,7 +879,7 @@ private void OnCanvasPresent(object? sender, CanvasPresentArgs args) if (_canvasWindow == null || _canvasWindow.IsClosed) { _canvasWindow = new CanvasWindow(); - _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token); + _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, _nodeClient?.CanvasSurfaceUrl); } // Configure window @@ -1342,7 +1361,7 @@ private void EnsureCanvasWindow() if (_canvasWindow == null || _canvasWindow.IsClosed) { _canvasWindow = new CanvasWindow(); - _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token); + _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, _nodeClient?.CanvasSurfaceUrl); } _canvasWindow?.Activate(); } diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs index fbd743a16..81d8bae96 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs @@ -92,17 +92,22 @@ private bool IsUrlSafe(string url) return true; } // Allow URLs from the trusted gateway origin with strict boundary check - if (!string.IsNullOrEmpty(_trustedGatewayOrigin) && - url.StartsWith(_trustedGatewayOrigin, StringComparison.OrdinalIgnoreCase) && - (url.Length == _trustedGatewayOrigin.Length || - url[_trustedGatewayOrigin.Length] == '/' || - url[_trustedGatewayOrigin.Length] == '?' || - url[_trustedGatewayOrigin.Length] == '#')) + if (MatchesTrustedPrefix(url, _trustedGatewayOrigin) || + MatchesTrustedPrefix(url, _canvasSurfaceBaseUrl)) { return true; } return !DangerousUrlPattern.IsMatch(url); } + + private static bool MatchesTrustedPrefix(string url, string? prefix) + { + if (string.IsNullOrEmpty(prefix)) return false; + if (!url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; + if (url.Length == prefix.Length) return true; + var next = url[prefix.Length]; + return next == '/' || next == '?' || next == '#'; + } private static bool IsSafeDataUrl(string url) { @@ -131,6 +136,13 @@ private static bool IsSafeDataUrl(string url) private string? _trustedGatewayOrigin; private string? _gatewayOriginForRewrite; private string? _gatewayToken; + // Plugin-node capability-scoped URL for the canvas surface, e.g. + // http://127.0.0.1:19001/__openclaw__/cap/. Sent by the + // gateway in hello-ok's pluginSurfaceUrls.canvas. Relative URLs that + // target /__openclaw__/canvas/... must be prefixed with this — the bare + // gateway origin returns 401 on that route because it expects the cap + // token (path-scoped or ?oc_cap=) for plugin-hosted surfaces. + private string? _canvasSurfaceBaseUrl; /// /// Allow URLs from the connected gateway origin. Call after creating the window @@ -138,10 +150,13 @@ private static bool IsSafeDataUrl(string url) /// Also rewrites gateway URLs to use the node's effective connection /// (e.g., localhost when connected via SSH tunnel). /// - public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null) + public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null, string? canvasSurfaceUrl = null) { if (string.IsNullOrEmpty(gatewayUrl)) return; _gatewayToken = token; + _canvasSurfaceBaseUrl = string.IsNullOrWhiteSpace(canvasSurfaceUrl) + ? null + : canvasSurfaceUrl!.TrimEnd('/'); try { var uri = new Uri(GatewayUrlHelper.NormalizeForWebSocket(gatewayUrl)); @@ -149,6 +164,8 @@ public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null) _trustedGatewayOrigin = $"{httpScheme}://{uri.Host}:{uri.Port}"; _gatewayOriginForRewrite = _trustedGatewayOrigin; Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}"); + if (_canvasSurfaceBaseUrl != null) + Logger.Info($"[Canvas] Canvas surface base URL (cap-scoped) registered"); ConfigureGatewayAuthHeaderInjection(); } catch (Exception ex) @@ -167,12 +184,42 @@ private string RewriteGatewayUrl(string url) try { - // Handle relative paths — prepend the gateway origin + // Handle relative paths — prepend the gateway origin (or the + // cap-scoped canvas surface URL for /__openclaw__/canvas/* paths, + // since that route requires the plugin-node capability token). if (url.StartsWith("/")) { - var rewritten = _gatewayOriginForRewrite + url; - rewritten = AppendGatewayToken(rewritten); - Logger.Info($"[Canvas] Resolved relative URL to gateway origin"); + // First check the local virtual host fast path for published + // canvas documents (avoids hitting the gateway entirely). + if (url.StartsWith("/__openclaw__/canvas/documents/", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(_canvasDir)) + { + var localRelative = url.Substring("/__openclaw__/canvas/documents/".Length); + var queryIdx = localRelative.IndexOfAny(new[] { '?', '#' }); + if (queryIdx >= 0) localRelative = localRelative.Substring(0, queryIdx); + var localPath = Path.GetFullPath(Path.Combine(_canvasDir, localRelative.Replace('/', Path.DirectorySeparatorChar))); + if (localPath.StartsWith(_canvasDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + File.Exists(localPath)) + { + var localUrl = $"https://openclaw-canvas.local/{localRelative}"; + Logger.Info($"[Canvas] Using local file: {localUrl}"); + return localUrl; + } + } + + string rewritten; + if (_canvasSurfaceBaseUrl != null && + url.StartsWith("/__openclaw__/canvas/", StringComparison.OrdinalIgnoreCase)) + { + rewritten = _canvasSurfaceBaseUrl + url; + Logger.Info($"[Canvas] Resolved relative canvas URL via cap-scoped surface URL"); + } + else + { + rewritten = _gatewayOriginForRewrite + url; + rewritten = AppendGatewayToken(rewritten); + Logger.Info($"[Canvas] Resolved relative URL to gateway origin"); + } return rewritten; }