Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/OpenClaw.Shared/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,12 @@ public class GatewaySelfInfo
public int? MaxPayload { get; set; }
public int? MaxBufferedBytes { get; set; }
public int? TickIntervalMs { get; set; }
/// <summary>
/// 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 <c>/__openclaw__/canvas/...</c> without 401.
/// </summary>
public Dictionary<string, string>? PluginSurfaceUrls { get; set; }
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;

public bool HasAnyDetails =>
Expand Down Expand Up @@ -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<string, string>(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;
}

Expand Down Expand Up @@ -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
};
}
Expand Down
26 changes: 25 additions & 1 deletion src/OpenClaw.Shared/WindowsNodeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ public class WindowsNodeClient : WebSocketClientBase
public string? NodeId => _nodeId;
public string GatewayUrl => GatewayUrlForDisplay;
public IReadOnlyList<INodeCapability> Capabilities => _capabilities;

/// <summary>
/// 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 <c>canvas.present</c> must be prefixed with this, not the bare
/// gateway origin — otherwise the gateway returns 401 on
/// <c>/__openclaw__/canvas/*</c>. May be <c>null</c> before the first
/// hello-ok with a registered canvas surface.
/// </summary>
public string? CanvasSurfaceUrl => _canvasSurfaceUrl;
private volatile string? _canvasSurfaceUrl;

/// <summary>True if connected but waiting for pairing approval on gateway</summary>
public bool IsPendingApproval => _isPendingApproval;
Expand Down Expand Up @@ -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);
}

Expand Down
23 changes: 21 additions & 2 deletions src/OpenClaw.Tray.WinUI/Services/NodeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
69 changes: 58 additions & 11 deletions src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -131,24 +136,36 @@ 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/<oc_cap_token>. 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;

/// <summary>
/// Allow URLs from the connected gateway origin. Call after creating the window
/// so that canvas.present URLs served by the gateway are not blocked.
/// Also rewrites gateway URLs to use the node's effective connection
/// (e.g., localhost when connected via SSH tunnel).
/// </summary>
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));
var httpScheme = uri.Scheme == "wss" ? "https" : "http";
_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)
Expand All @@ -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;
}

Expand Down
Loading