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