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))
{