diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 884874f6..824808d5 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -676,6 +676,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 => @@ -725,6 +731,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; } @@ -758,6 +781,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 8ca7c812..54573971 100644 --- a/src/OpenClaw.Shared/WindowsNodeClient.cs +++ b/src/OpenClaw.Shared/WindowsNodeClient.cs @@ -76,6 +76,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; @@ -1229,9 +1240,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 ed6f2fd9..337fe1f0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -1010,6 +1010,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, GetConfiguredGatewayUrl(), canvasUrl); + } + }); + } + GatewaySelfUpdated?.Invoke(this, info); } @@ -1062,7 +1081,11 @@ private void OnCanvasPresent(object? sender, CanvasPresentArgs args) if (_canvasWindow == null || _canvasWindow.IsClosed) { _canvasWindow = new CanvasWindow(); - _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl()); + _canvasWindow.SetTrustedGatewayOrigin( + GatewayUrl, + _token, + GetConfiguredGatewayUrl(), + _nodeClient?.CanvasSurfaceUrl); } // Configure window @@ -1558,7 +1581,11 @@ private void EnsureCanvasWindow() if (_canvasWindow == null || _canvasWindow.IsClosed) { _canvasWindow = new CanvasWindow(); - _canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl()); + _canvasWindow.SetTrustedGatewayOrigin( + GatewayUrl, + _token, + GetConfiguredGatewayUrl(), + _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 76cc9e78..b56c74a0 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs @@ -96,17 +96,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) { @@ -136,6 +141,13 @@ private static bool IsSafeDataUrl(string url) private string? _configuredGatewayOrigin; 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 @@ -143,10 +155,17 @@ 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, string? configuredGatewayUrl = null) + public void SetTrustedGatewayOrigin( + string? gatewayUrl, + string? token = null, + string? configuredGatewayUrl = null, + string? canvasSurfaceUrl = null) { if (string.IsNullOrEmpty(gatewayUrl)) return; _gatewayToken = token; + _canvasSurfaceBaseUrl = string.IsNullOrWhiteSpace(canvasSurfaceUrl) + ? null + : canvasSurfaceUrl!.TrimEnd('/'); try { _trustedGatewayOrigin = CanvasGatewayUrlRewriter.ToHttpOrigin(gatewayUrl); @@ -155,6 +174,8 @@ public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null, st : CanvasGatewayUrlRewriter.ToHttpOrigin(configuredGatewayUrl); _gatewayOriginForRewrite = _trustedGatewayOrigin; Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}; configured gateway origin: {_configuredGatewayOrigin}"); + if (_canvasSurfaceBaseUrl != null) + Logger.Info($"[Canvas] Canvas surface base URL (cap-scoped) registered"); ConfigureGatewayAuthHeaderInjection(); } catch (Exception ex) @@ -173,7 +194,51 @@ 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("/")) + { + // 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; + } + } + + if (_canvasSurfaceBaseUrl != null && + url.StartsWith("/__openclaw__/canvas/", StringComparison.OrdinalIgnoreCase)) + { + var capScopedUrl = _canvasSurfaceBaseUrl + url; + Logger.Info($"[Canvas] Resolved relative canvas URL via cap-scoped surface URL"); + return capScopedUrl; + } + } + else if (_canvasSurfaceBaseUrl != null) + { + var uri = new Uri(url); + var urlOrigin = $"{uri.Scheme}://{uri.Host}:{uri.Port}"; + if (uri.PathAndQuery.StartsWith("/__openclaw__/canvas/", StringComparison.OrdinalIgnoreCase) && + (urlOrigin.Equals(_gatewayOriginForRewrite, StringComparison.OrdinalIgnoreCase) || + urlOrigin.Equals(_configuredGatewayOrigin, StringComparison.OrdinalIgnoreCase))) + { + var capScopedUrl = _canvasSurfaceBaseUrl + uri.PathAndQuery; + Logger.Info($"[Canvas] Rewrote canvas URL via cap-scoped surface URL"); + return capScopedUrl; + } + } + var rewritten = CanvasGatewayUrlRewriter.Rewrite(url, _gatewayOriginForRewrite, _configuredGatewayOrigin); if (!string.Equals(url, rewritten, StringComparison.Ordinal)) {