@@ -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
0 commit comments