Skip to content

Commit c338d41

Browse files
liorb-mountappsshanselmanCopilot
authored
Fix canvas navigation URL handling (#711)
* Fix canvas navigation URL handling * Fix canvas navigate dispatcher timeout Route canvas.navigate through the same dispatcher timeout guard used by canvas.eval and canvas.snapshot so stalled UI dispatch cannot hang MCP tool calls indefinitely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Scott Hanselman <scott@hanselman.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ee8fc93 commit c338d41

8 files changed

Lines changed: 215 additions & 63 deletions

File tree

src/OpenClaw.Shared/Capabilities/CanvasCapability.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ private async Task<NodeInvokeResponse> HandleNavigateAsync(NodeInvokeRequest req
212212
// opener is the subscriber's word for how it serviced the request:
213213
// "canvas" (existing WebView2 frame), "browser" (default browser),
214214
// or anything else the subscriber wants to surface back to the agent.
215+
if (string.Equals(opener, "denied", StringComparison.OrdinalIgnoreCase) ||
216+
string.Equals(opener, "unsupported_in_canvas", StringComparison.OrdinalIgnoreCase))
217+
return Success(new { navigated = false, opener, url = canonical });
218+
215219
return Success(new { navigated = true, opener, url = canonical });
216220
}
217221
catch (Exception ex)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using OpenClaw.Shared;
2+
3+
namespace OpenClawTray.Helpers;
4+
5+
internal static class CanvasGatewayUrlRewriter
6+
{
7+
public static string? ToHttpOrigin(string? gatewayUrl)
8+
{
9+
if (string.IsNullOrWhiteSpace(gatewayUrl))
10+
return null;
11+
12+
var uri = new Uri(GatewayUrlHelper.NormalizeForWebSocket(gatewayUrl));
13+
var httpScheme = uri.Scheme == "wss" ? "https" : "http";
14+
return $"{httpScheme}://{uri.Host}:{uri.Port}";
15+
}
16+
17+
public static string Rewrite(string url, string? effectiveGatewayOrigin, string? configuredGatewayOrigin)
18+
{
19+
if (string.IsNullOrEmpty(effectiveGatewayOrigin))
20+
return url;
21+
22+
if (url.StartsWith("/", StringComparison.Ordinal))
23+
return effectiveGatewayOrigin + url;
24+
25+
var uri = new Uri(url);
26+
var urlOrigin = $"{uri.Scheme}://{uri.Host}:{uri.Port}";
27+
28+
if (IsGatewayOrigin(urlOrigin, effectiveGatewayOrigin, configuredGatewayOrigin) &&
29+
!urlOrigin.Equals(effectiveGatewayOrigin, StringComparison.OrdinalIgnoreCase))
30+
{
31+
return effectiveGatewayOrigin + uri.PathAndQuery;
32+
}
33+
34+
return url;
35+
}
36+
37+
private static bool IsGatewayOrigin(string urlOrigin, string effectiveGatewayOrigin, string? configuredGatewayOrigin)
38+
{
39+
return urlOrigin.Equals(effectiveGatewayOrigin, StringComparison.OrdinalIgnoreCase) ||
40+
(!string.IsNullOrEmpty(configuredGatewayOrigin) &&
41+
urlOrigin.Equals(configuredGatewayOrigin, StringComparison.OrdinalIgnoreCase));
42+
}
43+
}

src/OpenClaw.Tray.WinUI/Services/NodeService.cs

Lines changed: 34 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,7 @@ private void OnCanvasPresent(object? sender, CanvasPresentArgs args)
10611061
if (_canvasWindow == null || _canvasWindow.IsClosed)
10621062
{
10631063
_canvasWindow = new CanvasWindow();
1064-
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token);
1064+
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl());
10651065
}
10661066

10671067
// Configure window
@@ -1134,62 +1134,52 @@ private void OnCanvasHide(object? sender, EventArgs args)
11341134
}
11351135

11361136
/// <summary>
1137-
/// Service a <c>canvas.navigate</c> request by launching the URL in the
1138-
/// OS default browser. Always — even if a WebView2 canvas window is open.
1139-
/// Rationale: "open this link" on Windows means the default browser, and
1140-
/// the embedded WebView2 canvas runs URL-rewriting (gateway-origin pinning,
1141-
/// CSP, etc.) that mangles arbitrary external URLs. Agents that want to
1142-
/// load a page inside an embedded surface should use <c>canvas.present</c>.
1143-
///
1144-
/// Open canvas windows are NOT closed after navigate. A2UI surfaces are
1145-
/// control panels / dashboards / launchers, not browser frames; clicking a
1146-
/// link inside one shouldn't dismiss it any more than clicking a link in
1147-
/// the Start Menu would. Agents that want explicit teardown should call
1148-
/// <c>canvas.hide</c> or emit <c>deleteSurface</c>.
1149-
///
1150-
/// CanvasCapability has already validated the URL with HttpUrlValidator;
1151-
/// we re-validate here as defense-in-depth so the OS-level shell-execute
1152-
/// can never see an unvetted string.
1137+
/// Service a <c>canvas.navigate</c> request inside the WebView canvas.
1138+
/// CanvasCapability has already validated the URL; re-validate here before
1139+
/// handing it to WebView2.
11531140
/// </summary>
1154-
private Task<string> OnCanvasNavigate(string url)
1141+
private async Task<string> OnCanvasNavigate(string url)
11551142
{
11561143
if (!HttpUrlValidator.TryParse(url, out var canonical, out var validationError))
11571144
{
11581145
_logger.Warn($"OnCanvasNavigate rejected (validator): {validationError}");
11591146
throw new InvalidOperationException($"Invalid url: {validationError}");
11601147
}
11611148

1162-
var initialRisk = HttpUrlRiskEvaluator.Evaluate(canonical!);
1149+
var risk = await EnrichWithDnsRiskAsync(HttpUrlRiskEvaluator.Evaluate(canonical!)).ConfigureAwait(false);
1150+
if (risk.RequiresConfirmation)
1151+
{
1152+
_logger.Warn($"Canvas navigate unsupported in canvas: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(risk.CanonicalOrigin)}");
1153+
return "unsupported_in_canvas";
1154+
}
11631155

1164-
// Move the entire decision off the request thread so the agent's
1165-
// response latency carries no signal about the user's decision (see
1166-
// long comment retained below). DNS resolution + prompt + launch all
1167-
// run from the worker.
1168-
_ = Task.Run(async () =>
1156+
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
1157+
var cts = new CancellationTokenSource();
1158+
if (!_dispatcherQueue.TryEnqueue(() =>
11691159
{
1160+
if (cts.IsCancellationRequested) return;
11701161
try
11711162
{
1172-
// Best-effort triage: resolve DNS now so a hostname pointing at
1173-
// an internal IP raises the prompt. This is NOT a pin on the
1174-
// launched request — the OS browser performs its own DNS
1175-
// resolution when handed the URL, so the actual trust boundary
1176-
// is the user's browser zone/proxy config. A second resolve
1177-
// immediately before ShellExecute would not change that.
1178-
var pinnedRisk = await EnrichWithDnsRiskAsync(initialRisk).ConfigureAwait(false);
1179-
if (await ShouldLaunchAfterPromptAsync(pinnedRisk).ConfigureAwait(false))
1180-
LaunchInDefaultBrowser(canonical!);
1163+
CloseA2UICanvasWindow();
1164+
EnsureCanvasWindow();
1165+
if (_canvasWindow == null)
1166+
throw new InvalidOperationException("Canvas window unavailable");
1167+
1168+
_canvasWindow.Navigate(canonical!);
1169+
_canvasWindow.BringToFront(false);
1170+
_logger.Info($"Canvas navigate -> canvas: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(canonical)}");
1171+
tcs.TrySetResult("canvas");
11811172
}
11821173
catch (Exception ex)
11831174
{
1184-
_logger.Error("Canvas navigate (deferred) failed", ex);
1175+
tcs.TrySetException(ex);
11851176
}
1186-
});
1177+
}))
1178+
{
1179+
tcs.TrySetException(new InvalidOperationException("Failed to dispatch canvas.navigate to UI thread"));
1180+
}
11871181

1188-
// The agent gets the same response shape and the same response time
1189-
// whether or not a confirmation prompt is needed. If we awaited the
1190-
// prompt here, response latency would leak the user's decision time
1191-
// (or even the existence of a prompt).
1192-
return Task.FromResult("browser");
1182+
return await WaitWithTimeout(tcs.Task, cts, "canvas.navigate");
11931183
}
11941184

11951185
/// <summary>
@@ -1263,7 +1253,7 @@ private async Task<bool> ShouldLaunchAfterPromptAsync(HttpUrlRiskProfile pinnedR
12631253
if (decision.Kind == UrlNavigationApprovalDecisionKind.Deny)
12641254
{
12651255
_navigationDenyCooldown[pinnedRisk.HostKey] = DateTimeOffset.UtcNow + NavigationDenyCooldownDuration;
1266-
_logger.Warn($"Canvas navigate denied: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(pinnedRisk.CanonicalOrigin)} ({decision.Reason ?? "user denied"}); already reported success to agent");
1256+
_logger.Warn($"Canvas navigate denied before WebView navigation: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(pinnedRisk.CanonicalOrigin)} ({decision.Reason ?? "user denied"})");
12671257
return false;
12681258
}
12691259
// AllowHost (session-allowlist) is currently unreachable from
@@ -1567,11 +1557,13 @@ private void EnsureCanvasWindow()
15671557
if (_canvasWindow == null || _canvasWindow.IsClosed)
15681558
{
15691559
_canvasWindow = new CanvasWindow();
1570-
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token);
1560+
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl());
15711561
}
15721562
_canvasWindow?.Activate();
15731563
}
15741564

1565+
private string? GetConfiguredGatewayUrl() => _activeGatewayUrlResolver?.Invoke();
1566+
15751567
// Mutable context shared with GatewayActionTransport. SessionKey is updated
15761568
// from push props (when the agent supplies one); host/instance stay tied to
15771569
// the node client identity. Default sessionKey is "main", matching Android's

src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml.cs

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ private static bool IsSafeDataUrl(string url)
133133

134134
public bool IsClosed { get; private set; }
135135
private string? _trustedGatewayOrigin;
136+
private string? _configuredGatewayOrigin;
136137
private string? _gatewayOriginForRewrite;
137138
private string? _gatewayToken;
138139

@@ -142,17 +143,18 @@ private static bool IsSafeDataUrl(string url)
142143
/// Also rewrites gateway URLs to use the node's effective connection
143144
/// (e.g., localhost when connected via SSH tunnel).
144145
/// </summary>
145-
public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null)
146+
public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null, string? configuredGatewayUrl = null)
146147
{
147148
if (string.IsNullOrEmpty(gatewayUrl)) return;
148149
_gatewayToken = token;
149150
try
150151
{
151-
var uri = new Uri(GatewayUrlHelper.NormalizeForWebSocket(gatewayUrl));
152-
var httpScheme = uri.Scheme == "wss" ? "https" : "http";
153-
_trustedGatewayOrigin = $"{httpScheme}://{uri.Host}:{uri.Port}";
152+
_trustedGatewayOrigin = CanvasGatewayUrlRewriter.ToHttpOrigin(gatewayUrl);
153+
_configuredGatewayOrigin = string.IsNullOrWhiteSpace(configuredGatewayUrl)
154+
? _trustedGatewayOrigin
155+
: CanvasGatewayUrlRewriter.ToHttpOrigin(configuredGatewayUrl);
154156
_gatewayOriginForRewrite = _trustedGatewayOrigin;
155-
Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}");
157+
Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}; configured gateway origin: {_configuredGatewayOrigin}");
156158
ConfigureGatewayAuthHeaderInjection();
157159
}
158160
catch (Exception ex)
@@ -172,24 +174,13 @@ private string RewriteGatewayUrl(string url)
172174
try
173175
{
174176
// Handle relative paths — prepend the gateway origin
175-
if (url.StartsWith("/"))
177+
var rewritten = CanvasGatewayUrlRewriter.Rewrite(url, _gatewayOriginForRewrite, _configuredGatewayOrigin);
178+
if (!string.Equals(url, rewritten, StringComparison.Ordinal))
176179
{
177-
var rewritten = _gatewayOriginForRewrite + url;
178180
rewritten = AppendGatewayToken(rewritten);
179-
Logger.Info($"[Canvas] Resolved relative URL to gateway origin");
180-
return rewritten;
181-
}
182-
183-
var uri = new Uri(url);
184-
var httpScheme = uri.Scheme;
185-
var urlOrigin = $"{httpScheme}://{uri.Host}:{uri.Port}";
186-
187-
// If the URL's origin differs from our effective gateway origin, rewrite it
188-
if (!urlOrigin.Equals(_gatewayOriginForRewrite, StringComparison.OrdinalIgnoreCase))
189-
{
190-
var rewritten = _gatewayOriginForRewrite + uri.PathAndQuery;
191-
rewritten = AppendGatewayToken(rewritten);
192-
Logger.Info($"[Canvas] Rewrote URL to effective gateway origin");
181+
Logger.Info(url.StartsWith("/", StringComparison.Ordinal)
182+
? "[Canvas] Resolved relative URL to gateway origin"
183+
: "[Canvas] Rewrote URL to effective gateway origin");
193184
return rewritten;
194185
}
195186

@@ -548,6 +539,7 @@ private void OnWindowClosed(object sender, WindowEventArgs args)
548539
_canvasWatcher?.Dispose();
549540
_canvasWatcher = null;
550541
_trustedGatewayOrigin = null;
542+
_configuredGatewayOrigin = null;
551543
_gatewayOriginForRewrite = null;
552544
}
553545

tests/OpenClaw.Shared.Tests/CapabilityTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,28 @@ public async Task Navigate_ResponseIncludesOpenerAndCanonicalUrl()
16881688
Assert.Contains("\"url\":\"https://example.com/Path\"", json);
16891689
}
16901690

1691+
[Theory]
1692+
[InlineData("denied")]
1693+
[InlineData("unsupported_in_canvas")]
1694+
public async Task Navigate_NotOpenedByHandler_ReturnsNotNavigated(string opener)
1695+
{
1696+
var cap = new CanvasCapability(NullLogger.Instance);
1697+
cap.NavigateRequested += _ => Task.FromResult(opener);
1698+
1699+
var req = new NodeInvokeRequest
1700+
{
1701+
Id = "c12b-denied",
1702+
Command = "canvas.navigate",
1703+
Args = Parse("""{"url":"http://127.0.0.1:9/"}""")
1704+
};
1705+
var res = await cap.ExecuteAsync(req);
1706+
Assert.True(res.Ok);
1707+
1708+
var json = System.Text.Json.JsonSerializer.Serialize(res.Payload);
1709+
Assert.Contains($"\"opener\":\"{opener}\"", json);
1710+
Assert.Contains("\"navigated\":false", json);
1711+
}
1712+
16911713
[Theory]
16921714
[InlineData("javascript:alert(1)")]
16931715
[InlineData("file:///C:/Windows/System32/calc.exe")]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using OpenClawTray.Helpers;
2+
3+
namespace OpenClaw.Tray.Tests;
4+
5+
public class CanvasGatewayUrlRewriterTests
6+
{
7+
[Fact]
8+
public void Rewrite_LeavesExternalUrlUntouched()
9+
{
10+
var rewritten = CanvasGatewayUrlRewriter.Rewrite(
11+
"https://example.com/path?q=1",
12+
"http://localhost:18789",
13+
"https://gateway.example");
14+
15+
Assert.Equal("https://example.com/path?q=1", rewritten);
16+
}
17+
18+
[Fact]
19+
public void Rewrite_MapsConfiguredGatewayOriginToEffectiveTunnelOrigin()
20+
{
21+
var rewritten = CanvasGatewayUrlRewriter.Rewrite(
22+
"https://gateway.example/__openclaw__/a2ui/?session=main",
23+
CanvasGatewayUrlRewriter.ToHttpOrigin("ws://localhost:18789"),
24+
CanvasGatewayUrlRewriter.ToHttpOrigin("wss://gateway.example"));
25+
26+
Assert.Equal("http://localhost:18789/__openclaw__/a2ui/?session=main", rewritten);
27+
}
28+
29+
[Fact]
30+
public void Rewrite_MapsRelativePathToEffectiveGatewayOrigin()
31+
{
32+
var rewritten = CanvasGatewayUrlRewriter.Rewrite(
33+
"/__openclaw__/a2ui/",
34+
"http://localhost:18789",
35+
"https://gateway.example");
36+
37+
Assert.Equal("http://localhost:18789/__openclaw__/a2ui/", rewritten);
38+
}
39+
40+
[Fact]
41+
public void ToHttpOrigin_NormalizesWebSocketUrls()
42+
{
43+
Assert.Equal("https://gateway.example:443", CanvasGatewayUrlRewriter.ToHttpOrigin("wss://gateway.example"));
44+
Assert.Equal("http://localhost:18789", CanvasGatewayUrlRewriter.ToHttpOrigin("ws://localhost:18789"));
45+
}
46+
}

tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\ToastActivationRouter.cs" Link="Services\ToastActivationRouter.cs" />
6666
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\AppNotificationService.cs" Link="Services\AppNotificationService.cs" />
6767
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\UsageCostApplicationPolicy.cs" Link="Services\UsageCostApplicationPolicy.cs" />
68+
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\CanvasGatewayUrlRewriter.cs" Link="Helpers\CanvasGatewayUrlRewriter.cs" />
6869
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\TrayTooltipFormatter.cs" Link="Helpers\TrayTooltipFormatter.cs" />
6970
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\GatewayDashboardUrlBuilder.cs" Link="Helpers\GatewayDashboardUrlBuilder.cs" />
7071
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\TrayStateSnapshot.cs" Link="Services\TrayStateSnapshot.cs" />

tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,58 @@ public void CanvasWindow_CleansUpGatewayAuthWebResourceHandler()
3434
Assert.DoesNotContain("WebResourceRequested += (", source);
3535
}
3636

37+
[Fact]
38+
public void CanvasWindow_RewritesOnlyGatewayUrls()
39+
{
40+
var source = Read(
41+
"src",
42+
"OpenClaw.Tray.WinUI",
43+
"Windows",
44+
"CanvasWindow.xaml.cs");
45+
46+
Assert.Contains("_configuredGatewayOrigin", source);
47+
Assert.Contains("CanvasGatewayUrlRewriter.Rewrite(url", source);
48+
Assert.DoesNotContain("if (!urlOrigin.Equals(_gatewayOriginForRewrite", source);
49+
}
50+
51+
[Fact]
52+
public void CanvasNavigate_UsesCanvasWindow()
53+
{
54+
var source = Read(
55+
"src",
56+
"OpenClaw.Tray.WinUI",
57+
"Services",
58+
"NodeService.cs");
59+
60+
Assert.Contains("request inside the WebView canvas", source);
61+
Assert.Contains("HttpUrlRiskEvaluator.Evaluate(canonical!)", source);
62+
Assert.Contains("EnrichWithDnsRiskAsync", source);
63+
Assert.Contains("risk.RequiresConfirmation", source);
64+
Assert.Contains("return \"unsupported_in_canvas\"", source);
65+
Assert.DoesNotContain("ShouldLaunchAfterPromptAsync(risk)", source);
66+
Assert.Contains("_canvasWindow.Navigate(canonical!)", source);
67+
Assert.Contains("tcs.TrySetResult(\"canvas\")", source);
68+
Assert.Contains("Canvas navigate -> canvas", source);
69+
}
70+
71+
[Fact]
72+
public void CanvasGatewayOrigin_ComesFromActiveGatewayRecord()
73+
{
74+
var appSource = Read(
75+
"src",
76+
"OpenClaw.Tray.WinUI",
77+
"App.xaml.cs");
78+
var nodeServiceSource = Read(
79+
"src",
80+
"OpenClaw.Tray.WinUI",
81+
"Services",
82+
"NodeService.cs");
83+
84+
Assert.Contains("activeGatewayUrlResolver: () => _gatewayRegistry?.GetActive()?.Url", appSource);
85+
Assert.Contains("private string? GetConfiguredGatewayUrl() => _activeGatewayUrlResolver?.Invoke();", nodeServiceSource);
86+
Assert.DoesNotContain("private string? GetConfiguredGatewayUrl() => _settings?.UseSshTunnel", nodeServiceSource);
87+
}
88+
3789
[Fact]
3890
public void Source_DoesNotDeclareAsyncVoidHandlers()
3991
{

0 commit comments

Comments
 (0)