Skip to content

Commit 764265d

Browse files
Fix canvas navigation URL handling
1 parent 4be0057 commit 764265d

9 files changed

Lines changed: 222 additions & 65 deletions

File tree

src/OpenClaw.Shared/Capabilities/CanvasCapability.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ 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+
return Success(new { navigated = false, opener, url = canonical });
217+
215218
return Success(new { navigated = true, opener, url = canonical });
216219
}
217220
catch (Exception ex)

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1715,7 +1715,8 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna
17151715
() => _keepAliveWindow?.Content as FrameworkElement,
17161716
settings,
17171717
enableMcpServer: settings.EnableMcpServer,
1718-
identityDataPath: IdentityDataPath);
1718+
identityDataPath: IdentityDataPath,
1719+
configuredGatewayUrlProvider: () => _gatewayRegistry?.GetActive()?.Url);
17191720
_nodeService.StatusChanged += OnNodeStatusChanged;
17201721
_nodeService.NotificationRequested += OnNodeNotificationRequested;
17211722
_nodeService.ToastRequested += OnNodeToastRequested;
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: 33 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public sealed class NodeService : IDisposable, IAsyncDisposable
2525
private readonly IOpenClawLogger _logger;
2626
private readonly DispatcherQueue _dispatcherQueue;
2727
private readonly Func<FrameworkElement?> _rootProvider;
28+
private readonly Func<string?>? _configuredGatewayUrlProvider;
2829
private readonly SettingsManager? _settings;
2930
private readonly SemaphoreSlim _consentLock = new(1, 1);
3031
private readonly object _disposeLock = new();
@@ -188,13 +189,15 @@ public NodeService(
188189
Func<FrameworkElement?>? rootProvider = null,
189190
SettingsManager? settings = null,
190191
bool enableMcpServer = false,
191-
string? identityDataPath = null)
192+
string? identityDataPath = null,
193+
Func<string?>? configuredGatewayUrlProvider = null)
192194
{
193195
_logger = logger;
194196
_dispatcherQueue = dispatcherQueue;
195197
_dataPath = dataPath;
196198
_identityDataPath = string.IsNullOrWhiteSpace(identityDataPath) ? dataPath : identityDataPath;
197199
_rootProvider = rootProvider ?? (() => null);
200+
_configuredGatewayUrlProvider = configuredGatewayUrlProvider;
198201
_settings = settings;
199202
_enableMcpServer = enableMcpServer;
200203
_screenCaptureService = new ScreenCaptureService(logger);
@@ -860,7 +863,7 @@ private void OnCanvasPresent(object? sender, CanvasPresentArgs args)
860863
if (_canvasWindow == null || _canvasWindow.IsClosed)
861864
{
862865
_canvasWindow = new CanvasWindow();
863-
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token);
866+
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl());
864867
}
865868

866869
// Configure window
@@ -931,62 +934,47 @@ private void OnCanvasHide(object? sender, EventArgs args)
931934
}
932935

933936
/// <summary>
934-
/// Service a <c>canvas.navigate</c> request by launching the URL in the
935-
/// OS default browser. Always — even if a WebView2 canvas window is open.
936-
/// Rationale: "open this link" on Windows means the default browser, and
937-
/// the embedded WebView2 canvas runs URL-rewriting (gateway-origin pinning,
938-
/// CSP, etc.) that mangles arbitrary external URLs. Agents that want to
939-
/// load a page inside an embedded surface should use <c>canvas.present</c>.
940-
///
941-
/// Open canvas windows are NOT closed after navigate. A2UI surfaces are
942-
/// control panels / dashboards / launchers, not browser frames; clicking a
943-
/// link inside one shouldn't dismiss it any more than clicking a link in
944-
/// the Start Menu would. Agents that want explicit teardown should call
945-
/// <c>canvas.hide</c> or emit <c>deleteSurface</c>.
946-
///
947-
/// CanvasCapability has already validated the URL with HttpUrlValidator;
948-
/// we re-validate here as defense-in-depth so the OS-level shell-execute
949-
/// can never see an unvetted string.
937+
/// Service a <c>canvas.navigate</c> request inside the WebView canvas.
938+
/// CanvasCapability has already validated the URL; re-validate here before
939+
/// handing it to WebView2.
950940
/// </summary>
951-
private Task<string> OnCanvasNavigate(string url)
941+
private async Task<string> OnCanvasNavigate(string url)
952942
{
953943
if (!HttpUrlValidator.TryParse(url, out var canonical, out var validationError))
954944
{
955945
_logger.Warn($"OnCanvasNavigate rejected (validator): {validationError}");
956946
throw new InvalidOperationException($"Invalid url: {validationError}");
957947
}
958948

959-
var initialRisk = HttpUrlRiskEvaluator.Evaluate(canonical!);
949+
var risk = await EnrichWithDnsRiskAsync(HttpUrlRiskEvaluator.Evaluate(canonical!)).ConfigureAwait(false);
950+
if (!await ShouldLaunchAfterPromptAsync(risk).ConfigureAwait(false))
951+
return "denied";
960952

961-
// Move the entire decision off the request thread so the agent's
962-
// response latency carries no signal about the user's decision (see
963-
// long comment retained below). DNS resolution + prompt + launch all
964-
// run from the worker.
965-
_ = Task.Run(async () =>
953+
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
954+
if (!_dispatcherQueue.TryEnqueue(() =>
966955
{
967956
try
968957
{
969-
// Best-effort triage: resolve DNS now so a hostname pointing at
970-
// an internal IP raises the prompt. This is NOT a pin on the
971-
// launched request — the OS browser performs its own DNS
972-
// resolution when handed the URL, so the actual trust boundary
973-
// is the user's browser zone/proxy config. A second resolve
974-
// immediately before ShellExecute would not change that.
975-
var pinnedRisk = await EnrichWithDnsRiskAsync(initialRisk).ConfigureAwait(false);
976-
if (await ShouldLaunchAfterPromptAsync(pinnedRisk).ConfigureAwait(false))
977-
LaunchInDefaultBrowser(canonical!);
958+
CloseA2UICanvasWindow();
959+
EnsureCanvasWindow();
960+
if (_canvasWindow == null)
961+
throw new InvalidOperationException("Canvas window unavailable");
962+
963+
_canvasWindow.Navigate(canonical!);
964+
_canvasWindow.BringToFront(false);
965+
_logger.Info($"Canvas navigate -> canvas: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(canonical)}");
966+
tcs.TrySetResult("canvas");
978967
}
979968
catch (Exception ex)
980969
{
981-
_logger.Error("Canvas navigate (deferred) failed", ex);
970+
tcs.TrySetException(ex);
982971
}
983-
});
972+
}))
973+
{
974+
tcs.TrySetException(new InvalidOperationException("Failed to dispatch canvas.navigate to UI thread"));
975+
}
984976

985-
// The agent gets the same response shape and the same response time
986-
// whether or not a confirmation prompt is needed. If we awaited the
987-
// prompt here, response latency would leak the user's decision time
988-
// (or even the existence of a prompt).
989-
return Task.FromResult("browser");
977+
return await tcs.Task.ConfigureAwait(false);
990978
}
991979

992980
/// <summary>
@@ -1060,7 +1048,7 @@ private async Task<bool> ShouldLaunchAfterPromptAsync(HttpUrlRiskProfile pinnedR
10601048
if (decision.Kind == UrlNavigationApprovalDecisionKind.Deny)
10611049
{
10621050
_navigationDenyCooldown[pinnedRisk.HostKey] = DateTimeOffset.UtcNow + NavigationDenyCooldownDuration;
1063-
_logger.Warn($"Canvas navigate denied: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(pinnedRisk.CanonicalOrigin)} ({decision.Reason ?? "user denied"}); already reported success to agent");
1051+
_logger.Warn($"Canvas navigate denied before WebView navigation: {OpenClaw.Shared.UrlLogSanitizer.Sanitize(pinnedRisk.CanonicalOrigin)} ({decision.Reason ?? "user denied"})");
10641052
return false;
10651053
}
10661054
// AllowHost (session-allowlist) is currently unreachable from
@@ -1342,11 +1330,13 @@ private void EnsureCanvasWindow()
13421330
if (_canvasWindow == null || _canvasWindow.IsClosed)
13431331
{
13441332
_canvasWindow = new CanvasWindow();
1345-
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token);
1333+
_canvasWindow.SetTrustedGatewayOrigin(GatewayUrl, _token, GetConfiguredGatewayUrl());
13461334
}
13471335
_canvasWindow?.Activate();
13481336
}
13491337

1338+
private string? GetConfiguredGatewayUrl() => _configuredGatewayUrlProvider?.Invoke();
1339+
13501340
// Mutable context shared with GatewayActionTransport. SessionKey is updated
13511341
// from push props (when the agent supplies one); host/instance stay tied to
13521342
// 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
@@ -129,6 +129,7 @@ private static bool IsSafeDataUrl(string url)
129129

130130
public bool IsClosed { get; private set; }
131131
private string? _trustedGatewayOrigin;
132+
private string? _configuredGatewayOrigin;
132133
private string? _gatewayOriginForRewrite;
133134
private string? _gatewayToken;
134135

@@ -138,17 +139,18 @@ private static bool IsSafeDataUrl(string url)
138139
/// Also rewrites gateway URLs to use the node's effective connection
139140
/// (e.g., localhost when connected via SSH tunnel).
140141
/// </summary>
141-
public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null)
142+
public void SetTrustedGatewayOrigin(string? gatewayUrl, string? token = null, string? configuredGatewayUrl = null)
142143
{
143144
if (string.IsNullOrEmpty(gatewayUrl)) return;
144145
_gatewayToken = token;
145146
try
146147
{
147-
var uri = new Uri(GatewayUrlHelper.NormalizeForWebSocket(gatewayUrl));
148-
var httpScheme = uri.Scheme == "wss" ? "https" : "http";
149-
_trustedGatewayOrigin = $"{httpScheme}://{uri.Host}:{uri.Port}";
148+
_trustedGatewayOrigin = CanvasGatewayUrlRewriter.ToHttpOrigin(gatewayUrl);
149+
_configuredGatewayOrigin = string.IsNullOrWhiteSpace(configuredGatewayUrl)
150+
? _trustedGatewayOrigin
151+
: CanvasGatewayUrlRewriter.ToHttpOrigin(configuredGatewayUrl);
150152
_gatewayOriginForRewrite = _trustedGatewayOrigin;
151-
Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}");
153+
Logger.Info($"[Canvas] Trusted gateway origin: {_trustedGatewayOrigin}; configured gateway origin: {_configuredGatewayOrigin}");
152154
ConfigureGatewayAuthHeaderInjection();
153155
}
154156
catch (Exception ex)
@@ -168,24 +170,13 @@ private string RewriteGatewayUrl(string url)
168170
try
169171
{
170172
// Handle relative paths — prepend the gateway origin
171-
if (url.StartsWith("/"))
173+
var rewritten = CanvasGatewayUrlRewriter.Rewrite(url, _gatewayOriginForRewrite, _configuredGatewayOrigin);
174+
if (!string.Equals(url, rewritten, StringComparison.Ordinal))
172175
{
173-
var rewritten = _gatewayOriginForRewrite + url;
174176
rewritten = AppendGatewayToken(rewritten);
175-
Logger.Info($"[Canvas] Resolved relative URL to gateway origin");
176-
return rewritten;
177-
}
178-
179-
var uri = new Uri(url);
180-
var httpScheme = uri.Scheme;
181-
var urlOrigin = $"{httpScheme}://{uri.Host}:{uri.Port}";
182-
183-
// If the URL's origin differs from our effective gateway origin, rewrite it
184-
if (!urlOrigin.Equals(_gatewayOriginForRewrite, StringComparison.OrdinalIgnoreCase))
185-
{
186-
var rewritten = _gatewayOriginForRewrite + uri.PathAndQuery;
187-
rewritten = AppendGatewayToken(rewritten);
188-
Logger.Info($"[Canvas] Rewrote URL to effective gateway origin");
177+
Logger.Info(url.StartsWith("/", StringComparison.Ordinal)
178+
? "[Canvas] Resolved relative URL to gateway origin"
179+
: "[Canvas] Rewrote URL to effective gateway origin");
189180
return rewritten;
190181
}
191182

@@ -489,6 +480,7 @@ private void OnWindowClosed(object sender, WindowEventArgs args)
489480
_canvasWatcher?.Dispose();
490481
_canvasWatcher = null;
491482
_trustedGatewayOrigin = null;
483+
_configuredGatewayOrigin = null;
492484
_gatewayOriginForRewrite = null;
493485
}
494486

tests/OpenClaw.Shared.Tests/CapabilityTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,26 @@ public async Task Navigate_ResponseIncludesOpenerAndCanonicalUrl()
14311431
Assert.Contains("\"url\":\"https://example.com/Path\"", json);
14321432
}
14331433

1434+
[Fact]
1435+
public async Task Navigate_DeniedByHandler_ReturnsNotNavigated()
1436+
{
1437+
var cap = new CanvasCapability(NullLogger.Instance);
1438+
cap.NavigateRequested += _ => Task.FromResult("denied");
1439+
1440+
var req = new NodeInvokeRequest
1441+
{
1442+
Id = "c12b-denied",
1443+
Command = "canvas.navigate",
1444+
Args = Parse("""{"url":"http://127.0.0.1:9/"}""")
1445+
};
1446+
var res = await cap.ExecuteAsync(req);
1447+
Assert.True(res.Ok);
1448+
1449+
var json = System.Text.Json.JsonSerializer.Serialize(res.Payload);
1450+
Assert.Contains("\"opener\":\"denied\"", json);
1451+
Assert.Contains("\"navigated\":false", json);
1452+
}
1453+
14341454
[Theory]
14351455
[InlineData("javascript:alert(1)")]
14361456
[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
@@ -58,6 +58,7 @@
5858
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\NodeInvokeActivityFormatter.cs" Link="Services\NodeInvokeActivityFormatter.cs" />
5959
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\NodeCapabilityGating.cs" Link="Services\NodeCapabilityGating.cs" />
6060
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\ToastActivationRouter.cs" Link="Services\ToastActivationRouter.cs" />
61+
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\CanvasGatewayUrlRewriter.cs" Link="Helpers\CanvasGatewayUrlRewriter.cs" />
6162
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\TrayTooltipFormatter.cs" Link="Helpers\TrayTooltipFormatter.cs" />
6263
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Helpers\GatewayDashboardUrlBuilder.cs" Link="Helpers\GatewayDashboardUrlBuilder.cs" />
6364
<Compile Include="..\..\src\OpenClaw.Tray.WinUI\Services\TrayStateSnapshot.cs" Link="Services\TrayStateSnapshot.cs" />

0 commit comments

Comments
 (0)