From 4efc213281d1678cb25e0e7c30617f1a5f942275 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:15:05 -0700 Subject: [PATCH 01/20] fix(connection): open the dashboard on the tunnel, not the saved URL Signed-off-by: Sebastien Tardif --- .../GatewayClientEndpointResolver.cs | 39 ++++++++ .../App.CapabilityHandlers.cs | 22 ++++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 44 ++++++++- .../Pages/ConnectionPage.xaml.cs | 17 +++- .../GatewayClientEndpointResolverTests.cs | 95 +++++++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs index b2239b129..a965a0ed1 100644 --- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs +++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs @@ -18,4 +18,43 @@ public static string Resolve(GatewayRecord record) return $"ws://localhost:{tunnel.LocalPort}"; } + + /// + /// Picks the WebSocket endpoint a dashboard may open. + /// SSH records open only the local forward, and only while that forward is up. + /// The shared token must not be attached to the saved, untunneled address. + /// + public static bool TryResolveDashboardEndpoint( + GatewayRecord record, + SshTunnelSnapshot? tunnel, + out string endpoint, + out bool appendSharedToken) + { + ArgumentNullException.ThrowIfNull(record); + + if (record.SshTunnel is not { } config) + { + endpoint = record.Url; + appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken); + return !string.IsNullOrWhiteSpace(endpoint); + } + + if (!IsDashboardTunnelUp(config, tunnel)) + { + endpoint = ""; + appendSharedToken = false; + return false; + } + + endpoint = Resolve(record); + appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken); + return true; + } + + private static bool IsDashboardTunnelUp(SshTunnelConfig config, SshTunnelSnapshot? tunnel) => + tunnel is { IsRunning: true, Status: OpenClaw.Shared.TunnelStatus.Up } && + tunnel.CurrentLocalPort == config.LocalPort && + tunnel.CurrentRemotePort == config.RemotePort && + string.Equals(tunnel.CurrentUser, config.User, StringComparison.Ordinal) && + string.Equals(tunnel.CurrentHost, config.Host, StringComparison.OrdinalIgnoreCase); } diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index fc591680b..9c5fe7a7c 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -239,11 +239,29 @@ private void WireAppCapabilityHandlers() if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) return new { error = "Gateway URL or credential is not configured" }; + var endpoint = gatewayUrl; + var appendSharedToken = + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; + var active = _gatewayRegistry?.GetActive(); + if (active?.SshTunnel is not null) + { + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + active, + _sshTunnelService?.CreateSnapshot(), + out endpoint, + out var tunnelAllowsToken)) + { + return new { error = "Dashboard blocked because the SSH tunnel is not up." }; + } + + appendSharedToken = appendSharedToken && tunnelAllowsToken; + } + var url = GatewayDashboardUrlBuilder.Build( - gatewayUrl, + endpoint, path, token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + appendSharedToken); return new { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fdc2b9bf0..20ef83b4c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -149,6 +149,9 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer /// Ensures the managed SSH tunnel is started using the current settings. /// Used by connection settings when the user picks the SSH topology. /// + internal SshTunnelSnapshot? CaptureSshTunnelSnapshot() => + _sshTunnelService?.CreateSnapshot(); + public void EnsureSshTunnelStarted() { if (_sshTunnelService == null || _settings == null) @@ -3892,6 +3895,40 @@ private bool TryResolveChatCredentials( private void OpenDashboard(string? path = null) { if (_settings == null) return; + + var active = _gatewayRegistry?.GetActive(); + if (active?.SshTunnel is not null) + { + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + active, + _sshTunnelService?.CreateSnapshot(), + out var tunnelEndpoint, + out var appendSharedToken)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Dashboard blocked because the SSH tunnel is not up.")); + return; + } + + if (!TryResolveChatCredentials(out _, out var tunnelToken, out var tunnelCredentialSource, out var tunnelIsBootstrapToken)) + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + return; + } + + OpenDashboardUri(GatewayDashboardUrlBuilder.Build( + tunnelEndpoint, + path, + tunnelToken, + appendSharedToken && + !tunnelIsBootstrapToken && + tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken)); + return; + } + if (!EnsureSshTunnelConfigured()) { _toastService?.ShowToast(new ToastContentBuilder() @@ -3908,12 +3945,15 @@ private void OpenDashboard(string? path = null) return; } - var url = GatewayDashboardUrlBuilder.Build( + OpenDashboardUri(GatewayDashboardUrlBuilder.Build( gatewayUrl, path, token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken)); + } + private static void OpenDashboardUri(string url) + { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 42d4feaec..afd20f78a 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2570,7 +2570,18 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) if (rec == null) return; try { - if (!string.IsNullOrWhiteSpace(rec.SharedGatewayToken)) + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + rec, + CurrentApp.CaptureSshTunnelSnapshot(), + out var endpoint, + out var appendSharedToken)) + { + CurrentApp.ShowTransientConnectionError( + "Dashboard blocked because the SSH tunnel is not up."); + return; + } + + if (appendSharedToken && rec.SshTunnel is null) { var provenanceService = CurrentApp.ManagedLocalPortProvenance; if (provenanceService is null) @@ -2589,10 +2600,10 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } var url = GatewayDashboardUrlBuilder.Build( - rec.Url, + endpoint, path: null, rec.SharedGatewayToken, - appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken)); + appendSharedGatewayToken: appendSharedToken); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } catch (Exception ex) diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs index 5511d22c9..40049a345 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs @@ -1,3 +1,5 @@ +using OpenClaw.Shared; + namespace OpenClaw.Connection.Tests; public sealed class GatewayClientEndpointResolverTests @@ -52,4 +54,97 @@ public void Resolve_RejectsInvalidTunnelLocalPort(int localPort) Assert.Contains("local port", error.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordDoesNotOpenSavedUrlWhenTunnelIsDown() + { + var record = SshRecord(); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel: null, + out var endpoint, + out var appendSharedToken); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordUsesTunnelOnlyWhenThatForwardIsUp() + { + var record = SshRecord(); + var tunnel = UpTunnel(localPort: 45678); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken); + + Assert.True(opened); + Assert.Equal("ws://localhost:45678", endpoint); + Assert.True(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordRejectsADifferentLocalForward() + { + var record = SshRecord(); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + UpTunnel(localPort: 9999), + out var endpoint, + out var appendSharedToken); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_DirectRecordKeepsSavedUrl() + { + var record = new GatewayRecord + { + Id = "direct", + Url = "ws://127.0.0.1:18789", + SharedGatewayToken = "shared-secret", + }; + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel: null, + out var endpoint, + out var appendSharedToken); + + Assert.True(opened); + Assert.Equal("ws://127.0.0.1:18789", endpoint); + Assert.True(appendSharedToken); + } + + private static GatewayRecord SshRecord() => new() + { + Id = "ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = "shared-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + }; + + private static SshTunnelSnapshot UpTunnel(int localPort) => new( + IsRunning: true, + CurrentUser: "user", + CurrentHost: "gateway.example", + CurrentRemotePort: 18789, + CurrentLocalPort: localPort, + CurrentBrowserProxyRemotePort: 0, + CurrentBrowserProxyLocalPort: 0, + StartedAtUtc: new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), + LastError: null, + Status: TunnelStatus.Up); } From acb5744a9336f8536b4ebb7075c3812aad5577c6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 21:34:41 -0700 Subject: [PATCH 02/20] fix(connection): bind dashboard tokens to the owned SSH tunnel Require the SSH server port and a verified listener before a dashboard URL can carry a shared token, and read the token from the same gateway record as the tunnel. Signed-off-by: Sebastien Tardif --- .../GatewayClientEndpointResolver.cs | 6 ++- src/OpenClaw.Connection/SshTunnelService.cs | 5 +- src/OpenClaw.Connection/SshTunnelSnapshot.cs | 3 +- .../Capabilities/AppCapability.cs | 8 +-- .../App.CapabilityHandlers.cs | 50 +++++++++++------- src/OpenClaw.Tray.WinUI/App.xaml.cs | 45 ++++++++++------ .../Pages/ConnectionPage.xaml.cs | 28 ++++++---- .../GatewayClientEndpointResolverTests.cs | 52 +++++++++++++++++-- .../AppRefactorContractTests.cs | 4 +- 9 files changed, 144 insertions(+), 57 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs index a965a0ed1..4c734e096 100644 --- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs +++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs @@ -28,7 +28,8 @@ public static bool TryResolveDashboardEndpoint( GatewayRecord record, SshTunnelSnapshot? tunnel, out string endpoint, - out bool appendSharedToken) + out bool appendSharedToken, + bool listenerOwned = false) { ArgumentNullException.ThrowIfNull(record); @@ -39,7 +40,7 @@ public static bool TryResolveDashboardEndpoint( return !string.IsNullOrWhiteSpace(endpoint); } - if (!IsDashboardTunnelUp(config, tunnel)) + if (!listenerOwned || !IsDashboardTunnelUp(config, tunnel)) { endpoint = ""; appendSharedToken = false; @@ -55,6 +56,7 @@ private static bool IsDashboardTunnelUp(SshTunnelConfig config, SshTunnelSnapsho tunnel is { IsRunning: true, Status: OpenClaw.Shared.TunnelStatus.Up } && tunnel.CurrentLocalPort == config.LocalPort && tunnel.CurrentRemotePort == config.RemotePort && + tunnel.CurrentSshPort == config.SshPort && string.Equals(tunnel.CurrentUser, config.User, StringComparison.Ordinal) && string.Equals(tunnel.CurrentHost, config.Host, StringComparison.OrdinalIgnoreCase); } diff --git a/src/OpenClaw.Connection/SshTunnelService.cs b/src/OpenClaw.Connection/SshTunnelService.cs index 14c054c14..48dd55258 100644 --- a/src/OpenClaw.Connection/SshTunnelService.cs +++ b/src/OpenClaw.Connection/SshTunnelService.cs @@ -62,6 +62,7 @@ public SshTunnelConfig? ActiveConfig } public string? LocalTunnelUrl => IsActive ? $"ws://localhost:{CurrentLocalPort}" : null; public string? CurrentUser { get; private set; } + public int CurrentSshPort { get; private set; } public string? CurrentHost { get; private set; } public int CurrentRemotePort { get; private set; } public int CurrentLocalPort { get; private set; } @@ -85,7 +86,8 @@ public SshTunnelSnapshot CreateSnapshot() CurrentBrowserProxyLocalPort, StartedAtUtc, LastError, - Status); + Status, + CurrentSshPort); } } @@ -389,6 +391,7 @@ private void StartProcess(SshTunnelConfig tunnel, SshTunnelOwner owner, string s _processStarted = true; CurrentUser = user; + CurrentSshPort = sshPort; CurrentHost = host; CurrentRemotePort = remotePort; CurrentLocalPort = localPort; diff --git a/src/OpenClaw.Connection/SshTunnelSnapshot.cs b/src/OpenClaw.Connection/SshTunnelSnapshot.cs index 060f7196a..7c37c72ae 100644 --- a/src/OpenClaw.Connection/SshTunnelSnapshot.cs +++ b/src/OpenClaw.Connection/SshTunnelSnapshot.cs @@ -15,4 +15,5 @@ public sealed record SshTunnelSnapshot( int CurrentBrowserProxyLocalPort, DateTime? StartedAtUtc, string? LastError, - TunnelStatus Status); + TunnelStatus Status, + int CurrentSshPort = 22); diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index 5faac7ccc..d4424ecf1 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -46,7 +46,7 @@ public class AppCapability : NodeCapabilityBase public Func? SettingsSetHandler; public Func? MenuHandler; public Func? SearchHandler; - public Func? DashboardUrlHandler; + public Func>? DashboardUrlHandler; public Func>? ChatSnapshotHandler; public Func>? ChatSendHandler; public Func>? ChatResetHandler; @@ -69,7 +69,7 @@ public override async Task ExecuteAsync(NodeInvokeRequest re "app.settings.set" => HandleSettingsSet(request), "app.menu" => HandleMenu(), "app.search" => HandleSearch(request), - "app.dashboard.url" => HandleDashboardUrl(request), + "app.dashboard.url" => await HandleDashboardUrl(request), "app.chat.snapshot" => await HandleChatSnapshot(request), "app.chat.send" => await HandleChatSend(request), "app.chat.reset" => await HandleChatReset(request), @@ -197,11 +197,11 @@ private NodeInvokeResponse HandleSearch(NodeInvokeRequest request) return Success(SearchHandler(query)); } - private NodeInvokeResponse HandleDashboardUrl(NodeInvokeRequest request) + private async Task HandleDashboardUrl(NodeInvokeRequest request) { if (DashboardUrlHandler == null) return Error("Dashboard URL handler not registered"); - return Success(DashboardUrlHandler(GetStringArg(request.Args, "path"))); + return Success(await DashboardUrlHandler(GetStringArg(request.Args, "path"))); } private async Task HandleChatSnapshot(NodeInvokeRequest request) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 9c5fe7a7c..05977e525 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -234,41 +234,55 @@ private void WireAppCapabilityHandlers() return matches; }; - app.DashboardUrlHandler = (path) => + app.DashboardUrlHandler = async (path) => { - if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) - return new { error = "Gateway URL or credential is not configured" }; - - var endpoint = gatewayUrl; - var appendSharedToken = - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken; - var active = _gatewayRegistry?.GetActive(); - if (active?.SshTunnel is not null) + var pinned = _gatewayRegistry?.GetActive(); + if (pinned?.SshTunnel is { } ssh) { + var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!DashboardPinStillMatches(pinned)) + return new { error = "Dashboard blocked because the gateway changed." }; + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( - active, + pinned, _sshTunnelService?.CreateSnapshot(), - out endpoint, - out var tunnelAllowsToken)) + out var endpoint, + out var appendSharedToken, + listenerOwned)) { return new { error = "Dashboard blocked because the SSH tunnel is not up." }; } - appendSharedToken = appendSharedToken && tunnelAllowsToken; + var url = GatewayDashboardUrlBuilder.Build( + endpoint, + path, + pinned.SharedGatewayToken, + appendSharedToken); + + return new + { + url, + credentialSource = CredentialResolver.SourceSharedGatewayToken, + usesSharedGatewayToken = appendSharedToken, + hasTokenQuery = url.Contains("?token=", StringComparison.Ordinal) || url.Contains("&token=", StringComparison.Ordinal) + }; } - var url = GatewayDashboardUrlBuilder.Build( - endpoint, + if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) + return new { error = "Gateway URL or credential is not configured" }; + + var directUrl = GatewayDashboardUrlBuilder.Build( + gatewayUrl, path, token, - appendSharedToken); + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); return new { - url, + url = directUrl, credentialSource, usesSharedGatewayToken = !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, - hasTokenQuery = url.Contains("?token=", StringComparison.Ordinal) || url.Contains("&token=", StringComparison.Ordinal) + hasTokenQuery = directUrl.Contains("?token=", StringComparison.Ordinal) || directUrl.Contains("&token=", StringComparison.Ordinal) }; }; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 20ef83b4c..acec48a03 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -152,6 +152,19 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands, IPer internal SshTunnelSnapshot? CaptureSshTunnelSnapshot() => _sshTunnelService?.CreateSnapshot(); + internal Task IsDashboardListenerOwnedAsync(SshTunnelConfig config) => + _sshTunnelService?.IsOwnedListenerReadyAsync(config, config.LocalPort, CancellationToken.None) + ?? Task.FromResult(false); + + internal bool DashboardPinStillMatches(GatewayRecord pinned) + { + var current = _gatewayRegistry?.GetById(pinned.Id); + return current is not null + && string.Equals(current.Id, pinned.Id, StringComparison.Ordinal) + && string.Equals(current.SharedGatewayToken, pinned.SharedGatewayToken, StringComparison.Ordinal) + && current.SshTunnel == pinned.SshTunnel; + } + public void EnsureSshTunnelStarted() { if (_sshTunnelService == null || _settings == null) @@ -3893,17 +3906,27 @@ private bool TryResolveChatCredentials( #region Actions private void OpenDashboard(string? path = null) + { + _ = OpenDashboardAsync(path); + } + + private async Task OpenDashboardAsync(string? path = null) { if (_settings == null) return; - var active = _gatewayRegistry?.GetActive(); - if (active?.SshTunnel is not null) + var pinned = _gatewayRegistry?.GetActive(); + if (pinned?.SshTunnel is { } ssh) { + var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!DashboardPinStillMatches(pinned)) + return; + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( - active, + pinned, _sshTunnelService?.CreateSnapshot(), out var tunnelEndpoint, - out var appendSharedToken)) + out var appendSharedToken, + listenerOwned)) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("SSH tunnel") @@ -3911,21 +3934,11 @@ private void OpenDashboard(string? path = null) return; } - if (!TryResolveChatCredentials(out _, out var tunnelToken, out var tunnelCredentialSource, out var tunnelIsBootstrapToken)) - { - ShowConnectionSettingsForPairingIssue( - "Dashboard", - "Gateway URL or credential is not configured"); - return; - } - OpenDashboardUri(GatewayDashboardUrlBuilder.Build( tunnelEndpoint, path, - tunnelToken, - appendSharedToken && - !tunnelIsBootstrapToken && - tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken)); + pinned.SharedGatewayToken, + appendSharedToken)); return; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index afd20f78a..cea906c8e 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2566,32 +2566,42 @@ private void OnSavedRowOpenDashboard(object sender, RoutedEventArgs e) => private async Task OnSavedRowOpenDashboardAsync(object sender) { if (sender is not MenuFlyoutItem item || item.Tag is not string gwId) return; - var rec = _gatewayRegistry?.GetById(gwId); - if (rec == null) return; + var pinned = _gatewayRegistry?.GetById(gwId); + if (pinned == null) return; try { + var listenerOwned = false; + if (pinned.SshTunnel is { } ssh) + listenerOwned = await CurrentApp.IsDashboardListenerOwnedAsync(ssh); + + if (!CurrentApp.DashboardPinStillMatches(pinned)) + return; + if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( - rec, + pinned, CurrentApp.CaptureSshTunnelSnapshot(), out var endpoint, - out var appendSharedToken)) + out var appendSharedToken, + listenerOwned)) { CurrentApp.ShowTransientConnectionError( "Dashboard blocked because the SSH tunnel is not up."); return; } - if (appendSharedToken && rec.SshTunnel is null) + if (appendSharedToken && pinned.SshTunnel is null) { var provenanceService = CurrentApp.ManagedLocalPortProvenance; if (provenanceService is null) return; - _ = await provenanceService.InspectAsync(rec); + _ = await provenanceService.InspectAsync(pinned); + if (!CurrentApp.DashboardPinStillMatches(pinned)) + return; var candidate = new GatewayCredential( - rec.SharedGatewayToken!, + pinned.SharedGatewayToken!, IsBootstrapToken: false, CredentialResolver.SourceSharedGatewayToken); - if (!provenanceService.IsStrongCredentialAllowed(rec, candidate)) + if (!provenanceService.IsStrongCredentialAllowed(pinned, candidate)) { CurrentApp.ShowTransientConnectionError( "Dashboard blocked because the saved gateway address is not owned by the verified managed gateway."); @@ -2602,7 +2612,7 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) var url = GatewayDashboardUrlBuilder.Build( endpoint, path: null, - rec.SharedGatewayToken, + pinned.SharedGatewayToken, appendSharedGatewayToken: appendSharedToken); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs index 40049a345..7cc9d4af7 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs @@ -82,7 +82,8 @@ public void TryResolveDashboardEndpoint_SshRecordUsesTunnelOnlyWhenThatForwardIs record, tunnel, out var endpoint, - out var appendSharedToken); + out var appendSharedToken, + listenerOwned: true); Assert.True(opened); Assert.Equal("ws://localhost:45678", endpoint); @@ -90,6 +91,46 @@ public void TryResolveDashboardEndpoint_SshRecordUsesTunnelOnlyWhenThatForwardIs Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryResolveDashboardEndpoint_SshRecordRejectsDifferentSshServerPort() + { + var record = SshRecord(sshPort: 2222); + var tunnel = UpTunnel(localPort: 45678, currentSshPort: 22); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: true); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.Equal("", endpoint); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + + [Fact] + public void TryResolveDashboardEndpoint_SshRecordDoesNotOpenWhenListenerIsNotOwned() + { + var record = SshRecord(); + var tunnel = UpTunnel(localPort: 45678, currentSshPort: 22); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: false); + + Assert.False(opened); + Assert.False(appendSharedToken); + Assert.Equal("", endpoint); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("shared-secret", endpoint, StringComparison.Ordinal); + } + [Fact] public void TryResolveDashboardEndpoint_SshRecordRejectsADifferentLocalForward() { @@ -128,15 +169,15 @@ public void TryResolveDashboardEndpoint_DirectRecordKeepsSavedUrl() Assert.True(appendSharedToken); } - private static GatewayRecord SshRecord() => new() + private static GatewayRecord SshRecord(int sshPort = 22) => new() { Id = "ssh", Url = "ws://gateway.example:18789", SharedGatewayToken = "shared-secret", - SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678, SshPort: sshPort), }; - private static SshTunnelSnapshot UpTunnel(int localPort) => new( + private static SshTunnelSnapshot UpTunnel(int localPort, int currentSshPort = 22) => new( IsRunning: true, CurrentUser: "user", CurrentHost: "gateway.example", @@ -146,5 +187,6 @@ public void TryResolveDashboardEndpoint_DirectRecordKeepsSavedUrl() CurrentBrowserProxyLocalPort: 0, StartedAtUtc: new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc), LastError: null, - Status: TunnelStatus.Up); + Status: TunnelStatus.Up, + CurrentSshPort: currentSshPort); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 7d72af69c..8a6ab59e7 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -455,8 +455,10 @@ public void LifecycleStatus_IsWrittenFromManagerSnapshotOnly() public void Dashboard_SurfacesSshTunnelConfigurationFailure() { var source = ReadAppSources(); - var method = ExtractMethod(source, "OpenDashboard"); + var method = ExtractMethod(source, "OpenDashboardAsync"); + Assert.Contains("IsDashboardListenerOwnedAsync", method); + Assert.Contains("DashboardPinStillMatches", method); Assert.Contains("if (!EnsureSshTunnelConfigured())", method); Assert.Contains("_toastService?.ShowToast", method); Assert.Contains("Check SSH tunnel settings and logs.", method); From 51c2586a007010f9cd1038060815698dca62372a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:21:21 -0700 Subject: [PATCH 03/20] fix(connection): keep dashboard credential precedence on the pinned record An SSH dashboard was always appending the shared token and labeling that as the MCP source. The URL now follows the pinned record's credential source, and a pin mismatch returns no token URL. Signed-off-by: Sebastien Tardif --- .../DashboardCredentialGate.cs | 71 +++++++++++ .../InteractiveGatewayCredentialResolver.cs | 116 ++++++++++++------ .../App.CapabilityHandlers.cs | 37 +++++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 98 ++++++++++++++- .../Pages/ConnectionPage.xaml.cs | 48 +++++++- .../DashboardCredentialGateTests.cs | 75 +++++++++++ 6 files changed, 396 insertions(+), 49 deletions(-) create mode 100644 src/OpenClaw.Connection/DashboardCredentialGate.cs create mode 100644 tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs diff --git a/src/OpenClaw.Connection/DashboardCredentialGate.cs b/src/OpenClaw.Connection/DashboardCredentialGate.cs new file mode 100644 index 000000000..af4d81fcd --- /dev/null +++ b/src/OpenClaw.Connection/DashboardCredentialGate.cs @@ -0,0 +1,71 @@ +namespace OpenClaw.Connection; + +/// +/// Decides whether a dashboard URL may carry a token for one pinned gateway record. +/// Device and bootstrap credentials are not written into the URL. A shared token is +/// appended only when that is the credential resolved for the same pin. +/// +public static class DashboardCredentialGate +{ + public const string PinMismatchMessage = "Dashboard blocked because of a pin mismatch."; + + public static DashboardCredentialDecision Decide( + bool pinMatches, + bool samePinnedRecord, + bool tunnelAllowsSharedToken, + string? credentialSource, + bool isBootstrapToken, + string? resolvedToken, + string? pinnedSharedToken) + { + if (!pinMatches || !samePinnedRecord) + return DashboardCredentialDecision.Mismatch; + + var source = string.IsNullOrWhiteSpace(credentialSource) ? "none" : credentialSource; + var sharedChosen = !isBootstrapToken + && string.Equals(source, CredentialResolver.SourceSharedGatewayToken, StringComparison.Ordinal); + + if (!sharedChosen) + { + return new DashboardCredentialDecision( + AppendToken: false, + CredentialSource: source, + Token: null, + PinMismatch: false); + } + + if (!tunnelAllowsSharedToken) + { + return new DashboardCredentialDecision( + AppendToken: false, + CredentialSource: source, + Token: null, + PinMismatch: false); + } + + if (string.IsNullOrEmpty(resolvedToken) + || !string.Equals(resolvedToken, pinnedSharedToken, StringComparison.Ordinal)) + { + return DashboardCredentialDecision.Mismatch; + } + + return new DashboardCredentialDecision( + AppendToken: true, + CredentialSource: source, + Token: resolvedToken, + PinMismatch: false); + } +} + +public readonly record struct DashboardCredentialDecision( + bool AppendToken, + string CredentialSource, + string? Token, + bool PinMismatch) +{ + public static DashboardCredentialDecision Mismatch { get; } = new( + AppendToken: false, + CredentialSource: "none", + Token: null, + PinMismatch: true); +} diff --git a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs index 7f588f421..94dc16317 100644 --- a/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs +++ b/src/OpenClaw.Connection/InteractiveGatewayCredentialResolver.cs @@ -60,47 +60,19 @@ public static bool TryResolve( var active = registry?.GetActive(); if (active != null && !string.IsNullOrWhiteSpace(active.Url)) { - // For HTTP surfaces (chat), prefer SharedGatewayToken over DeviceToken. - // DeviceToken is for WebSocket auth (auth.deviceToken); SharedGatewayToken - // is for HTTP ?token= auth which the chat/dashboard endpoints expect. - if (!string.IsNullOrWhiteSpace(active.SharedGatewayToken)) + if (TryResolveRecord( + active, + registry!.GetIdentityDirectory(active.Id), + identityReader, + authorizeCredential, + out credential, + out var rejected)) { - var sharedCredential = new GatewayCredential( - active.SharedGatewayToken!, - IsBootstrapToken: false, - CredentialResolver.SourceSharedGatewayToken); - if (authorizeCredential is not null && - !authorizeCredential(active, sharedCredential)) - { - credential = null; - return false; - } - credential = new InteractiveGatewayCredential( - active.Url, - active.SharedGatewayToken!, - false, - CredentialResolver.SourceSharedGatewayToken); return true; } - // Fall back to standard credential resolution (DeviceToken → Bootstrap) - var resolver = new CredentialResolver(identityReader); - var resolved = resolver.ResolveOperator(active, registry!.GetIdentityDirectory(active.Id)); - if (resolved != null) - { - if (authorizeCredential is not null && - !authorizeCredential(active, resolved)) - { - credential = null; - return false; - } - credential = new InteractiveGatewayCredential( - active.Url, - resolved.Token, - resolved.IsBootstrapToken, - resolved.Source); - return true; - } + if (rejected) + return false; if (!string.Equals(active.Url, effectiveGatewayUrl, StringComparison.OrdinalIgnoreCase)) { @@ -145,6 +117,76 @@ public static bool TryResolve( legacyCredential.Source); return true; } + + /// + /// Resolves HTTP credentials for one gateway record. + /// Shared token wins for dashboard/chat URLs when it is present and allowed. + /// Otherwise the operator resolver order applies: device token, then bootstrap. + /// + public static bool TryResolveRecord( + GatewayRecord record, + string identityDirectory, + IDeviceIdentityReader identityReader, + Func? authorizeCredential, + out InteractiveGatewayCredential? credential, + out bool rejected) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentException.ThrowIfNullOrWhiteSpace(identityDirectory); + ArgumentNullException.ThrowIfNull(identityReader); + + rejected = false; + if (string.IsNullOrWhiteSpace(record.Url)) + { + credential = null; + return false; + } + + // HTTP surfaces prefer the shared token. DeviceToken is WebSocket auth. + if (!string.IsNullOrWhiteSpace(record.SharedGatewayToken)) + { + var sharedCredential = new GatewayCredential( + record.SharedGatewayToken!, + IsBootstrapToken: false, + CredentialResolver.SourceSharedGatewayToken); + if (authorizeCredential is not null && + !authorizeCredential(record, sharedCredential)) + { + credential = null; + rejected = true; + return false; + } + + credential = new InteractiveGatewayCredential( + record.Url, + record.SharedGatewayToken!, + false, + CredentialResolver.SourceSharedGatewayToken); + return true; + } + + var resolved = new CredentialResolver(identityReader).ResolveOperator(record, identityDirectory); + if (resolved == null) + { + credential = null; + return false; + } + + if (authorizeCredential is not null && + !authorizeCredential(record, resolved)) + { + credential = null; + rejected = true; + return false; + } + + credential = new InteractiveGatewayCredential( + record.Url, + resolved.Token, + resolved.IsBootstrapToken, + resolved.Source); + return true; + } } public sealed record InteractiveGatewayCredential( diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 05977e525..f919b78b8 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -240,8 +240,18 @@ private void WireAppCapabilityHandlers() if (pinned?.SshTunnel is { } ssh) { var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); - if (!DashboardPinStillMatches(pinned)) - return new { error = "Dashboard blocked because the gateway changed." }; + if (!TryResolvePinnedDashboardCredential( + pinned, + out var pinnedToken, + out var pinnedCredentialSource, + out var pinnedIsBootstrapToken, + out var pinMismatch)) + { + if (pinMismatch) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + + return new { error = "Gateway URL or credential is not configured" }; + } if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( pinned, @@ -253,17 +263,32 @@ private void WireAppCapabilityHandlers() return new { error = "Dashboard blocked because the SSH tunnel is not up." }; } + var decision = DashboardCredentialGate.Decide( + DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + pinnedCredentialSource, + pinnedIsBootstrapToken, + pinnedToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + var url = GatewayDashboardUrlBuilder.Build( endpoint, path, - pinned.SharedGatewayToken, - appendSharedToken); + decision.Token, + decision.AppendToken && + !pinnedIsBootstrapToken && + pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken); return new { url, - credentialSource = CredentialResolver.SourceSharedGatewayToken, - usesSharedGatewayToken = appendSharedToken, + credentialSource = decision.CredentialSource, + usesSharedGatewayToken = decision.AppendToken && + !pinnedIsBootstrapToken && + pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken, hasTokenQuery = url.Contains("?token=", StringComparison.Ordinal) || url.Contains("&token=", StringComparison.Ordinal) }; } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index acec48a03..31f6f7337 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -162,9 +162,61 @@ internal bool DashboardPinStillMatches(GatewayRecord pinned) return current is not null && string.Equals(current.Id, pinned.Id, StringComparison.Ordinal) && string.Equals(current.SharedGatewayToken, pinned.SharedGatewayToken, StringComparison.Ordinal) + && string.Equals(current.BootstrapToken, pinned.BootstrapToken, StringComparison.Ordinal) + && string.Equals(current.Url, pinned.Url, StringComparison.OrdinalIgnoreCase) && current.SshTunnel == pinned.SshTunnel; } + internal bool TryResolvePinnedDashboardCredential( + GatewayRecord pinned, + out string token, + out string credentialSource, + out bool isBootstrapToken, + out bool pinMismatch) + { + token = string.Empty; + credentialSource = "none"; + isBootstrapToken = false; + pinMismatch = false; + + if (!DashboardPinStillMatches(pinned)) + { + pinMismatch = true; + return false; + } + + if (_gatewayRegistry is null || _settings is null) + return false; + + var identityDirectory = _gatewayRegistry.GetIdentityDirectory(pinned.Id); + var credential = new CredentialResolver(DeviceIdentityFileReader.Instance) + .ResolveOperator(pinned, identityDirectory); + + if (!DashboardPinStillMatches(pinned)) + { + token = string.Empty; + credentialSource = "none"; + isBootstrapToken = false; + pinMismatch = true; + Logger.Warn("Dashboard pin mismatch after credential resolution; refusing token URL."); + return false; + } + + if (credential is null) + return false; + + if (credential.Source == CredentialResolver.SourceSharedGatewayToken && + _managedLocalPortProvenance?.IsStrongCredentialAllowed(pinned, credential) == false) + { + return false; + } + + token = credential.Token; + credentialSource = credential.Source; + isBootstrapToken = credential.IsBootstrapToken; + return true; + } + public void EnsureSshTunnelStarted() { if (_sshTunnelService == null || _settings == null) @@ -3918,8 +3970,29 @@ private async Task OpenDashboardAsync(string? path = null) if (pinned?.SshTunnel is { } ssh) { var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); - if (!DashboardPinStillMatches(pinned)) + if (!TryResolvePinnedDashboardCredential( + pinned, + out var tunnelToken, + out var tunnelCredentialSource, + out var tunnelIsBootstrapToken, + out var pinMismatch)) + { + if (pinMismatch) + { + Logger.Warn("Dashboard pin mismatch; refusing token URL."); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + } + else + { + ShowConnectionSettingsForPairingIssue( + "Dashboard", + "Gateway URL or credential is not configured"); + } + return; + } if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( pinned, @@ -3934,11 +4007,30 @@ private async Task OpenDashboardAsync(string? path = null) return; } + var decision = DashboardCredentialGate.Decide( + DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + tunnelCredentialSource, + tunnelIsBootstrapToken, + tunnelToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + { + Logger.Warn("Dashboard pin mismatch; refusing token URL."); + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + return; + } + OpenDashboardUri(GatewayDashboardUrlBuilder.Build( tunnelEndpoint, path, - pinned.SharedGatewayToken, - appendSharedToken)); + decision.Token, + decision.AppendToken && + !tunnelIsBootstrapToken && + tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken)); return; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index cea906c8e..6d4b917e6 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2575,7 +2575,10 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) listenerOwned = await CurrentApp.IsDashboardListenerOwnedAsync(ssh); if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); return; + } if (!GatewayClientEndpointResolver.TryResolveDashboardEndpoint( pinned, @@ -2589,14 +2592,53 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) return; } - if (appendSharedToken && pinned.SshTunnel is null) + string? dashboardToken = pinned.SharedGatewayToken; + var appendDashboardToken = appendSharedToken; + if (pinned.SshTunnel is not null) + { + if (!CurrentApp.TryResolvePinnedDashboardCredential( + pinned, + out var resolvedToken, + out var credentialSource, + out var isBootstrapToken, + out var pinMismatch)) + { + CurrentApp.ShowTransientConnectionError(pinMismatch + ? DashboardCredentialGate.PinMismatchMessage + : "Gateway URL or credential is not configured"); + return; + } + + var decision = DashboardCredentialGate.Decide( + CurrentApp.DashboardPinStillMatches(pinned), + samePinnedRecord: true, + appendSharedToken, + credentialSource, + isBootstrapToken, + resolvedToken, + pinned.SharedGatewayToken); + if (decision.PinMismatch) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return; + } + + dashboardToken = decision.Token; + appendDashboardToken = decision.AppendToken && + !isBootstrapToken && + credentialSource == CredentialResolver.SourceSharedGatewayToken; + } + else if (appendSharedToken) { var provenanceService = CurrentApp.ManagedLocalPortProvenance; if (provenanceService is null) return; _ = await provenanceService.InspectAsync(pinned); if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); return; + } var candidate = new GatewayCredential( pinned.SharedGatewayToken!, IsBootstrapToken: false, @@ -2612,8 +2654,8 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) var url = GatewayDashboardUrlBuilder.Build( endpoint, path: null, - pinned.SharedGatewayToken, - appendSharedGatewayToken: appendSharedToken); + dashboardToken, + appendSharedGatewayToken: appendDashboardToken); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } catch (Exception ex) diff --git a/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs new file mode 100644 index 000000000..db42a52c5 --- /dev/null +++ b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs @@ -0,0 +1,75 @@ +namespace OpenClaw.Connection.Tests; + +public sealed class DashboardCredentialGateTests +{ + [Fact] + public void SharedToken_IsAppendedWhenItIsThePinnedCredential() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceSharedGatewayToken, + isBootstrapToken: false, + resolvedToken: "shared-secret", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.PinMismatch); + Assert.True(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, decision.CredentialSource); + Assert.Equal("shared-secret", decision.Token); + } + + [Fact] + public void DeviceToken_WinsOverStoredSharedTokenForTheSamePin() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceDeviceToken, + isBootstrapToken: false, + resolvedToken: "device-token", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.PinMismatch); + Assert.False(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceDeviceToken, decision.CredentialSource); + Assert.Null(decision.Token); + } + + [Fact] + public void BootstrapToken_IsNotAppended() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: true, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceBootstrapToken, + isBootstrapToken: true, + resolvedToken: "bootstrap-token", + pinnedSharedToken: "shared-secret"); + + Assert.False(decision.AppendToken); + Assert.Equal(CredentialResolver.SourceBootstrapToken, decision.CredentialSource); + Assert.Null(decision.Token); + } + + [Fact] + public void PinMismatch_ReturnsNoTokenUrlMaterial() + { + var decision = DashboardCredentialGate.Decide( + pinMatches: false, + samePinnedRecord: true, + tunnelAllowsSharedToken: true, + CredentialResolver.SourceSharedGatewayToken, + isBootstrapToken: false, + resolvedToken: "shared-secret", + pinnedSharedToken: "other-secret"); + + Assert.True(decision.PinMismatch); + Assert.False(decision.AppendToken); + Assert.Null(decision.Token); + Assert.Equal("none", decision.CredentialSource); + } +} From 99ea745923d5d6edf113a8d2bf979f5db85615b1 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 01:55:31 +0200 Subject: [PATCH 04/20] fix(connection): use HTTP credentials for pinned SSH dashboards Resolve the pinned dashboard credential with the existing shared-first HTTP resolver while preserving authorization, pin checks, and the final token gate. Add stored-identity regression coverage and tray, saved-row, and MCP wiring guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ec217af-d09a-475f-8c7c-1153efc34eae --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 19 +-- .../DashboardCredentialGateTests.cs | 109 +++++++++++++++++- .../AppRefactorContractTests.cs | 41 +++++++ 3 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 31f6f7337..b5eb8cf1b 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -189,8 +189,15 @@ internal bool TryResolvePinnedDashboardCredential( return false; var identityDirectory = _gatewayRegistry.GetIdentityDirectory(pinned.Id); - var credential = new CredentialResolver(DeviceIdentityFileReader.Instance) - .ResolveOperator(pinned, identityDirectory); + var resolved = InteractiveGatewayCredentialResolver.TryResolveRecord( + pinned, + identityDirectory, + DeviceIdentityFileReader.Instance, + (record, candidate) => + candidate.Source != CredentialResolver.SourceSharedGatewayToken || + _managedLocalPortProvenance?.IsStrongCredentialAllowed(record, candidate) != false, + out var credential, + out _); if (!DashboardPinStillMatches(pinned)) { @@ -202,14 +209,8 @@ internal bool TryResolvePinnedDashboardCredential( return false; } - if (credential is null) - return false; - - if (credential.Source == CredentialResolver.SourceSharedGatewayToken && - _managedLocalPortProvenance?.IsStrongCredentialAllowed(pinned, credential) == false) - { + if (!resolved || credential is null) return false; - } token = credential.Token; credentialSource = credential.Source; diff --git a/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs index db42a52c5..ae3c8cca1 100644 --- a/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs +++ b/tests/OpenClaw.Connection.Tests/DashboardCredentialGateTests.cs @@ -1,3 +1,6 @@ +using OpenClaw.Shared; +using OpenClaw.TestSupport; + namespace OpenClaw.Connection.Tests; public sealed class DashboardCredentialGateTests @@ -21,7 +24,7 @@ public void SharedToken_IsAppendedWhenItIsThePinnedCredential() } [Fact] - public void DeviceToken_WinsOverStoredSharedTokenForTheSamePin() + public void DeviceToken_IsNeverAppendedEvenWhenASharedTokenIsStored() { var decision = DashboardCredentialGate.Decide( pinMatches: true, @@ -55,21 +58,115 @@ public void BootstrapToken_IsNotAppended() Assert.Null(decision.Token); } - [Fact] - public void PinMismatch_ReturnsNoTokenUrlMaterial() + [Theory] + [InlineData(false, true, "shared-secret")] + [InlineData(true, false, "shared-secret")] + [InlineData(true, true, "changed-secret")] + public void PinMismatch_ReturnsNoTokenUrlMaterial( + bool pinMatches, bool samePinnedRecord, string pinnedSharedToken) { var decision = DashboardCredentialGate.Decide( - pinMatches: false, - samePinnedRecord: true, + pinMatches, + samePinnedRecord, tunnelAllowsSharedToken: true, CredentialResolver.SourceSharedGatewayToken, isBootstrapToken: false, resolvedToken: "shared-secret", - pinnedSharedToken: "other-secret"); + pinnedSharedToken); Assert.True(decision.PinMismatch); Assert.False(decision.AppendToken); Assert.Null(decision.Token); Assert.Equal("none", decision.CredentialSource); } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(false, false)] + public void PinnedSshHttpResolver_OnlyAppendsSharedToken( + bool hasSharedToken, bool hasDeviceToken) + { + using var temp = new TempDirectory(); + var record = new GatewayRecord + { + Id = "paired-ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = hasSharedToken ? "shared-secret" : null, + BootstrapToken = "bootstrap-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + }; + var registry = new GatewayRegistry(temp.Path); + registry.AddOrUpdate(record); + var identityDirectory = registry.GetIdentityDirectory(record.Id); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + if (hasDeviceToken) + identity.StoreDeviceTokenForRole("operator", "paired-device-secret"); + + var operatorCredential = new CredentialResolver(DeviceIdentityFileReader.Instance) + .ResolveOperator(record, identityDirectory); + Assert.NotNull(operatorCredential); + Assert.Equal(hasDeviceToken ? CredentialResolver.SourceDeviceToken + : CredentialResolver.SourceBootstrapToken, operatorCredential.Source); + + var authorized = false; + Assert.True(InteractiveGatewayCredentialResolver.TryResolveRecord( + record, identityDirectory, DeviceIdentityFileReader.Instance, + (pinned, candidate) => + { + Assert.Same(record, pinned); + Assert.Equal(hasSharedToken ? "shared-secret" : operatorCredential.Token, candidate.Token); + authorized = true; + return true; + }, + out var credential, out var rejected)); + Assert.True(authorized); + Assert.False(rejected); + Assert.NotNull(credential); + + var tunnel = new SshTunnelSnapshot(true, "user", "gateway.example", 18789, 45678, + 0, 0, DateTime.UtcNow, null, TunnelStatus.Up); + Assert.True(GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, tunnel, out var endpoint, out var appendSharedToken, listenerOwned: true)); + Assert.Equal("ws://localhost:45678", endpoint); + var decision = DashboardCredentialGate.Decide( + pinMatches: true, samePinnedRecord: true, appendSharedToken, + credential.Source, credential.IsBootstrapToken, credential.Token, record.SharedGatewayToken); + + Assert.False(decision.PinMismatch); + Assert.Equal(hasSharedToken, decision.AppendToken); + Assert.Equal(hasSharedToken ? "shared-secret" : null, decision.Token); + Assert.Equal(hasSharedToken ? CredentialResolver.SourceSharedGatewayToken + : operatorCredential.Source, decision.CredentialSource); + } + + [Fact] + public void PinnedSshHttpResolver_AuthorizationRejectionDoesNotFallBackToDeviceToken() + { + using var temp = new TempDirectory(); + var identity = new DeviceIdentity(temp.Path); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "paired-device-secret"); + var record = new GatewayRecord + { + Id = "paired-ssh", + Url = "ws://gateway.example:18789", + SharedGatewayToken = "shared-secret", + SshTunnel = new SshTunnelConfig("user", "gateway.example", 18789, 45678), + }; + + Assert.False(InteractiveGatewayCredentialResolver.TryResolveRecord( + record, temp.Path, DeviceIdentityFileReader.Instance, + (pinned, candidate) => + { + Assert.Same(record, pinned); + Assert.Equal(CredentialResolver.SourceSharedGatewayToken, candidate.Source); + return false; + }, + out var credential, out var rejected)); + + Assert.True(rejected); + Assert.Null(credential); + } } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 92fe9c805..092ed770c 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -479,6 +479,47 @@ public void Dashboard_SurfacesSshTunnelConfigurationFailure() Assert.Contains("Check SSH tunnel settings and logs.", method); } + [Fact] + public void Dashboard_PinnedCredentialUsesHttpResolverBetweenPinChecks() + { + var method = ExtractMethod(ReadAppSources(), "TryResolvePinnedDashboardCredential"); + + // Retire when the WinUI adapter can be invoked directly by a unit test. + AssertInOrder(method, + "DashboardPinStillMatches(pinned)", + "_gatewayRegistry.GetIdentityDirectory(pinned.Id)", + "InteractiveGatewayCredentialResolver.TryResolveRecord(", + "DeviceIdentityFileReader.Instance", + "IsStrongCredentialAllowed(record, candidate)", + "DashboardPinStillMatches(pinned)", + "token = credential.Token"); + Assert.DoesNotContain("ResolveOperator(", method); + Assert.DoesNotContain("TryResolveChatCredentials(", method); + } + + [Theory] + [InlineData("App.xaml.cs", "OpenDashboardAsync")] + [InlineData("Pages\\ConnectionPage.xaml.cs", "OnSavedRowOpenDashboardAsync")] + [InlineData("App.CapabilityHandlers.cs", "app.DashboardUrlHandler =")] + public void Dashboard_AllSshCallersKeepPinnedCredentialAndEndpointGates(string file, string methodName) + { + var source = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", + file.Replace('\\', Path.DirectorySeparatorChar))); + var method = file == "App.CapabilityHandlers.cs" + ? source[source.IndexOf(methodName, StringComparison.Ordinal).. + source.IndexOf("app.ChatSnapshotHandler =", StringComparison.Ordinal)] + : ExtractMethod(source, methodName); + + Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", method); + Assert.Contains("TryResolvePinnedDashboardCredential(", method); + Assert.Contains("GatewayClientEndpointResolver.TryResolveDashboardEndpoint(", method); + AssertInOrder(method, "TryResolvePinnedDashboardCredential(", + "DashboardCredentialGate.Decide(", "DashboardPinStillMatches(pinned)", + "pinned.SharedGatewayToken", "GatewayDashboardUrlBuilder.Build("); + Assert.Contains("CredentialResolver.SourceSharedGatewayToken", method); + } + [Fact] public void SshTunnelExit_RecoversActiveRegistryGatewayThroughConnectionManager() { From 73c772321d98e453f9045022ad3961bfd9ecffa4 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 11:15:29 -0700 Subject: [PATCH 05/20] fix(connection): authorize pinned dashboard HTTP credentials through the shared authorizer The merge left a direct managed-port check on the pinned dashboard path. HTTP surfaces now ask InteractiveGatewayEndpointAuthorizer, which still applies that check for a non-native gateway and adds native ownership inspection. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 3 +-- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fa2af1907..3f107a2f2 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -196,8 +196,7 @@ internal bool TryResolvePinnedDashboardCredential( identityDirectory, DeviceIdentityFileReader.Instance, (record, candidate) => - candidate.Source != CredentialResolver.SourceSharedGatewayToken || - _managedLocalPortProvenance?.IsStrongCredentialAllowed(record, candidate) != false, + InteractiveEndpointAuthorizer?.IsCredentialAllowed(record, candidate) == true, out var credential, out _); diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 9d20b45cb..937148a86 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -492,7 +492,7 @@ public void Dashboard_PinnedCredentialUsesHttpResolverBetweenPinChecks() "_gatewayRegistry.GetIdentityDirectory(pinned.Id)", "InteractiveGatewayCredentialResolver.TryResolveRecord(", "DeviceIdentityFileReader.Instance", - "IsStrongCredentialAllowed(record, candidate)", + "IsCredentialAllowed(record, candidate)", "DashboardPinStillMatches(pinned)", "token = credential.Token"); Assert.DoesNotContain("ResolveOperator(", method); From edb569f8cd5a0cc3194212e010a5ccf52134a877 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 11:34:26 -0700 Subject: [PATCH 06/20] chore: retrigger CI after the tray UI host hang The UI job aborted after five minutes of host inactivity during markdown and workspace layout tests. Those tests are not part of this dashboard change. Signed-off-by: Sebastien Tardif From 1d9bbac0e6d18288ecafe6ce4a50552185904778 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 11:52:32 -0700 Subject: [PATCH 07/20] fix(connection): keep the saved dashboard route on the SSH tunnel An SSH dashboard address such as wss://host/mount/?view=compact#section now opens on localhost at the forwarded port and keeps the path, query, and fragment. Browser launch goes through GatewayDashboardLauncher, so a failure shows the existing retry dialog and the token URL is not logged. The mounted-address resolver case passed. build.ps1 exit 0. Shared 4263 passed, 33 skipped. Tray 4000 passed. Signed-off-by: Sebastien Tardif --- .../GatewayClientEndpointResolver.cs | 36 ++++++++++++++++++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 29 ++++++++------- .../Services/GatewayDashboardLauncher.cs | 20 +++++++++++ .../GatewayClientEndpointResolverTests.cs | 22 ++++++++++++ .../AppRefactorContractTests.cs | 6 ++++ 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs index 4c734e096..dcdbde62f 100644 --- a/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs +++ b/src/OpenClaw.Connection/GatewayClientEndpointResolver.cs @@ -47,11 +47,45 @@ public static bool TryResolveDashboardEndpoint( return false; } - endpoint = Resolve(record); + if (!TrySubstituteLocalDashboardAddress(record.Url, config.LocalPort, out endpoint)) + { + endpoint = ""; + appendSharedToken = false; + return false; + } + appendSharedToken = !string.IsNullOrWhiteSpace(record.SharedGatewayToken); return true; } + // The socket client uses Resolve. The browser keeps the saved route + // and only the host and port move onto the local forward. + private static bool TrySubstituteLocalDashboardAddress(string savedUrl, int localPort, out string endpoint) + { + endpoint = ""; + if (!Uri.TryCreate(savedUrl, UriKind.Absolute, out var saved)) + return false; + + var builder = new UriBuilder(saved) + { + Host = "localhost", + Port = localPort, + }; + builder.UserName = ""; + builder.Password = ""; + var text = builder.Uri.AbsoluteUri; + if (saved.AbsolutePath == "/" && + string.IsNullOrEmpty(saved.Query) && + string.IsNullOrEmpty(saved.Fragment) && + !savedUrl.TrimEnd().EndsWith('/')) + { + text = text.TrimEnd('/'); + } + + endpoint = text; + return true; + } + private static bool IsDashboardTunnelUp(SshTunnelConfig config, SshTunnelSnapshot? tunnel) => tunnel is { IsRunning: true, Status: OpenClaw.Shared.TunnelStatus.Up } && tunnel.CurrentLocalPort == config.LocalPort && diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3f107a2f2..5b3c7defb 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4167,13 +4167,13 @@ private async Task OpenDashboardAsync(string? path = null) return; } - OpenDashboardUri(GatewayDashboardUrlBuilder.Build( + await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( tunnelEndpoint, path, decision.Token, decision.AppendToken && !tunnelIsBootstrapToken && - tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken)); + tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken), path); return; } @@ -4193,23 +4193,26 @@ private async Task OpenDashboardAsync(string? path = null) return; } - OpenDashboardUri(GatewayDashboardUrlBuilder.Build( + await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( gatewayUrl, path, token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken)); + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken), path); } - private static void OpenDashboardUri(string url) + private Task LaunchPreparedDashboardAsync(string url, string? path) { - try - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - catch (Exception ex) - { - Logger.Error($"Failed to open dashboard: {ex.Message}"); - } + var launcher = new GatewayDashboardLauncher( + () => true, + () => null, + async target => await global::Windows.System.Launcher.LaunchUriAsync(new Uri(target)), + () => AsyncEventHandlerGuard.Run( + () => _windowManager?.ShowDashboardLaunchFailureAsync(() => OpenDashboard(path)) + ?? Task.CompletedTask, + new AppLogger(), + "Dashboard launch error"), + () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); + return launcher.OpenPreparedAsync(url); } // ── IAppCommands implementation ───────────────────────────────────── diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index e7cfae2d3..158f5a3e1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -37,6 +37,26 @@ public async Task OpenAsync(string? path = null) return true; } + public Task OpenPreparedAsync(string url) => OpenUrlAsync(url); + + private async Task OpenUrlAsync(string url) + { + try + { + if (!await launchBrowser(url)) + throw new InvalidOperationException("Windows did not open the Dashboard."); + } + catch (Exception ex) when (IsExpectedLaunchFailure(ex)) + { + // Browser failures can include the full credential-bearing URL. Do not log them. + reportFailure(); + return false; + } + + reportOpened?.Invoke(); + return true; + } + private static bool IsExpectedLaunchFailure(Exception error) => error is InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException or Win32Exception or COMException; diff --git a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs index 7cc9d4af7..964b52e60 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayClientEndpointResolverTests.cs @@ -91,6 +91,28 @@ public void TryResolveDashboardEndpoint_SshRecordUsesTunnelOnlyWhenThatForwardIs Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void TryResolveDashboardEndpoint_SshRecordKeepsSavedRouteOnTheLocalForward() + { + var record = SshRecord() with + { + Url = "wss://gateway.example/mount/?view=compact#section", + }; + var tunnel = UpTunnel(localPort: 45678); + + var opened = GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + record, + tunnel, + out var endpoint, + out var appendSharedToken, + listenerOwned: true); + + Assert.True(opened); + Assert.Equal("wss://localhost:45678/mount/?view=compact#section", endpoint); + Assert.True(appendSharedToken); + Assert.DoesNotContain("gateway.example", endpoint, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void TryResolveDashboardEndpoint_SshRecordRejectsDifferentSshServerPort() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 937148a86..f7579d858 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -520,6 +520,12 @@ public void Dashboard_AllSshCallersKeepPinnedCredentialAndEndpointGates(string f "DashboardCredentialGate.Decide(", "DashboardPinStillMatches(pinned)", "pinned.SharedGatewayToken", "GatewayDashboardUrlBuilder.Build("); Assert.Contains("CredentialResolver.SourceSharedGatewayToken", method); + if (file == "App.xaml.cs") + { + Assert.Contains("LaunchPreparedDashboardAsync(", method); + Assert.DoesNotContain("OpenDashboardUri", method); + Assert.DoesNotContain("ex.Message", method); + } } [Fact] From d94a9f770c0f1dbf2d5d757aeeae5b3b0e6e4de0 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 10:25:44 -0700 Subject: [PATCH 08/20] fix(connection): recheck SSH dashboard ownership before opening it The listener check that builds a dashboard link can be stale by the time the browser starts. The tray, the saved-row action, and local MCP check the owned listener again immediately before launch or before returning the link. A failed recheck does not open the browser and does not return the URL. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4001 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 13 +++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 34 ++++++++++++++++++- .../Pages/ConnectionPage.xaml.cs | 28 +++++++++++++++ .../Services/GatewayDashboardLauncher.cs | 10 +++++- .../AppRefactorContractTests.cs | 24 +++++++++++++ .../SetupDashboardHandoffTests.cs | 20 +++++++++++ 6 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index fe2a5188b..64ded1665 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -283,6 +283,19 @@ private void WireAppCapabilityHandlers() decision.AppendToken && !pinnedIsBootstrapToken && pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken); + var stillOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!DashboardPinStillMatches(pinned)) + return new { error = DashboardCredentialGate.PinMismatchMessage }; + if (!stillOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + stillOwned)) + { + return new { error = "Dashboard blocked because the SSH tunnel is not up." }; + } return new { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 5b3c7defb..9c559b772 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4212,7 +4212,39 @@ private Task LaunchPreparedDashboardAsync(string url, string? path) new AppLogger(), "Dashboard launch error"), () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); - return launcher.OpenPreparedAsync(url); + return launcher.OpenPreparedAsync(url, ConfirmActiveDashboardListenerAsync); + } + + private async Task ConfirmActiveDashboardListenerAsync() + { + var pinned = _gatewayRegistry?.GetActive(); + if (pinned?.SshTunnel is not { } ssh) + return true; + + if (!DashboardPinStillMatches(pinned)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("Dashboard") + .AddText(DashboardCredentialGate.PinMismatchMessage)); + return false; + } + + var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + if (!listenerOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + listenerOwned)) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText("Dashboard blocked because the SSH tunnel is not up.")); + return false; + } + + return true; } // ── IAppCommands implementation ───────────────────────────────────── diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 9e763aa98..92289a581 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2656,6 +2656,9 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) path: null, dashboardToken, appendSharedGatewayToken: appendDashboardToken); + if (pinned.SshTunnel is { } launchSsh && + !await SavedDashboardListenerStillOwnedAsync(pinned, launchSsh)) + return; await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } catch (Exception ex) @@ -2664,6 +2667,31 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } + private async Task SavedDashboardListenerStillOwnedAsync(GatewayRecord pinned, SshTunnelConfig ssh) + { + if (!CurrentApp.DashboardPinStillMatches(pinned)) + { + CurrentApp.ShowTransientConnectionError(DashboardCredentialGate.PinMismatchMessage); + return false; + } + + var listenerOwned = await CurrentApp.IsDashboardListenerOwnedAsync(ssh); + if (!listenerOwned || + !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + CurrentApp.CaptureSshTunnelSnapshot(), + out _, + out _, + listenerOwned)) + { + CurrentApp.ShowTransientConnectionError( + "Dashboard blocked because the SSH tunnel is not up."); + return false; + } + + return true; + } + private void OnSavedRowEdit(object sender, RoutedEventArgs e) { if (sender is not MenuFlyoutItem item || item.Tag is not string gwId) return; diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index 158f5a3e1..0880a8104 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -37,7 +37,15 @@ public async Task OpenAsync(string? path = null) return true; } - public Task OpenPreparedAsync(string url) => OpenUrlAsync(url); + public async Task OpenPreparedAsync(string url, Func>? confirmReady = null) + { + // The ownership check that built this URL can be stale by the time the + // browser starts. Refuse the launch when that check no longer holds. + if (confirmReady is not null && !await confirmReady()) + return false; + + return await OpenUrlAsync(url); + } private async Task OpenUrlAsync(string url) { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f7579d858..50e3a72be 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -525,6 +525,30 @@ public void Dashboard_AllSshCallersKeepPinnedCredentialAndEndpointGates(string f Assert.Contains("LaunchPreparedDashboardAsync(", method); Assert.DoesNotContain("OpenDashboardUri", method); Assert.DoesNotContain("ex.Message", method); + var prepared = ExtractMethod(source, "LaunchPreparedDashboardAsync"); + Assert.Contains("ConfirmActiveDashboardListenerAsync", prepared); + var confirm = ExtractMethod(source, "ConfirmActiveDashboardListenerAsync"); + Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", confirm); + Assert.Contains("TryResolveDashboardEndpoint(", confirm); + } + + if (file == "Pages\\ConnectionPage.xaml.cs") + { + Assert.Contains("SavedDashboardListenerStillOwnedAsync(", method); + var recheck = ExtractMethod(source, "SavedDashboardListenerStillOwnedAsync"); + Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", recheck); + var buildAt = method.IndexOf("GatewayDashboardUrlBuilder.Build(", StringComparison.Ordinal); + var recheckAt = method.IndexOf("SavedDashboardListenerStillOwnedAsync(", StringComparison.Ordinal); + var launchAt = method.IndexOf("LaunchUriAsync", StringComparison.Ordinal); + Assert.True(buildAt >= 0 && recheckAt > buildAt && launchAt > recheckAt); + } + + if (file == "App.CapabilityHandlers.cs") + { + var first = method.IndexOf("IsDashboardListenerOwnedAsync(ssh)", StringComparison.Ordinal); + var buildAt = method.IndexOf("GatewayDashboardUrlBuilder.Build(", StringComparison.Ordinal); + var second = method.IndexOf("IsDashboardListenerOwnedAsync(ssh)", buildAt, StringComparison.Ordinal); + Assert.True(first >= 0 && buildAt > first && second > buildAt); } } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 9d725a8f0..8cea04c65 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -194,6 +194,26 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA Assert.Equal(1, opened); } + [Fact] + public async Task OpenPrepared_RefusesBrowserWhenOwnershipRecheckFails() + { + var launches = 0; + var launcher = new GatewayDashboardLauncher( + () => true, + () => null, + _ => + { + launches++; + return Task.FromResult(true); + }, + () => throw new InvalidOperationException("Unexpected failure")); + + Assert.False(await launcher.OpenPreparedAsync( + "https://localhost/control#token=secret", + () => Task.FromResult(false))); + Assert.Equal(0, launches); + } + private sealed class StartupClock : TimeProvider { public DateTimeOffset Now { get; set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); From e52d75a30b0090b86fb5ae3627581571c55e5f0b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 11:14:43 -0700 Subject: [PATCH 09/20] fix(connection): keep the dashboard token off the issued SSH link The ownership recheck still returned a reusable token fragment aimed at the tunnel port. SSH dashboard opens and local MCP now issue a loopback page with no credential. That page writes the token only after a fresh ownership check, and a second request does not. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4002 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 17 ++- .../Pages/ConnectionPage.xaml.cs | 7 +- .../Services/DashboardCredentialHandoff.cs | 137 ++++++++++++++++++ .../Services/GatewayDashboardLauncher.cs | 7 +- .../OpenClaw.Tray.Tests.csproj | 1 + .../SetupDashboardHandoffTests.cs | 21 +++ 6 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 64ded1665..a3ed8d8f6 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -297,14 +297,27 @@ private void WireAppCapabilityHandlers() return new { error = "Dashboard blocked because the SSH tunnel is not up." }; } + var publicUrl = DashboardCredentialHandoff.Start(async () => + { + if (!DashboardPinStillMatches(pinned)) + return false; + var ownedNow = await IsDashboardListenerOwnedAsync(ssh); + return ownedNow && + GatewayClientEndpointResolver.TryResolveDashboardEndpoint( + pinned, + _sshTunnelService?.CreateSnapshot(), + out _, + out _, + ownedNow); + }, url); return new { - url, + url = publicUrl, credentialSource = decision.CredentialSource, usesSharedGatewayToken = decision.AppendToken && !pinnedIsBootstrapToken && pinnedCredentialSource == CredentialResolver.SourceSharedGatewayToken, - hasTokenQuery = url.Contains("?token=", StringComparison.Ordinal) || url.Contains("&token=", StringComparison.Ordinal) + hasTokenQuery = false }; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 92289a581..f0c049c95 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2659,7 +2659,12 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) if (pinned.SshTunnel is { } launchSsh && !await SavedDashboardListenerStillOwnedAsync(pinned, launchSsh)) return; - await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); + var launchUrl = pinned.SshTunnel is null + ? url + : DashboardCredentialHandoff.Start( + () => SavedDashboardListenerStillOwnedAsync(pinned, pinned.SshTunnel), + url); + await global::Windows.System.Launcher.LaunchUriAsync(new Uri(launchUrl)); } catch (Exception ex) { diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs new file mode 100644 index 000000000..b4e06cdd0 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace OpenClawTray.Services; + +/// +/// Serves one loopback page that does not contain the dashboard credential. +/// The credential is written only after a fresh ownership check, and only to that response. +/// +internal static class DashboardCredentialHandoff +{ + private static readonly ConcurrentDictionary Live = new(); + + public static string Start(Func> owned, string destination) + { + ArgumentNullException.ThrowIfNull(owned); + ArgumentException.ThrowIfNullOrWhiteSpace(destination); + var handoff = new Handoff(owned, destination); + Live[handoff.Nonce] = handoff; + handoff.Start(); + return handoff.BrowserUrl; + } + + private sealed class Handoff + { + private readonly Func> _owned; + private readonly string _destination; + private readonly HttpListener _listener; + private readonly CancellationTokenSource _lifetime = new(TimeSpan.FromMinutes(2)); + private int _delivered; + + public Handoff(Func> owned, string destination) + { + _owned = owned; + _destination = destination; + Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); + var port = FreePort(); + BrowserUrl = $"http://127.0.0.1:{port}/d/{Nonce}"; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + } + + public string Nonce { get; } + public string BrowserUrl { get; } + + public void Start() + { + _listener.Start(); + _ = ServeAsync(); + } + + private async Task ServeAsync() + { + try + { + while (_listener.IsListening && !_lifetime.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync().WaitAsync(_lifetime.Token); + } + catch (OperationCanceledException) + { + break; + } + + _ = AnswerAsync(context); + } + } + catch (HttpListenerException) + { + } + catch (ObjectDisposedException) + { + } + finally + { + Stop(); + } + } + + private async Task AnswerAsync(HttpListenerContext context) + { + var response = context.Response; + try + { + var path = context.Request.Url?.AbsolutePath ?? ""; + var allowed = path.Equals($"/d/{Nonce}", StringComparison.Ordinal) && + Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && + await _owned(); + if (!allowed) + { + if (path.Equals($"/d/{Nonce}", StringComparison.Ordinal)) + Interlocked.Exchange(ref _delivered, 1); + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + var html = ""; + var bytes = Encoding.UTF8.GetBytes(html); + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/html; charset=utf-8"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + response.Close(); + } + catch (Exception) + { + try { response.Abort(); } catch (Exception) { } + } + } + + private void Stop() + { + Live.TryRemove(Nonce, out _); + _lifetime.Cancel(); + if (_listener.IsListening) + _listener.Stop(); + _listener.Close(); + } + + private static int FreePort() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index 0880a8104..77bbe1fe1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -44,7 +44,12 @@ public async Task OpenPreparedAsync(string url, Func>? confirmR if (confirmReady is not null && !await confirmReady()) return false; - return await OpenUrlAsync(url); + // The browser receives a loopback page with no credential. The credential + // is released only when that page is requested and ownership still holds. + var launchUrl = confirmReady is null + ? url + : DashboardCredentialHandoff.Start(confirmReady, url); + return await OpenUrlAsync(launchUrl); } private async Task OpenUrlAsync(string url) diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index fb1e2138f..c8ffd7d5c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -102,6 +102,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 8cea04c65..7c49164a6 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Net.Http; using OpenClaw.Connection; using OpenClaw.SetupEngine; using OpenClawTray.Services; @@ -194,6 +196,25 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA Assert.Equal(1, opened); } + [Fact] + public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() + { + const string destination = "https://localhost:45678/mount/?view=compact#section&token=secret"; + var open = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination); + Assert.DoesNotContain("token=", open, StringComparison.OrdinalIgnoreCase); + using var http = new HttpClient(); + var first = await http.GetStringAsync(open); + Assert.Contains("token=secret", first, StringComparison.Ordinal); + var denied = await http.GetAsync(open); + Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); + Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + var closed = DashboardCredentialHandoff.Start(() => Task.FromResult(false), destination); + var blocked = await http.GetAsync(closed); + Assert.Equal(HttpStatusCode.NotFound, blocked.StatusCode); + Assert.DoesNotContain("secret", await blocked.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } + [Fact] public async Task OpenPrepared_RefusesBrowserWhenOwnershipRecheckFails() { From 60b1cc0ee03d04c2389ce820ce10922b4025e2e0 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 12:04:55 -0700 Subject: [PATCH 10/20] fix(connection): bind the dashboard handoff to the gateway that issued it The one-use page checked whichever gateway was active and treated a gateway with no SSH tunnel as ready. Switching away from the SSH gateway before the browser requested the page could release the first gateway's token. The check now requires the active record to still be the issued gateway and its tunnel. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 3 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 28 +++++++++++-------- .../Services/DashboardCredentialHandoff.cs | 9 ++++++ .../AppRefactorContractTests.cs | 7 +++-- .../SetupDashboardHandoffTests.cs | 18 ++++++++++++ 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index a3ed8d8f6..562650f82 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -299,7 +299,8 @@ private void WireAppCapabilityHandlers() var publicUrl = DashboardCredentialHandoff.Start(async () => { - if (!DashboardPinStillMatches(pinned)) + if (!DashboardIssuedBinding.Matches(pinned, ssh, _gatewayRegistry?.GetActive()) || + !DashboardPinStillMatches(pinned)) return false; var ownedNow = await IsDashboardListenerOwnedAsync(ssh); return ownedNow && diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 9c559b772..e0838ea0e 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4173,7 +4173,7 @@ await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( decision.Token, decision.AppendToken && !tunnelIsBootstrapToken && - tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken), path); + tunnelCredentialSource == CredentialResolver.SourceSharedGatewayToken), path, pinned, ssh); return; } @@ -4200,7 +4200,11 @@ await LaunchPreparedDashboardAsync(GatewayDashboardUrlBuilder.Build( !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken), path); } - private Task LaunchPreparedDashboardAsync(string url, string? path) + private Task LaunchPreparedDashboardAsync( + string url, + string? path, + GatewayRecord? issued = null, + SshTunnelConfig? issuedTunnel = null) { var launcher = new GatewayDashboardLauncher( () => true, @@ -4212,16 +4216,18 @@ private Task LaunchPreparedDashboardAsync(string url, string? path) new AppLogger(), "Dashboard launch error"), () => _appNotificationService?.Dismiss(GatewayDashboardLauncher.FailureNotificationId)); - return launcher.OpenPreparedAsync(url, ConfirmActiveDashboardListenerAsync); + Func>? confirm = issued is not null && issuedTunnel is not null + ? () => ConfirmIssuedDashboardListenerAsync(issued, issuedTunnel) + : null; + return launcher.OpenPreparedAsync(url, confirm); } - private async Task ConfirmActiveDashboardListenerAsync() + private async Task ConfirmIssuedDashboardListenerAsync( + GatewayRecord issued, + SshTunnelConfig issuedTunnel) { - var pinned = _gatewayRegistry?.GetActive(); - if (pinned?.SshTunnel is not { } ssh) - return true; - - if (!DashboardPinStillMatches(pinned)) + if (!DashboardIssuedBinding.Matches(issued, issuedTunnel, _gatewayRegistry?.GetActive()) || + !DashboardPinStillMatches(issued)) { _toastService?.ShowToast(new ToastContentBuilder() .AddText("Dashboard") @@ -4229,10 +4235,10 @@ private async Task ConfirmActiveDashboardListenerAsync() return false; } - var listenerOwned = await IsDashboardListenerOwnedAsync(ssh); + var listenerOwned = await IsDashboardListenerOwnedAsync(issuedTunnel); if (!listenerOwned || !GatewayClientEndpointResolver.TryResolveDashboardEndpoint( - pinned, + issued, _sshTunnelService?.CreateSnapshot(), out _, out _, diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index b4e06cdd0..c79d4abfe 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Text; using System.Text.Json; +using OpenClaw.Connection; namespace OpenClawTray.Services; @@ -10,6 +11,14 @@ namespace OpenClawTray.Services; /// Serves one loopback page that does not contain the dashboard credential. /// The credential is written only after a fresh ownership check, and only to that response. /// +internal static class DashboardIssuedBinding +{ + public static bool Matches(GatewayRecord issued, SshTunnelConfig issuedTunnel, GatewayRecord? active) => + active is not null + && string.Equals(active.Id, issued.Id, StringComparison.Ordinal) + && active.SshTunnel == issuedTunnel; +} + internal static class DashboardCredentialHandoff { private static readonly ConcurrentDictionary Live = new(); diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 50e3a72be..dcc7d8e21 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -526,9 +526,10 @@ public void Dashboard_AllSshCallersKeepPinnedCredentialAndEndpointGates(string f Assert.DoesNotContain("OpenDashboardUri", method); Assert.DoesNotContain("ex.Message", method); var prepared = ExtractMethod(source, "LaunchPreparedDashboardAsync"); - Assert.Contains("ConfirmActiveDashboardListenerAsync", prepared); - var confirm = ExtractMethod(source, "ConfirmActiveDashboardListenerAsync"); - Assert.Contains("IsDashboardListenerOwnedAsync(ssh)", confirm); + Assert.Contains("ConfirmIssuedDashboardListenerAsync", prepared); + var confirm = ExtractMethod(source, "ConfirmIssuedDashboardListenerAsync"); + Assert.Contains("DashboardIssuedBinding.Matches", confirm); + Assert.Contains("IsDashboardListenerOwnedAsync(issuedTunnel)", confirm); Assert.Contains("TryResolveDashboardEndpoint(", confirm); } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 7c49164a6..4218cf8fd 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -196,6 +196,24 @@ public async Task ExplicitDashboardRetry_KeepsRequestedPathAndClearsFailureOnlyA Assert.Equal(1, opened); } + [Fact] + public void IssuedBinding_RejectsSwitchToAnotherGateway() + { + var tunnel = new SshTunnelConfig("root", "gateway.example", 18789, 45678, false, 22); + var issued = new GatewayRecord + { + Id = "gateway-a", + Url = "wss://gateway.example/mount/", + SharedGatewayToken = "token-a", + SshTunnel = tunnel, + }; + var other = new GatewayRecord { Id = "gateway-b", Url = "ws://127.0.0.1:18789/" }; + + Assert.True(DashboardIssuedBinding.Matches(issued, tunnel, issued)); + Assert.False(DashboardIssuedBinding.Matches(issued, tunnel, other)); + Assert.False(DashboardIssuedBinding.Matches(issued, tunnel, null)); + } + [Fact] public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() { From e136209cc89b7d981bc503ff0f480b0a1cd335ad Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 13:44:47 -0700 Subject: [PATCH 11/20] fix(connection): keep the dashboard token off the tunnel port The one-use page sent the browser to the SSH forward with the token in the address. A process that binds that port after the response can read the fragment. The page now stays on the handoff listener and proxies to the forward only while ownership still holds. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../Services/DashboardCredentialHandoff.cs | 105 ++++++++++++++++-- .../SetupDashboardHandoffTests.cs | 1 + 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index c79d4abfe..2f609535f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Net.WebSockets; using System.Text; using System.Text.Json; using OpenClaw.Connection; @@ -98,20 +99,38 @@ private async Task AnswerAsync(HttpListenerContext context) try { var path = context.Request.Url?.AbsolutePath ?? ""; - var allowed = path.Equals($"/d/{Nonce}", StringComparison.Ordinal) && - Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && - await _owned(); - if (!allowed) + if (!path.Equals($"/d/{Nonce}", StringComparison.Ordinal)) { - if (path.Equals($"/d/{Nonce}", StringComparison.Ordinal)) - Interlocked.Exchange(ref _delivered, 1); + if (!await _owned()) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + if (context.Request.IsWebSocketRequest) + { + await ProxyWebSocketAsync(context); + return; + } + + await ProxyAsync(context); + return; + } + + var first = Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && await _owned(); + if (!first) + { + Interlocked.Exchange(ref _delivered, 1); response.StatusCode = (int)HttpStatusCode.NotFound; response.Close(); return; } + // Stay on this listener. A later navigation to the tunnel port + // would hand the fragment to whoever bound that port. var html = ""; + JsonSerializer.Serialize(SameOriginDestination()) + ")"; var bytes = Encoding.UTF8.GetBytes(html); response.StatusCode = (int)HttpStatusCode.OK; response.ContentType = "text/html; charset=utf-8"; @@ -125,6 +144,78 @@ private async Task AnswerAsync(HttpListenerContext context) } } + private string SameOriginDestination() + { + var destination = new Uri(_destination); + var local = new Uri(BrowserUrl); + var hash = destination.Fragment; + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{local.Port}/"); + hash = string.IsNullOrEmpty(hash) + ? "#gatewayUrl=" + gateway + : hash + "&gatewayUrl=" + gateway; + var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; + return $"http://127.0.0.1:{local.Port}{path}{destination.Query}{hash}"; + } + + private async Task ProxyAsync(HttpListenerContext context) + { + var incoming = context.Request.Url; + var pathAndQuery = (incoming?.PathAndQuery) ?? "/"; + var destination = new Uri(_destination); + var scheme = destination.Scheme == "https" && + (destination.Host is "localhost" or "127.0.0.1") + ? "http" + : destination.Scheme; + var upstream = new Uri($"{scheme}://{destination.Host}:{destination.Port}{pathAndQuery}"); + using var client = new HttpClient(); + using var request = new HttpRequestMessage(new HttpMethod(context.Request.HttpMethod), upstream); + using var reply = await client.SendAsync(request); + context.Response.StatusCode = (int)reply.StatusCode; + var bytes = await reply.Content.ReadAsByteArrayAsync(); + if (reply.Content.Headers.ContentType is { } type) + context.Response.ContentType = type.ToString(); + context.Response.ContentLength64 = bytes.Length; + await context.Response.OutputStream.WriteAsync(bytes); + context.Response.Close(); + } + + private async Task ProxyWebSocketAsync(HttpListenerContext context) + { + var socket = await context.AcceptWebSocketAsync(null); + var destination = new Uri(_destination); + var incoming = context.Request.Url; + var upstreamUri = new Uri($"ws://{destination.Host}:{destination.Port}{incoming?.PathAndQuery}"); + using var upstream = new ClientWebSocket(); + try + { + await upstream.ConnectAsync(upstreamUri, _lifetime.Token); + await Task.WhenAll( + PumpAsync(socket.WebSocket, upstream), + PumpAsync(upstream, socket.WebSocket)); + } + catch (Exception) + { + if (socket.WebSocket.State == WebSocketState.Open) + await socket.WebSocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "closed", CancellationToken.None); + } + } + + private static async Task PumpAsync(WebSocket from, WebSocket to) + { + var buffer = new byte[8192]; + while (from.State == WebSocketState.Open && to.State == WebSocketState.Open) + { + var result = await from.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + await to.CloseAsync(WebSocketCloseStatus.NormalClosure, "closed", CancellationToken.None); + return; + } + + await to.SendAsync(buffer.AsMemory(0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None); + } + } + private void Stop() { Live.TryRemove(Nonce, out _); diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 4218cf8fd..20ae5e193 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -223,6 +223,7 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() using var http = new HttpClient(); var first = await http.GetStringAsync(open); Assert.Contains("token=secret", first, StringComparison.Ordinal); + Assert.DoesNotContain("45678", first, StringComparison.Ordinal); var denied = await http.GetAsync(open); Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); From e8f4d2c10ff8f7c74aae24225b4f8d3d242e95c6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 14:17:54 -0700 Subject: [PATCH 12/20] fix(connection): keep one dashboard origin and recheck the tunnel The handoff listener used a new port and closed after two minutes, so a reload lost the Control UI profile and freed that origin. It also sent bytes to the forward before checking that the SSH process still owned the port, and it downgraded loopback TLS to plain HTTP. Launches now share 127.0.0.1:47831 for the life of the tray. Unused links expire. Connected sockets are checked again before any dashboard bytes move, and the upstream scheme follows the saved destination. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../Services/DashboardCredentialHandoff.cs | 309 +++++++++++------- .../SetupDashboardHandoffTests.cs | 1 + 2 files changed, 183 insertions(+), 127 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index 2f609535f..dece19f91 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net; +using System.Net.Security; using System.Net.Sockets; using System.Net.WebSockets; using System.Text; @@ -22,181 +23,234 @@ active is not null internal static class DashboardCredentialHandoff { + // One origin for every launch so the Control UI can keep its local settings. + public const int Port = 47831; + private static readonly ConcurrentDictionary Live = new(); + private static readonly object ListenerGate = new(); + private static HttpListener? _listener; + private static Handoff? _active; public static string Start(Func> owned, string destination) { ArgumentNullException.ThrowIfNull(owned); ArgumentException.ThrowIfNullOrWhiteSpace(destination); + EnsureListener(); var handoff = new Handoff(owned, destination); Live[handoff.Nonce] = handoff; - handoff.Start(); + _ = handoff.ExpireUnusedAsync(); return handoff.BrowserUrl; } - private sealed class Handoff + private static void EnsureListener() { - private readonly Func> _owned; - private readonly string _destination; - private readonly HttpListener _listener; - private readonly CancellationTokenSource _lifetime = new(TimeSpan.FromMinutes(2)); - private int _delivered; - - public Handoff(Func> owned, string destination) + lock (ListenerGate) { - _owned = owned; - _destination = destination; - Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); - var port = FreePort(); - BrowserUrl = $"http://127.0.0.1:{port}/d/{Nonce}"; - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + if (_listener is { IsListening: true }) + return; + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); + listener.Start(); + _listener = listener; + _ = ServeAsync(listener); } + } - public string Nonce { get; } - public string BrowserUrl { get; } + private static async Task ServeAsync(HttpListener listener) + { + try + { + while (listener.IsListening) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync(); + } + catch (HttpListenerException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } - public void Start() + _ = AnswerAsync(context); + } + } + catch (ObjectDisposedException) { - _listener.Start(); - _ = ServeAsync(); } + } - private async Task ServeAsync() + private static async Task AnswerAsync(HttpListenerContext context) + { + var response = context.Response; + try { - try + var path = context.Request.Url?.AbsolutePath ?? ""; + if (path.StartsWith("/d/", StringComparison.Ordinal)) { - while (_listener.IsListening && !_lifetime.IsCancellationRequested) + var nonce = path["/d/".Length..]; + if (!Live.TryGetValue(nonce, out var handoff)) { - HttpListenerContext context; - try - { - context = await _listener.GetContextAsync().WaitAsync(_lifetime.Token); - } - catch (OperationCanceledException) - { - break; - } - - _ = AnswerAsync(context); + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; } + + await handoff.DeliverAsync(context); + return; } - catch (HttpListenerException) - { - } - catch (ObjectDisposedException) + + var active = _active; + if (active is null || !await active.Owned()) { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; } - finally + + if (context.Request.IsWebSocketRequest) { - Stop(); + await active.ProxyWebSocketAsync(context); + return; } + + await active.ProxyAsync(context); + } + catch (Exception) + { + try { response.Abort(); } catch (Exception) { } } + } - private async Task AnswerAsync(HttpListenerContext context) + private sealed class Handoff + { + private readonly Func> _owned; + private readonly string _destination; + private int _delivered; + + public Handoff(Func> owned, string destination) { - var response = context.Response; - try - { - var path = context.Request.Url?.AbsolutePath ?? ""; - if (!path.Equals($"/d/{Nonce}", StringComparison.Ordinal)) - { - if (!await _owned()) - { - response.StatusCode = (int)HttpStatusCode.NotFound; - response.Close(); - return; - } - - if (context.Request.IsWebSocketRequest) - { - await ProxyWebSocketAsync(context); - return; - } - - await ProxyAsync(context); - return; - } + _owned = owned; + _destination = destination; + Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); + BrowserUrl = $"http://127.0.0.1:{Port}/d/{Nonce}"; + } - var first = Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && await _owned(); - if (!first) - { - Interlocked.Exchange(ref _delivered, 1); - response.StatusCode = (int)HttpStatusCode.NotFound; - response.Close(); - return; - } + public string Nonce { get; } + public string BrowserUrl { get; } + public Task Owned() => _owned(); - // Stay on this listener. A later navigation to the tunnel port - // would hand the fragment to whoever bound that port. - var html = ""; - var bytes = Encoding.UTF8.GetBytes(html); - response.StatusCode = (int)HttpStatusCode.OK; - response.ContentType = "text/html; charset=utf-8"; - response.ContentLength64 = bytes.Length; - await response.OutputStream.WriteAsync(bytes); - response.Close(); - } - catch (Exception) + public async Task ExpireUnusedAsync() + { + await Task.Delay(TimeSpan.FromMinutes(2)); + if (Interlocked.CompareExchange(ref _delivered, 0, 0) == 0) + Live.TryRemove(Nonce, out _); + } + + public async Task DeliverAsync(HttpListenerContext context) + { + var response = context.Response; + var first = Interlocked.CompareExchange(ref _delivered, 1, 0) == 0 && await _owned(); + if (!first) { - try { response.Abort(); } catch (Exception) { } + Interlocked.Exchange(ref _delivered, 1); + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; } + + _active = this; + var html = ""; + var bytes = Encoding.UTF8.GetBytes(html); + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "text/html; charset=utf-8"; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes); + response.Close(); } private string SameOriginDestination() { var destination = new Uri(_destination); - var local = new Uri(BrowserUrl); var hash = destination.Fragment; - var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{local.Port}/"); + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{Port}/"); hash = string.IsNullOrEmpty(hash) ? "#gatewayUrl=" + gateway : hash + "&gatewayUrl=" + gateway; var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; - return $"http://127.0.0.1:{local.Port}{path}{destination.Query}{hash}"; + return $"http://127.0.0.1:{Port}{path}{destination.Query}{hash}"; } - private async Task ProxyAsync(HttpListenerContext context) + public async Task ProxyAsync(HttpListenerContext context) { - var incoming = context.Request.Url; - var pathAndQuery = (incoming?.PathAndQuery) ?? "/"; var destination = new Uri(_destination); - var scheme = destination.Scheme == "https" && - (destination.Host is "localhost" or "127.0.0.1") - ? "http" - : destination.Scheme; - var upstream = new Uri($"{scheme}://{destination.Host}:{destination.Port}{pathAndQuery}"); - using var client = new HttpClient(); - using var request = new HttpRequestMessage(new HttpMethod(context.Request.HttpMethod), upstream); - using var reply = await client.SendAsync(request); - context.Response.StatusCode = (int)reply.StatusCode; - var bytes = await reply.Content.ReadAsByteArrayAsync(); - if (reply.Content.Headers.ContentType is { } type) - context.Response.ContentType = type.ToString(); - context.Response.ContentLength64 = bytes.Length; - await context.Response.OutputStream.WriteAsync(bytes); + var pathAndQuery = context.Request.Url?.PathAndQuery ?? "/"; + using var tcp = new TcpClient(); + await tcp.ConnectAsync(destination.Host, destination.Port); + if (!await _owned()) + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + context.Response.Close(); + return; + } + + await using var raw = tcp.GetStream(); + Stream stream = raw; + if (destination.Scheme == "https") + { + // The owned forward presents the gateway certificate, not a 127.0.0.1 name. + var ssl = new SslStream(raw, leaveInnerStreamOpen: false, (_, _, _, _) => true); + await ssl.AuthenticateAsClientAsync(destination.Host); + stream = ssl; + } + + var requestText = + $"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\nHost: {destination.Host}\r\nConnection: close\r\n\r\n"; + var requestBytes = Encoding.ASCII.GetBytes(requestText); + await stream.WriteAsync(requestBytes); + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer); + var payload = buffer.ToArray(); + var headerEnd = FindHeaderEnd(payload); + var body = headerEnd < 0 ? payload : payload[(headerEnd + 4)..]; + context.Response.StatusCode = StatusCode(payload); + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body); context.Response.Close(); } - private async Task ProxyWebSocketAsync(HttpListenerContext context) + public async Task ProxyWebSocketAsync(HttpListenerContext context) { - var socket = await context.AcceptWebSocketAsync(null); + var browser = await context.AcceptWebSocketAsync(null); var destination = new Uri(_destination); - var incoming = context.Request.Url; - var upstreamUri = new Uri($"ws://{destination.Host}:{destination.Port}{incoming?.PathAndQuery}"); + var scheme = destination.Scheme == "https" ? "wss" : "ws"; + var upstreamUri = new Uri($"{scheme}://{destination.Host}:{destination.Port}{context.Request.Url?.PathAndQuery}"); using var upstream = new ClientWebSocket(); try { - await upstream.ConnectAsync(upstreamUri, _lifetime.Token); + await upstream.ConnectAsync(upstreamUri, CancellationToken.None); + if (!await _owned()) + { + upstream.Abort(); + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "closed", CancellationToken.None); + return; + } + await Task.WhenAll( - PumpAsync(socket.WebSocket, upstream), - PumpAsync(upstream, socket.WebSocket)); + PumpAsync(browser.WebSocket, upstream), + PumpAsync(upstream, browser.WebSocket)); } catch (Exception) { - if (socket.WebSocket.State == WebSocketState.Open) - await socket.WebSocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "closed", CancellationToken.None); + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "closed", CancellationToken.None); } } @@ -208,7 +262,8 @@ private static async Task PumpAsync(WebSocket from, WebSocket to) var result = await from.ReceiveAsync(buffer, CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { - await to.CloseAsync(WebSocketCloseStatus.NormalClosure, "closed", CancellationToken.None); + if (to.State == WebSocketState.Open) + await to.CloseAsync(WebSocketCloseStatus.NormalClosure, "closed", CancellationToken.None); return; } @@ -216,22 +271,22 @@ private static async Task PumpAsync(WebSocket from, WebSocket to) } } - private void Stop() + private static int FindHeaderEnd(byte[] payload) { - Live.TryRemove(Nonce, out _); - _lifetime.Cancel(); - if (_listener.IsListening) - _listener.Stop(); - _listener.Close(); + for (var i = 0; i + 3 < payload.Length; i++) + { + if (payload[i] == '\r' && payload[i + 1] == '\n' && payload[i + 2] == '\r' && payload[i + 3] == '\n') + return i; + } + + return -1; } - private static int FreePort() + private static int StatusCode(byte[] payload) { - using var probe = new TcpListener(IPAddress.Loopback, 0); - probe.Start(); - var port = ((IPEndPoint)probe.LocalEndpoint).Port; - probe.Stop(); - return port; + var line = Encoding.ASCII.GetString(payload.AsSpan(0, Math.Min(payload.Length, 32))); + var parts = line.Split(' '); + return parts.Length > 1 && int.TryParse(parts[1], out var code) ? code : 502; } } } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 20ae5e193..9f8f8cf1b 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -220,6 +220,7 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() const string destination = "https://localhost:45678/mount/?view=compact#section&token=secret"; var open = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination); Assert.DoesNotContain("token=", open, StringComparison.OrdinalIgnoreCase); + Assert.Contains($":{DashboardCredentialHandoff.Port}/", open, StringComparison.Ordinal); using var http = new HttpClient(); var first = await http.GetStringAsync(open); Assert.Contains("token=secret", first, StringComparison.Ordinal); From a6f5e2ede23c9935787f95e3082c1732a961b5cb Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 14:37:32 -0700 Subject: [PATCH 13/20] ci: retrigger UI after a testhost crash The UI job on run 37845784723 aborted because the test host exited. The tests named at the crash were markdown list layout and workspace session layout. Tray, Setup and connect E2E, and the other product checks passed. Signed-off-by: Sebastien Tardif From 18bebb666e67e0edbe236f18fe339bfdd3357f72 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 14:56:03 -0700 Subject: [PATCH 14/20] ci: retrigger Core after a credential-file lock ConcurrentCreationConvergesOnOneCredential failed on run 37848102774 because api-credential.dpapi was in use by another process. UI passed on this tree. The dashboard handoff does not touch that store. Signed-off-by: Sebastien Tardif From 9cb62aa92b28201ad8b93674baae1358807ba987 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 15:53:30 -0700 Subject: [PATCH 15/20] fix(connection): keep each dashboard tab on the gateway that opened it The proxy marked every response as HTML, and the newest handoff replaced the backend for every earlier tab. Each issued link now keeps its own /s/ path. Proxied responses keep the upstream content type. TLS uses the saved gateway name instead of accepting any certificate for localhost. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 5 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../Pages/ConnectionPage.xaml.cs | 3 +- .../Services/DashboardCredentialHandoff.cs | 80 ++++++++++++++----- .../Services/GatewayDashboardLauncher.cs | 4 +- .../SetupDashboardHandoffTests.cs | 5 ++ 6 files changed, 77 insertions(+), 25 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 562650f82..7d86fa85e 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -297,6 +297,9 @@ private void WireAppCapabilityHandlers() return new { error = "Dashboard blocked because the SSH tunnel is not up." }; } + var tlsHost = Uri.TryCreate(pinned.Url, UriKind.Absolute, out var pinnedUri) + ? pinnedUri.Host + : null; var publicUrl = DashboardCredentialHandoff.Start(async () => { if (!DashboardIssuedBinding.Matches(pinned, ssh, _gatewayRegistry?.GetActive()) || @@ -310,7 +313,7 @@ private void WireAppCapabilityHandlers() out _, out _, ownedNow); - }, url); + }, url, tlsHost); return new { url = publicUrl, diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index e0838ea0e..278cf6c7c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4219,7 +4219,10 @@ private Task LaunchPreparedDashboardAsync( Func>? confirm = issued is not null && issuedTunnel is not null ? () => ConfirmIssuedDashboardListenerAsync(issued, issuedTunnel) : null; - return launcher.OpenPreparedAsync(url, confirm); + var tlsHost = issued is not null && Uri.TryCreate(issued.Url, UriKind.Absolute, out var issuedUri) + ? issuedUri.Host + : null; + return launcher.OpenPreparedAsync(url, confirm, tlsHost); } private async Task ConfirmIssuedDashboardListenerAsync( diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index f0c049c95..1935fa26d 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2663,7 +2663,8 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) ? url : DashboardCredentialHandoff.Start( () => SavedDashboardListenerStillOwnedAsync(pinned, pinned.SshTunnel), - url); + url, + Uri.TryCreate(pinned.Url, UriKind.Absolute, out var savedUri) ? savedUri.Host : null); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(launchUrl)); } catch (Exception ex) diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index dece19f91..3a4701862 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -29,14 +29,13 @@ internal static class DashboardCredentialHandoff private static readonly ConcurrentDictionary Live = new(); private static readonly object ListenerGate = new(); private static HttpListener? _listener; - private static Handoff? _active; - public static string Start(Func> owned, string destination) + public static string Start(Func> owned, string destination, string? tlsHost = null) { ArgumentNullException.ThrowIfNull(owned); ArgumentException.ThrowIfNullOrWhiteSpace(destination); EnsureListener(); - var handoff = new Handoff(owned, destination); + var handoff = new Handoff(owned, destination, tlsHost); Live[handoff.Nonce] = handoff; _ = handoff.ExpireUnusedAsync(); return handoff.BrowserUrl; @@ -104,8 +103,7 @@ private static async Task AnswerAsync(HttpListenerContext context) return; } - var active = _active; - if (active is null || !await active.Owned()) + if (!TrySession(path, out var session, out var upstreamPath) || !await session.Owned()) { response.StatusCode = (int)HttpStatusCode.NotFound; response.Close(); @@ -114,11 +112,11 @@ private static async Task AnswerAsync(HttpListenerContext context) if (context.Request.IsWebSocketRequest) { - await active.ProxyWebSocketAsync(context); + await session.ProxyWebSocketAsync(context, upstreamPath); return; } - await active.ProxyAsync(context); + await session.ProxyAsync(context, upstreamPath); } catch (Exception) { @@ -126,16 +124,50 @@ private static async Task AnswerAsync(HttpListenerContext context) } } + private static bool TrySession(string path, out Handoff session, out string upstreamPath) + { + foreach (var candidate in Live.Values) + { + var prefix = "/s/" + candidate.Nonce; + if (!path.Equals(prefix, StringComparison.Ordinal) && + !path.StartsWith(prefix + "/", StringComparison.Ordinal)) + continue; + session = candidate; + upstreamPath = path[prefix.Length..]; + if (string.IsNullOrEmpty(upstreamPath)) + upstreamPath = "/"; + return true; + } + + session = null!; + upstreamPath = "/"; + return false; + } + + internal static string? ReadContentType(string headerText) + { + foreach (var line in headerText.Split("\r\n")) + { + const string name = "Content-Type:"; + if (line.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + return line[name.Length..].Trim(); + } + + return null; + } + private sealed class Handoff { private readonly Func> _owned; private readonly string _destination; + private readonly string _tlsHost; private int _delivered; - public Handoff(Func> owned, string destination) + public Handoff(Func> owned, string destination, string? tlsHost) { _owned = owned; _destination = destination; + _tlsHost = string.IsNullOrWhiteSpace(tlsHost) ? new Uri(destination).Host : tlsHost; Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); BrowserUrl = $"http://127.0.0.1:{Port}/d/{Nonce}"; } @@ -163,7 +195,6 @@ public async Task DeliverAsync(HttpListenerContext context) return; } - _active = this; var html = ""; var bytes = Encoding.UTF8.GetBytes(html); @@ -178,18 +209,19 @@ private string SameOriginDestination() { var destination = new Uri(_destination); var hash = destination.Fragment; - var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{Port}/"); + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{Port}/s/{Nonce}/"); hash = string.IsNullOrEmpty(hash) ? "#gatewayUrl=" + gateway : hash + "&gatewayUrl=" + gateway; var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; - return $"http://127.0.0.1:{Port}{path}{destination.Query}{hash}"; + return $"http://127.0.0.1:{Port}/s/{Nonce}{path}{destination.Query}{hash}"; } - public async Task ProxyAsync(HttpListenerContext context) + public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) { var destination = new Uri(_destination); - var pathAndQuery = context.Request.Url?.PathAndQuery ?? "/"; + var query = context.Request.Url?.Query ?? ""; + var pathAndQuery = upstreamPath + query; using var tcp = new TcpClient(); await tcp.ConnectAsync(destination.Host, destination.Port); if (!await _owned()) @@ -203,14 +235,13 @@ public async Task ProxyAsync(HttpListenerContext context) Stream stream = raw; if (destination.Scheme == "https") { - // The owned forward presents the gateway certificate, not a 127.0.0.1 name. - var ssl = new SslStream(raw, leaveInnerStreamOpen: false, (_, _, _, _) => true); - await ssl.AuthenticateAsClientAsync(destination.Host); + var ssl = new SslStream(raw, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsClientAsync(_tlsHost); stream = ssl; } var requestText = - $"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\nHost: {destination.Host}\r\nConnection: close\r\n\r\n"; + $"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\nHost: {_tlsHost}\r\nConnection: close\r\n\r\n"; var requestBytes = Encoding.ASCII.GetBytes(requestText); await stream.WriteAsync(requestBytes); using var buffer = new MemoryStream(); @@ -219,19 +250,28 @@ public async Task ProxyAsync(HttpListenerContext context) var headerEnd = FindHeaderEnd(payload); var body = headerEnd < 0 ? payload : payload[(headerEnd + 4)..]; context.Response.StatusCode = StatusCode(payload); - context.Response.ContentType = "text/html; charset=utf-8"; + var headerText = Encoding.ASCII.GetString(payload, 0, headerEnd < 0 ? payload.Length : headerEnd); + if (ReadContentType(headerText) is { } contentType) + context.Response.ContentType = contentType; context.Response.ContentLength64 = body.Length; await context.Response.OutputStream.WriteAsync(body); context.Response.Close(); } - public async Task ProxyWebSocketAsync(HttpListenerContext context) + public async Task ProxyWebSocketAsync(HttpListenerContext context, string upstreamPath) { var browser = await context.AcceptWebSocketAsync(null); var destination = new Uri(_destination); var scheme = destination.Scheme == "https" ? "wss" : "ws"; - var upstreamUri = new Uri($"{scheme}://{destination.Host}:{destination.Port}{context.Request.Url?.PathAndQuery}"); + var query = context.Request.Url?.Query ?? ""; + var upstreamUri = new Uri($"{scheme}://{destination.Host}:{destination.Port}{upstreamPath}{query}"); using var upstream = new ClientWebSocket(); + var tlsHost = _tlsHost; + upstream.Options.RemoteCertificateValidationCallback = (_, certificate, _, errors) => + errors == System.Net.Security.SslPolicyErrors.None || + (errors == System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch && + certificate is System.Security.Cryptography.X509Certificates.X509Certificate2 cert && + cert.MatchesHostname(tlsHost)); try { await upstream.ConnectAsync(upstreamUri, CancellationToken.None); diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index 77bbe1fe1..5b020275f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -37,7 +37,7 @@ public async Task OpenAsync(string? path = null) return true; } - public async Task OpenPreparedAsync(string url, Func>? confirmReady = null) + public async Task OpenPreparedAsync(string url, Func>? confirmReady = null, string? tlsHost = null) { // The ownership check that built this URL can be stale by the time the // browser starts. Refuse the launch when that check no longer holds. @@ -48,7 +48,7 @@ public async Task OpenPreparedAsync(string url, Func>? confirmR // is released only when that page is requested and ownership still holds. var launchUrl = confirmReady is null ? url - : DashboardCredentialHandoff.Start(confirmReady, url); + : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost); return await OpenUrlAsync(launchUrl); } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 9f8f8cf1b..965601181 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -224,7 +224,12 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() using var http = new HttpClient(); var first = await http.GetStringAsync(open); Assert.Contains("token=secret", first, StringComparison.Ordinal); + Assert.Contains("/s/", first, StringComparison.Ordinal); Assert.DoesNotContain("45678", first, StringComparison.Ordinal); + Assert.Equal( + "application/javascript", + DashboardCredentialHandoff.ReadContentType( + "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\n\r\n")); var denied = await http.GetAsync(open); Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); From 5e28f41df7e56b8791b6f23c398e4baf3ae78780 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 16:52:52 -0700 Subject: [PATCH 16/20] fix(connection): route dashboard assets to the issuing session Gateway pages request /assets from the site root, so a session prefix on the document alone returned 404. Absolute links in HTML are rewritten under /s/, and a later request is matched from its Referer. HTTP forwarding now requires the accepted socket's process to be the current listener before any bytes are copied. WebSocket certificates must match the saved gateway name. Security headers from the gateway are copied through. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../WindowsTcpListenerSnapshot.cs | 77 ++++++++++++++++ .../Services/DashboardCredentialHandoff.cs | 88 +++++++++++++++++-- .../SetupDashboardHandoffTests.cs | 11 +++ 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index 76b490afd..ab335b47e 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -33,6 +33,82 @@ public static WindowsTcpListenerSnapshotResult Capture() return new(result, ipv4Complete, ipv6Complete); } + public static int? MatchAcceptedProcess( + IEnumerable<(int LocalPort, int RemotePort, int ProcessId)> rows, + int serverPort, + int clientPort) + { + foreach (var row in rows) + { + if (row.LocalPort == serverPort && row.RemotePort == clientPort) + return row.ProcessId; + } + + return null; + } + + public static int? AcceptedProcessId(int serverPort, int clientPort) + { + if (!OperatingSystem.IsWindows()) + return null; + + var rows = new List<(int LocalPort, int RemotePort, int ProcessId)>(); + if (!CaptureConnections(rows)) + return null; + return MatchAcceptedProcess(rows, serverPort, clientPort); + } + + private static bool CaptureConnections(List<(int LocalPort, int RemotePort, int ProcessId)> rows) + { + for (var attempt = 0; attempt < 3; attempt++) + { + var bufferLength = 0; + var status = GetExtendedTcpTable( + IntPtr.Zero, + ref bufferLength, + sort: true, + ipVersion: AfInet, + tableClass: TcpTableOwnerPidConnections, + reserved: 0); + if (status != ErrorInsufficientBuffer || bufferLength <= 0) + return false; + + var tablePtr = Marshal.AllocHGlobal(bufferLength); + try + { + status = GetExtendedTcpTable( + tablePtr, + ref bufferLength, + sort: true, + ipVersion: AfInet, + tableClass: TcpTableOwnerPidConnections, + reserved: 0); + if (status == ErrorInsufficientBuffer) + continue; + if (status != ErrorSuccess) + return false; + + var rowCount = Marshal.ReadInt32(tablePtr); + var rowPtr = IntPtr.Add(tablePtr, sizeof(int)); + var rowSize = Marshal.SizeOf(); + for (var i = 0; i < rowCount; i++) + { + var row = Marshal.PtrToStructure(rowPtr); + rows.Add((ReadPort(row.LocalPort), ReadPort(row.RemotePort), unchecked((int)row.OwningProcessId))); + rowPtr = IntPtr.Add(rowPtr, rowSize); + } + + return true; + } + finally + { + Marshal.FreeHGlobal(tablePtr); + } + } + + return false; + } + public static string? GetProcessCommandLine(int processId) { if (processId <= 0) @@ -245,6 +321,7 @@ private static void ResolveProcess( private const int AfInet = 2; private const int AfInet6 = 23; private const int TcpTableOwnerPidListener = 3; + private const int TcpTableOwnerPidConnections = 4; private const uint ErrorSuccess = 0; private const uint ErrorInsufficientBuffer = 122; diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index 3a4701862..fec16183b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Linq; using System.Net; using System.Net.Security; using System.Net.Sockets; @@ -103,7 +104,15 @@ private static async Task AnswerAsync(HttpListenerContext context) return; } - if (!TrySession(path, out var session, out var upstreamPath) || !await session.Owned()) + if (!TrySession(path, out var session, out var upstreamPath) && + !TrySessionFromReferer(context.Request.Headers["Referer"], path, out session, out upstreamPath)) + { + response.StatusCode = (int)HttpStatusCode.NotFound; + response.Close(); + return; + } + + if (!await session.Owned()) { response.StatusCode = (int)HttpStatusCode.NotFound; response.Close(); @@ -144,6 +153,59 @@ private static bool TrySession(string path, out Handoff session, out string upst return false; } + private static bool TrySessionFromReferer(string? referer, string requestPath, out Handoff session, out string upstreamPath) + { + session = null!; + upstreamPath = requestPath; + if (string.IsNullOrWhiteSpace(referer) || !Uri.TryCreate(referer, UriKind.Absolute, out var uri)) + return false; + if (!TrySession(uri.AbsolutePath, out session, out _)) + return false; + if (string.IsNullOrEmpty(upstreamPath)) + upstreamPath = "/"; + return true; + } + + internal static string RewriteRootAbsolute(string html, string sessionPrefix) + { + return System.Text.RegularExpressions.Regex.Replace( + html, + "(src|href|action)=\"/(?!s/)", + "$1=\"" + sessionPrefix + "/", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + internal static void CopySecurityHeaders(string headerText, HttpListenerResponse response) + { + string[] names = + [ + "Content-Security-Policy", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Cross-Origin-Opener-Policy", + "Cross-Origin-Resource-Policy", + "Cross-Origin-Embedder-Policy", + ]; + foreach (var line in headerText.Split("\r\n")) + { + var colon = line.IndexOf(':'); + if (colon <= 0) + continue; + var name = line[..colon].Trim(); + if (!names.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + try + { + response.Headers[name] = line[(colon + 1)..].Trim(); + } + catch (ArgumentException) + { + } + } + } + internal static string? ReadContentType(string headerText) { foreach (var line in headerText.Split("\r\n")) @@ -224,7 +286,11 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) var pathAndQuery = upstreamPath + query; using var tcp = new TcpClient(); await tcp.ConnectAsync(destination.Host, destination.Port); - if (!await _owned()) + var clientPort = tcp.Client.LocalEndPoint is IPEndPoint local ? local.Port : 0; + var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(destination.Port, clientPort); + var listener = WindowsTcpListenerSnapshot.Capture().Listeners + .FirstOrDefault(item => item.Port == destination.Port); + if (accepted is null || listener is null || accepted != listener.ProcessId || !await _owned()) { context.Response.StatusCode = (int)HttpStatusCode.NotFound; context.Response.Close(); @@ -251,8 +317,16 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) var body = headerEnd < 0 ? payload : payload[(headerEnd + 4)..]; context.Response.StatusCode = StatusCode(payload); var headerText = Encoding.ASCII.GetString(payload, 0, headerEnd < 0 ? payload.Length : headerEnd); + CopySecurityHeaders(headerText, context.Response); if (ReadContentType(headerText) is { } contentType) + { context.Response.ContentType = contentType; + if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + { + var html = Encoding.UTF8.GetString(body); + body = Encoding.UTF8.GetBytes(RewriteRootAbsolute(html, "/s/" + Nonce)); + } + } context.Response.ContentLength64 = body.Length; await context.Response.OutputStream.WriteAsync(body); context.Response.Close(); @@ -268,10 +342,12 @@ public async Task ProxyWebSocketAsync(HttpListenerContext context, string upstre using var upstream = new ClientWebSocket(); var tlsHost = _tlsHost; upstream.Options.RemoteCertificateValidationCallback = (_, certificate, _, errors) => - errors == System.Net.Security.SslPolicyErrors.None || - (errors == System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch && - certificate is System.Security.Cryptography.X509Certificates.X509Certificate2 cert && - cert.MatchesHostname(tlsHost)); + { + if (certificate is not System.Security.Cryptography.X509Certificates.X509Certificate2 cert) + return false; + var other = errors & ~System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch; + return other == System.Net.Security.SslPolicyErrors.None && cert.MatchesHostname(tlsHost); + }; try { await upstream.ConnectAsync(upstreamUri, CancellationToken.None); diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 965601181..6ffeaabef 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -230,6 +230,17 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() "application/javascript", DashboardCredentialHandoff.ReadContentType( "HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\n\r\n")); + Assert.Equal( + "", + DashboardCredentialHandoff.RewriteRootAbsolute( + "", + "/s/abc")); + Assert.Equal( + 42, + WindowsTcpListenerSnapshot.MatchAcceptedProcess( + [(45678, 51000, 42), (51000, 45678, 7)], + serverPort: 45678, + clientPort: 51000)); var denied = await http.GetAsync(open); Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); From 26f07579b4a49657294e6f5798ab376ed46c1679 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 17:23:39 -0700 Subject: [PATCH 17/20] fix(connection): separate dashboard origins and forward the request Each gateway now gets its own loopback origin, so one dashboard cannot read another's browser storage. The proxy forwards cookies, authorization, and the request body, and it rewrites redirects back under the issuing session. The WebSocket uses the same accepted socket that was checked against the current listener before any frames are copied. ./build.ps1 passed. Shared 4263 passed, 33 skipped, 4296 total. Tray 4003 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 2 +- .../Pages/ConnectionPage.xaml.cs | 3 +- .../Services/DashboardCredentialHandoff.cs | 165 +++++++++++++++--- .../Services/GatewayDashboardLauncher.cs | 8 +- .../SetupDashboardHandoffTests.cs | 3 + 6 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 7d86fa85e..853f49371 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -313,7 +313,7 @@ private void WireAppCapabilityHandlers() out _, out _, ownedNow); - }, url, tlsHost); + }, url, tlsHost, pinned.Id); return new { url = publicUrl, diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 278cf6c7c..46869cde8 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4222,7 +4222,7 @@ private Task LaunchPreparedDashboardAsync( var tlsHost = issued is not null && Uri.TryCreate(issued.Url, UriKind.Absolute, out var issuedUri) ? issuedUri.Host : null; - return launcher.OpenPreparedAsync(url, confirm, tlsHost); + return launcher.OpenPreparedAsync(url, confirm, tlsHost, issued?.Id); } private async Task ConfirmIssuedDashboardListenerAsync( diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 1935fa26d..4c260fe83 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2664,7 +2664,8 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) : DashboardCredentialHandoff.Start( () => SavedDashboardListenerStillOwnedAsync(pinned, pinned.SshTunnel), url, - Uri.TryCreate(pinned.Url, UriKind.Absolute, out var savedUri) ? savedUri.Host : null); + Uri.TryCreate(pinned.Url, UriKind.Absolute, out var savedUri) ? savedUri.Host : null, + pinned.Id); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(launchUrl)); } catch (Exception ex) diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index fec16183b..c5a3f3d05 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -28,30 +28,46 @@ internal static class DashboardCredentialHandoff public const int Port = 47831; private static readonly ConcurrentDictionary Live = new(); + private static readonly ConcurrentDictionary OriginPorts = new(); + private static readonly ConcurrentDictionary Listeners = new(); private static readonly object ListenerGate = new(); - private static HttpListener? _listener; - public static string Start(Func> owned, string destination, string? tlsHost = null) + public static string Start( + Func> owned, + string destination, + string? tlsHost = null, + string? originKey = null) { ArgumentNullException.ThrowIfNull(owned); ArgumentException.ThrowIfNullOrWhiteSpace(destination); - EnsureListener(); - var handoff = new Handoff(owned, destination, tlsHost); + var key = string.IsNullOrWhiteSpace(originKey) ? "default" : originKey; + var port = OriginPorts.GetOrAdd(key, static assigned => assigned == "default" ? Port : FreePort()); + EnsureListener(port); + var handoff = new Handoff(owned, destination, tlsHost, port); Live[handoff.Nonce] = handoff; _ = handoff.ExpireUnusedAsync(); return handoff.BrowserUrl; } - private static void EnsureListener() + private static int FreePort() + { + using var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + private static void EnsureListener(int port) { lock (ListenerGate) { - if (_listener is { IsListening: true }) + if (Listeners.TryGetValue(port, out var existing) && existing.IsListening) return; var listener = new HttpListener(); - listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); listener.Start(); - _listener = listener; + Listeners[port] = listener; _ = ServeAsync(listener); } } @@ -166,6 +182,27 @@ private static bool TrySessionFromReferer(string? referer, string requestPath, o return true; } + internal static string? ReadHeader(string headerText, string name) + { + foreach (var line in headerText.Split("\r\n")) + { + var prefix = name + ":"; + if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return line[prefix.Length..].Trim(); + } + + return null; + } + + internal static string RewriteLocation(string location, string sessionPrefix) + { + if (location.StartsWith(sessionPrefix, StringComparison.Ordinal)) + return location; + if (location.StartsWith("/", StringComparison.Ordinal)) + return sessionPrefix + location; + return location; + } + internal static string RewriteRootAbsolute(string html, string sessionPrefix) { return System.Text.RegularExpressions.Regex.Replace( @@ -223,15 +260,17 @@ private sealed class Handoff private readonly Func> _owned; private readonly string _destination; private readonly string _tlsHost; + private readonly int _port; private int _delivered; - public Handoff(Func> owned, string destination, string? tlsHost) + public Handoff(Func> owned, string destination, string? tlsHost, int port) { _owned = owned; _destination = destination; _tlsHost = string.IsNullOrWhiteSpace(tlsHost) ? new Uri(destination).Host : tlsHost; + _port = port; Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); - BrowserUrl = $"http://127.0.0.1:{Port}/d/{Nonce}"; + BrowserUrl = $"http://127.0.0.1:{port}/d/{Nonce}"; } public string Nonce { get; } @@ -271,12 +310,12 @@ private string SameOriginDestination() { var destination = new Uri(_destination); var hash = destination.Fragment; - var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{Port}/s/{Nonce}/"); + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{_port}/s/{Nonce}/"); hash = string.IsNullOrEmpty(hash) ? "#gatewayUrl=" + gateway : hash + "&gatewayUrl=" + gateway; var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; - return $"http://127.0.0.1:{Port}/s/{Nonce}{path}{destination.Query}{hash}"; + return $"http://127.0.0.1:{_port}/s/{Nonce}{path}{destination.Query}{hash}"; } public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) @@ -306,10 +345,30 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) stream = ssl; } - var requestText = - $"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\nHost: {_tlsHost}\r\nConnection: close\r\n\r\n"; - var requestBytes = Encoding.ASCII.GetBytes(requestText); - await stream.WriteAsync(requestBytes); + var bodyBytes = Array.Empty(); + if (context.Request.HasEntityBody) + { + using var incoming = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(incoming); + bodyBytes = incoming.ToArray(); + } + + var requestHeader = new StringBuilder(); + requestHeader.Append($"{context.Request.HttpMethod} {pathAndQuery} HTTP/1.1\r\n"); + requestHeader.Append($"Host: {_tlsHost}\r\n"); + requestHeader.Append("Connection: close\r\n"); + foreach (var name in new[] { "Cookie", "Authorization", "Content-Type", "Accept", "Accept-Language" }) + { + var value = context.Request.Headers[name]; + if (!string.IsNullOrEmpty(value)) + requestHeader.Append($"{name}: {value}\r\n"); + } + if (bodyBytes.Length > 0) + requestHeader.Append($"Content-Length: {bodyBytes.Length}\r\n"); + requestHeader.Append("\r\n"); + await stream.WriteAsync(Encoding.ASCII.GetBytes(requestHeader.ToString())); + if (bodyBytes.Length > 0) + await stream.WriteAsync(bodyBytes); using var buffer = new MemoryStream(); await stream.CopyToAsync(buffer); var payload = buffer.ToArray(); @@ -318,6 +377,16 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) context.Response.StatusCode = StatusCode(payload); var headerText = Encoding.ASCII.GetString(payload, 0, headerEnd < 0 ? payload.Length : headerEnd); CopySecurityHeaders(headerText, context.Response); + if (ReadHeader(headerText, "Location") is { } location) + { + try + { + context.Response.Headers["Location"] = RewriteLocation(location, "/s/" + Nonce); + } + catch (ArgumentException) + { + } + } if (ReadContentType(headerText) is { } contentType) { context.Response.ContentType = contentType; @@ -336,29 +405,45 @@ public async Task ProxyWebSocketAsync(HttpListenerContext context, string upstre { var browser = await context.AcceptWebSocketAsync(null); var destination = new Uri(_destination); - var scheme = destination.Scheme == "https" ? "wss" : "ws"; var query = context.Request.Url?.Query ?? ""; - var upstreamUri = new Uri($"{scheme}://{destination.Host}:{destination.Port}{upstreamPath}{query}"); - using var upstream = new ClientWebSocket(); - var tlsHost = _tlsHost; - upstream.Options.RemoteCertificateValidationCallback = (_, certificate, _, errors) => + using var probe = new TcpClient(); + await probe.ConnectAsync(destination.Host, destination.Port); + var clientPort = probe.Client.LocalEndPoint is IPEndPoint local ? local.Port : 0; + var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(destination.Port, clientPort); + var listener = WindowsTcpListenerSnapshot.Capture().Listeners + .FirstOrDefault(item => item.Port == destination.Port); + if (accepted is null || listener is null || accepted != listener.ProcessId || !await _owned()) { - if (certificate is not System.Security.Cryptography.X509Certificates.X509Certificate2 cert) - return false; - var other = errors & ~System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch; - return other == System.Net.Security.SslPolicyErrors.None && cert.MatchesHostname(tlsHost); - }; + probe.Close(); + if (browser.WebSocket.State == WebSocketState.Open) + await browser.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "closed", CancellationToken.None); + return; + } + try { - await upstream.ConnectAsync(upstreamUri, CancellationToken.None); - if (!await _owned()) + Stream socketStream = probe.GetStream(); + if (destination.Scheme == "https") + { + var ssl = new SslStream(socketStream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsClientAsync(_tlsHost); + socketStream = ssl; + } + + var key = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + var upgrade = + $"GET {upstreamPath}{query} HTTP/1.1\r\nHost: {_tlsHost}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"; + await socketStream.WriteAsync(Encoding.ASCII.GetBytes(upgrade)); + if (!await ReadUpgradeAcceptedAsync(socketStream)) { - upstream.Abort(); if (browser.WebSocket.State == WebSocketState.Open) await browser.WebSocket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "closed", CancellationToken.None); return; } + using var upstream = WebSocket.CreateFromStream( + socketStream, + new WebSocketCreationOptions { IsServer = false }); await Task.WhenAll( PumpAsync(browser.WebSocket, upstream), PumpAsync(upstream, browser.WebSocket)); @@ -370,6 +455,28 @@ await Task.WhenAll( } } + private static async Task ReadUpgradeAcceptedAsync(Stream stream) + { + using var header = new MemoryStream(); + var window = new byte[4]; + var one = new byte[1]; + while (await stream.ReadAsync(one) > 0) + { + header.WriteByte(one[0]); + window[0] = window[1]; + window[1] = window[2]; + window[2] = window[3]; + window[3] = one[0]; + if (window[0] == '\r' && window[1] == '\n' && window[2] == '\r' && window[3] == '\n') + break; + if (header.Length > 8192) + return false; + } + + var text = Encoding.ASCII.GetString(header.ToArray()); + return text.Contains(" 101 ", StringComparison.Ordinal); + } + private static async Task PumpAsync(WebSocket from, WebSocket to) { var buffer = new byte[8192]; diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index 5b020275f..a96ade0a7 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -37,7 +37,11 @@ public async Task OpenAsync(string? path = null) return true; } - public async Task OpenPreparedAsync(string url, Func>? confirmReady = null, string? tlsHost = null) + public async Task OpenPreparedAsync( + string url, + Func>? confirmReady = null, + string? tlsHost = null, + string? originKey = null) { // The ownership check that built this URL can be stale by the time the // browser starts. Refuse the launch when that check no longer holds. @@ -48,7 +52,7 @@ public async Task OpenPreparedAsync(string url, Func>? confirmR // is released only when that page is requested and ownership still holds. var launchUrl = confirmReady is null ? url - : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost); + : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost, originKey); return await OpenUrlAsync(launchUrl); } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 6ffeaabef..36f8569c8 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -235,6 +235,9 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() DashboardCredentialHandoff.RewriteRootAbsolute( "", "/s/abc")); + Assert.Equal("/s/abc/assets/app.js", DashboardCredentialHandoff.RewriteLocation("/assets/app.js", "/s/abc")); + var other = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "other-gateway"); + Assert.DoesNotContain($":{DashboardCredentialHandoff.Port}/", other, StringComparison.Ordinal); Assert.Equal( 42, WindowsTcpListenerSnapshot.MatchAcceptedProcess( From 10f8f2dda4eec6b0f58c9a21977959d58c2bf3ce Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 18:15:57 -0700 Subject: [PATCH 18/20] fix(connection): keep each dashboard origin and decode chunked bodies A session nonce is served only by the listener that issued it, including Referer fallback. Each gateway keeps the loopback port in dashboard-origins.json, and the first gateway keeps port 47831. Accepted sockets match the connected address family and endpoint, including IPv6. Chunked upstream responses are decoded before the browser reads them. Cross-origin nonce requests return 404. A saved port survives an in-memory reset. An IPv6 loopback connection matches this process. A chunked JSON upstream returns the decoded object. ./build.ps1 passed. Shared 4263 passed / 33 skipped / 4296. Tray 4007 passed. Signed-off-by: Sebastien Tardif --- .../WindowsTcpListenerSnapshot.cs | 103 +++++++-- .../Services/DashboardCredentialHandoff.cs | 201 ++++++++++++++++-- .../SetupDashboardHandoffTests.cs | 137 +++++++++++- 3 files changed, 405 insertions(+), 36 deletions(-) diff --git a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs index ab335b47e..55ca5f6c0 100644 --- a/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs +++ b/src/OpenClaw.Connection/WindowsTcpListenerSnapshot.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Net; +using System.Net.Sockets; using System.Runtime.InteropServices; namespace OpenClaw.Connection; @@ -33,32 +34,87 @@ public static WindowsTcpListenerSnapshotResult Capture() return new(result, ipv4Complete, ipv6Complete); } + public readonly record struct AcceptedTcpEndpoint( + IPAddress LocalAddress, + int LocalPort, + IPAddress RemoteAddress, + int RemotePort, + int ProcessId); + public static int? MatchAcceptedProcess( - IEnumerable<(int LocalPort, int RemotePort, int ProcessId)> rows, - int serverPort, - int clientPort) + IEnumerable rows, + IPEndPoint server, + IPEndPoint client) { foreach (var row in rows) { - if (row.LocalPort == serverPort && row.RemotePort == clientPort) + if (row.LocalPort == server.Port && + row.RemotePort == client.Port && + AddressesMatch(row.LocalAddress, server.Address) && + AddressesMatch(row.RemoteAddress, client.Address)) return row.ProcessId; } return null; } - public static int? AcceptedProcessId(int serverPort, int clientPort) + public static WindowsTcpListenerInfo? MatchListener( + IEnumerable listeners, + IPEndPoint connected) + { + WindowsTcpListenerInfo? wildcard = null; + foreach (var item in listeners) + { + if (item.Port != connected.Port) + continue; + if (AddressesMatch(item.Address, connected.Address)) + return item; + if (wildcard is null && IsSameFamilyWildcard(item.Address, connected.Address)) + wildcard = item; + } + + return wildcard; + } + + public static int? AcceptedProcessId(IPEndPoint server, IPEndPoint client) { if (!OperatingSystem.IsWindows()) return null; - var rows = new List<(int LocalPort, int RemotePort, int ProcessId)>(); + var rows = new List(); if (!CaptureConnections(rows)) return null; - return MatchAcceptedProcess(rows, serverPort, clientPort); + return MatchAcceptedProcess(rows, server, client); } - private static bool CaptureConnections(List<(int LocalPort, int RemotePort, int ProcessId)> rows) + internal static bool AddressesMatch(IPAddress left, IPAddress right) + { + if (left.Equals(right)) + return true; + if (left.IsIPv4MappedToIPv6 && left.MapToIPv4().Equals(right)) + return true; + if (right.IsIPv4MappedToIPv6 && right.MapToIPv4().Equals(left)) + return true; + return false; + } + + private static bool IsSameFamilyWildcard(IPAddress listener, IPAddress connected) + { + if (connected.AddressFamily == AddressFamily.InterNetwork) + return listener.Equals(IPAddress.Any); + if (connected.AddressFamily == AddressFamily.InterNetworkV6) + return listener.Equals(IPAddress.IPv6Any); + return false; + } + + private static bool CaptureConnections(List rows) + { + var ipv4 = CaptureConnectionFamily(AfInet, rows); + var ipv6 = CaptureConnectionFamily(AfInet6, rows); + return ipv4 || ipv6; + } + + private static bool CaptureConnectionFamily(int addressFamily, List rows) { for (var attempt = 0; attempt < 3; attempt++) { @@ -67,7 +123,7 @@ private static bool CaptureConnections(List<(int LocalPort, int RemotePort, int IntPtr.Zero, ref bufferLength, sort: true, - ipVersion: AfInet, + ipVersion: addressFamily, tableClass: TcpTableOwnerPidConnections, reserved: 0); if (status != ErrorInsufficientBuffer || bufferLength <= 0) @@ -80,7 +136,7 @@ private static bool CaptureConnections(List<(int LocalPort, int RemotePort, int tablePtr, ref bufferLength, sort: true, - ipVersion: AfInet, + ipVersion: addressFamily, tableClass: TcpTableOwnerPidConnections, reserved: 0); if (status == ErrorInsufficientBuffer) @@ -90,11 +146,32 @@ private static bool CaptureConnections(List<(int LocalPort, int RemotePort, int var rowCount = Marshal.ReadInt32(tablePtr); var rowPtr = IntPtr.Add(tablePtr, sizeof(int)); - var rowSize = Marshal.SizeOf(); + var rowSize = addressFamily == AfInet6 + ? Marshal.SizeOf() + : Marshal.SizeOf(); for (var i = 0; i < rowCount; i++) { - var row = Marshal.PtrToStructure(rowPtr); - rows.Add((ReadPort(row.LocalPort), ReadPort(row.RemotePort), unchecked((int)row.OwningProcessId))); + if (addressFamily == AfInet6) + { + var row = Marshal.PtrToStructure(rowPtr); + rows.Add(new AcceptedTcpEndpoint( + new IPAddress(row.LocalAddress, row.LocalScopeId), + ReadPort(row.LocalPort), + new IPAddress(row.RemoteAddress, row.RemoteScopeId), + ReadPort(row.RemotePort), + unchecked((int)row.OwningProcessId))); + } + else + { + var row = Marshal.PtrToStructure(rowPtr); + rows.Add(new AcceptedTcpEndpoint( + new IPAddress(BitConverter.GetBytes(row.LocalAddress)), + ReadPort(row.LocalPort), + new IPAddress(BitConverter.GetBytes(row.RemoteAddress)), + ReadPort(row.RemotePort), + unchecked((int)row.OwningProcessId))); + } + rowPtr = IntPtr.Add(rowPtr, rowSize); } diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index c5a3f3d05..eed15f691 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Security; @@ -41,14 +42,72 @@ public static string Start( ArgumentNullException.ThrowIfNull(owned); ArgumentException.ThrowIfNullOrWhiteSpace(destination); var key = string.IsNullOrWhiteSpace(originKey) ? "default" : originKey; - var port = OriginPorts.GetOrAdd(key, static assigned => assigned == "default" ? Port : FreePort()); - EnsureListener(port); + var port = PortFor(key); + try + { + EnsureListener(port); + } + catch (HttpListenerException ex) + { + throw new InvalidOperationException( + $"Dashboard origin 127.0.0.1:{port} is already in use. Close the program using that port and open the dashboard again.", + ex); + } var handoff = new Handoff(owned, destination, tlsHost, port); Live[handoff.Nonce] = handoff; _ = handoff.ExpireUnusedAsync(); return handoff.BrowserUrl; } + /// + /// Test override for the per-gateway origin file. Production uses the tray data directory. + /// + internal static string? OriginStorePath { get; set; } + + internal static void ResetOriginsForTests() => OriginPorts.Clear(); + + private static int PortFor(string key) + { + if (OriginPorts.TryGetValue(key, out var cached)) + return cached; + + lock (ListenerGate) + { + if (OriginPorts.TryGetValue(key, out cached)) + return cached; + + var saved = ReadStore(); + if (!saved.TryGetValue(key, out var port)) + { + // The previous handoff used 47831 for every gateway. Keep that + // origin for the first gateway so its browser profile stays put. + // Later gateways get their own port, saved for the next process. + port = key == "default" || !PortTaken(Port, saved) ? Port : AllocatePort(saved); + saved[key] = port; + WriteStore(saved); + } + + OriginPorts[key] = port; + return port; + } + } + + private static bool PortTaken(int port, Dictionary saved) => + saved.ContainsValue(port) || OriginPorts.Values.Contains(port); + + private static int AllocatePort(Dictionary saved) + { + for (var attempt = 0; attempt < 8; attempt++) + { + var port = FreePort(); + if (port == Port || PortTaken(port, saved)) + continue; + return port; + } + + throw new InvalidOperationException("No free loopback port is available for a dashboard origin."); + } + private static int FreePort() { using var probe = new TcpListener(IPAddress.Loopback, 0); @@ -58,6 +117,37 @@ private static int FreePort() return port; } + private static string StoreFilePath() => + OriginStorePath ?? Path.Combine(AppIdentity.ResolveRoamingDataDirectory(), "dashboard-origins.json"); + + private static Dictionary ReadStore() + { + try + { + var path = StoreFilePath(); + if (!File.Exists(path)) + return new Dictionary(StringComparer.Ordinal); + var saved = JsonSerializer.Deserialize>(File.ReadAllText(path)); + return saved ?? new Dictionary(StringComparer.Ordinal); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + throw new InvalidOperationException("The saved dashboard origins could not be read.", ex); + } + } + + private static void WriteStore(Dictionary saved) + { + var path = StoreFilePath(); + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + var json = JsonSerializer.Serialize(saved); + var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + } + private static void EnsureListener(int port) { lock (ListenerGate) @@ -106,10 +196,11 @@ private static async Task AnswerAsync(HttpListenerContext context) try { var path = context.Request.Url?.AbsolutePath ?? ""; + var requestPort = RequestPort(context); if (path.StartsWith("/d/", StringComparison.Ordinal)) { var nonce = path["/d/".Length..]; - if (!Live.TryGetValue(nonce, out var handoff)) + if (!Live.TryGetValue(nonce, out var handoff) || handoff.Port != requestPort) { response.StatusCode = (int)HttpStatusCode.NotFound; response.Close(); @@ -120,8 +211,8 @@ private static async Task AnswerAsync(HttpListenerContext context) return; } - if (!TrySession(path, out var session, out var upstreamPath) && - !TrySessionFromReferer(context.Request.Headers["Referer"], path, out session, out upstreamPath)) + if (!TrySession(path, requestPort, out var session, out var upstreamPath) && + !TrySessionFromReferer(context.Request.Headers["Referer"], path, requestPort, out session, out upstreamPath)) { response.StatusCode = (int)HttpStatusCode.NotFound; response.Close(); @@ -149,10 +240,17 @@ private static async Task AnswerAsync(HttpListenerContext context) } } - private static bool TrySession(string path, out Handoff session, out string upstreamPath) + private static int RequestPort(HttpListenerContext context) => + context.Request.LocalEndPoint is IPEndPoint local + ? local.Port + : context.Request.Url?.Port ?? 0; + + private static bool TrySession(string path, int requestPort, out Handoff session, out string upstreamPath) { foreach (var candidate in Live.Values) { + if (candidate.Port != requestPort) + continue; var prefix = "/s/" + candidate.Nonce; if (!path.Equals(prefix, StringComparison.Ordinal) && !path.StartsWith(prefix + "/", StringComparison.Ordinal)) @@ -169,19 +267,66 @@ private static bool TrySession(string path, out Handoff session, out string upst return false; } - private static bool TrySessionFromReferer(string? referer, string requestPath, out Handoff session, out string upstreamPath) + private static bool TrySessionFromReferer( + string? referer, + string requestPath, + int requestPort, + out Handoff session, + out string upstreamPath) { session = null!; upstreamPath = requestPath; if (string.IsNullOrWhiteSpace(referer) || !Uri.TryCreate(referer, UriKind.Absolute, out var uri)) return false; - if (!TrySession(uri.AbsolutePath, out session, out _)) + if (uri.Port != requestPort) + return false; + if (!TrySession(uri.AbsolutePath, requestPort, out session, out _)) return false; if (string.IsNullOrEmpty(upstreamPath)) upstreamPath = "/"; return true; } + internal static byte[]? DecodeChunked(byte[] body) + { + var output = new MemoryStream(); + var index = 0; + while (index < body.Length) + { + var lineEnd = IndexOfCrlf(body, index); + if (lineEnd < 0) + return null; + var sizeText = Encoding.ASCII.GetString(body, index, lineEnd - index); + var extension = sizeText.IndexOf(';'); + if (extension >= 0) + sizeText = sizeText[..extension]; + if (!int.TryParse(sizeText.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var size) || size < 0) + return null; + index = lineEnd + 2; + if (size == 0) + return output.ToArray(); + if (index + size + 2 > body.Length) + return null; + output.Write(body, index, size); + if (body[index + size] != '\r' || body[index + size + 1] != '\n') + return null; + index += size + 2; + } + + return null; + } + + private static int IndexOfCrlf(byte[] body, int start) + { + for (var i = start; i + 1 < body.Length; i++) + { + if (body[i] == '\r' && body[i + 1] == '\n') + return i; + } + + return -1; + } + internal static string? ReadHeader(string headerText, string name) { foreach (var line in headerText.Split("\r\n")) @@ -275,6 +420,7 @@ public Handoff(Func> owned, string destination, string? tlsHost, int public string Nonce { get; } public string BrowserUrl { get; } + public int Port => _port; public Task Owned() => _owned(); public async Task ExpireUnusedAsync() @@ -325,11 +471,7 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) var pathAndQuery = upstreamPath + query; using var tcp = new TcpClient(); await tcp.ConnectAsync(destination.Host, destination.Port); - var clientPort = tcp.Client.LocalEndPoint is IPEndPoint local ? local.Port : 0; - var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(destination.Port, clientPort); - var listener = WindowsTcpListenerSnapshot.Capture().Listeners - .FirstOrDefault(item => item.Port == destination.Port); - if (accepted is null || listener is null || accepted != listener.ProcessId || !await _owned()) + if (!BackendIsOwned(tcp) || !await _owned()) { context.Response.StatusCode = (int)HttpStatusCode.NotFound; context.Response.Close(); @@ -374,8 +516,21 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) var payload = buffer.ToArray(); var headerEnd = FindHeaderEnd(payload); var body = headerEnd < 0 ? payload : payload[(headerEnd + 4)..]; - context.Response.StatusCode = StatusCode(payload); var headerText = Encoding.ASCII.GetString(payload, 0, headerEnd < 0 ? payload.Length : headerEnd); + if (ReadHeader(headerText, "Transfer-Encoding") is { } transfer && + transfer.Contains("chunked", StringComparison.OrdinalIgnoreCase)) + { + var decoded = DecodeChunked(body); + if (decoded is null) + { + context.Response.StatusCode = (int)HttpStatusCode.BadGateway; + context.Response.Close(); + return; + } + + body = decoded; + } + context.Response.StatusCode = StatusCode(payload); CopySecurityHeaders(headerText, context.Response); if (ReadHeader(headerText, "Location") is { } location) { @@ -408,11 +563,7 @@ public async Task ProxyWebSocketAsync(HttpListenerContext context, string upstre var query = context.Request.Url?.Query ?? ""; using var probe = new TcpClient(); await probe.ConnectAsync(destination.Host, destination.Port); - var clientPort = probe.Client.LocalEndPoint is IPEndPoint local ? local.Port : 0; - var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(destination.Port, clientPort); - var listener = WindowsTcpListenerSnapshot.Capture().Listeners - .FirstOrDefault(item => item.Port == destination.Port); - if (accepted is null || listener is null || accepted != listener.ProcessId || !await _owned()) + if (!BackendIsOwned(probe) || !await _owned()) { probe.Close(); if (browser.WebSocket.State == WebSocketState.Open) @@ -455,6 +606,18 @@ await Task.WhenAll( } } + private static bool BackendIsOwned(TcpClient tcp) + { + if (tcp.Client.LocalEndPoint is not IPEndPoint local || + tcp.Client.RemoteEndPoint is not IPEndPoint remote) + return false; + var accepted = WindowsTcpListenerSnapshot.AcceptedProcessId(remote, local); + var listener = WindowsTcpListenerSnapshot.MatchListener( + WindowsTcpListenerSnapshot.Capture().Listeners, + remote); + return accepted is not null && listener is not null && accepted == listener.ProcessId; + } + private static async Task ReadUpgradeAcceptedAsync(Stream stream) { using var header = new MemoryStream(); diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index 36f8569c8..b084165b3 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -1,5 +1,7 @@ using System.Net; using System.Net.Http; +using System.Net.Sockets; +using System.Text; using OpenClaw.Connection; using OpenClaw.SetupEngine; using OpenClawTray.Services; @@ -217,6 +219,9 @@ public void IssuedBinding_RejectsSwitchToAnotherGateway() [Fact] public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); const string destination = "https://localhost:45678/mount/?view=compact#section&token=secret"; var open = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination); Assert.DoesNotContain("token=", open, StringComparison.OrdinalIgnoreCase); @@ -241,19 +246,143 @@ public async Task DashboardHandoff_HidesCredentialUntilOwnershipHoldsOnce() Assert.Equal( 42, WindowsTcpListenerSnapshot.MatchAcceptedProcess( - [(45678, 51000, 42), (51000, 45678, 7)], - serverPort: 45678, - clientPort: 51000)); + [ + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 45678, IPAddress.Loopback, 51000, 42), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 51000, IPAddress.Loopback, 45678, 7), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.IPv6Loopback, 45678, IPAddress.IPv6Loopback, 51000, 9), + ], + new IPEndPoint(IPAddress.Loopback, 45678), + new IPEndPoint(IPAddress.Loopback, 51000))); + Assert.Equal( + 9, + WindowsTcpListenerSnapshot.MatchAcceptedProcess( + [ + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.Loopback, 45678, IPAddress.Loopback, 51000, 42), + new WindowsTcpListenerSnapshot.AcceptedTcpEndpoint(IPAddress.IPv6Loopback, 45678, IPAddress.IPv6Loopback, 51000, 9), + ], + new IPEndPoint(IPAddress.IPv6Loopback, 45678), + new IPEndPoint(IPAddress.IPv6Loopback, 51000))); + Assert.Equal( + 9, + WindowsTcpListenerSnapshot.MatchListener( + [ + new WindowsTcpListenerInfo(IPAddress.Loopback, 45678, 42, null, null), + new WindowsTcpListenerInfo(IPAddress.IPv6Loopback, 45678, 9, null, null), + ], + new IPEndPoint(IPAddress.IPv6Loopback, 45678))?.ProcessId); + Assert.Equal( + "hello", + Encoding.ASCII.GetString(DashboardCredentialHandoff.DecodeChunked( + Encoding.ASCII.GetBytes("5\r\nhello\r\n0\r\n\r\n"))!)); var denied = await http.GetAsync(open); Assert.Equal(HttpStatusCode.NotFound, denied.StatusCode); Assert.DoesNotContain("secret", await denied.Content.ReadAsStringAsync(), StringComparison.Ordinal); - var closed = DashboardCredentialHandoff.Start(() => Task.FromResult(false), destination); + var closed = DashboardCredentialHandoff.Start(() => Task.FromResult(false), destination, originKey: "closed-gateway"); var blocked = await http.GetAsync(closed); Assert.Equal(HttpStatusCode.NotFound, blocked.StatusCode); Assert.DoesNotContain("secret", await blocked.Content.ReadAsStringAsync(), StringComparison.Ordinal); } + [Fact] + public async Task DashboardHandoff_RejectsAnotherGatewaysNonceOnThisOrigin() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://localhost:45678/mount/?view=compact#token=secret"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-b"); + var firstUri = new Uri(first); + var secondUri = new Uri(second); + Assert.NotEqual(firstUri.Port, secondUri.Port); + var nonceB = secondUri.AbsolutePath["/d/".Length..]; + using var http = new HttpClient(); + var cross = await http.GetAsync($"http://127.0.0.1:{firstUri.Port}/s/{nonceB}/"); + Assert.Equal(HttpStatusCode.NotFound, cross.StatusCode); + Assert.DoesNotContain("secret", await cross.Content.ReadAsStringAsync(), StringComparison.Ordinal); + var referred = new HttpRequestMessage(HttpMethod.Get, $"http://127.0.0.1:{firstUri.Port}/assets/app.js"); + referred.Headers.Referrer = new Uri($"http://127.0.0.1:{secondUri.Port}/s/{nonceB}/"); + var referredResponse = await http.SendAsync(referred); + Assert.Equal(HttpStatusCode.NotFound, referredResponse.StatusCode); + } + + [Fact] + public void DashboardHandoff_ReusesSavedOriginAfterRestart() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://localhost:45678/mount/"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var port = new Uri(first).Port; + DashboardCredentialHandoff.ResetOriginsForTests(); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + Assert.Equal(port, new Uri(second).Port); + Assert.Equal(port, new Uri(DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a")).Port); + } + + [Fact] + public async Task AcceptedProcessId_MatchesTheIpv6LoopbackConnection() + { + using var listener = new TcpListener(IPAddress.IPv6Loopback, 0); + listener.Start(); + var server = (IPEndPoint)listener.LocalEndpoint; + using var client = new TcpClient(AddressFamily.InterNetworkV6); + await client.ConnectAsync(server); + var local = (IPEndPoint)client.Client.LocalEndPoint!; + using var accepted = await listener.AcceptTcpClientAsync(); + Assert.Equal(Environment.ProcessId, WindowsTcpListenerSnapshot.AcceptedProcessId(server, local)); + var match = WindowsTcpListenerSnapshot.MatchListener(WindowsTcpListenerSnapshot.Capture().Listeners, server); + Assert.Equal(Environment.ProcessId, match?.ProcessId); + } + + [Fact] + public async Task DashboardHandoff_DecodesChunkedUpstreamBody() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var upstreamPort = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + var buffer = new byte[2048]; + var read = 0; + while (read < 4 || !Encoding.ASCII.GetString(buffer, 0, read).Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var count = await stream.ReadAsync(buffer.AsMemory(read)); + if (count == 0) + break; + read += count; + } + var response = + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + "b\r\n{\"ok\":true}\r\n0\r\n\r\n"; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response)); + }); + + var open = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + $"http://127.0.0.1:{upstreamPort}/item", + originKey: "chunk-gateway"); + using var http = new HttpClient(); + var page = await http.GetStringAsync(open); + var marker = "location.replace("; + var start = page.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var jsonStart = start + marker.Length; + var jsonEnd = page.IndexOf(")", jsonStart, StringComparison.Ordinal); + var sessionUrl = System.Text.Json.JsonSerializer.Deserialize(page[jsonStart..jsonEnd]); + Assert.NotNull(sessionUrl); + var body = await http.GetStringAsync(sessionUrl); + Assert.Equal("{\"ok\":true}", body); + Assert.DoesNotContain("\r\n", body, StringComparison.Ordinal); + } + [Fact] public async Task OpenPrepared_RefusesBrowserWhenOwnershipRecheckFails() { From 77fe88df9eb0222674447530dad4460e39d38b01 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 8 Oct 2026 18:44:03 -0700 Subject: [PATCH 19/20] chore(ci): retrigger UI host crash on dashboard origin fix The UI job aborted after 448 passed tests. The host died with 0xC0000005 in Marshal.Release during a COM finalizer. The named test was GatewayFixtureUiTests.FixtureGuardAndActualSetupResolverUseTheSameEffectiveLocalDirectory, and the log says that test may not be the source. No assertion failed. This is not the earlier markdown renderer host abort. Signed-off-by: Sebastien Tardif From c50f40648ee0a6460813d69ffb1687b8cbd6c525 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 13 Oct 2026 17:33:29 -0700 Subject: [PATCH 20/20] fix(connection): keep a dashboard origin with its gateway A saved gateway id no longer keeps the previous destination's browser origin. The one-use launch nonce stays off gatewayUrl, the Control UI base path carries the saved session, and upstream Set-Cookie headers reach the loopback page. Tests cover a retarget with the same local forward, a legacy origin file, a stable gatewayUrl, the declared base path, and proxied cookies. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 3 +- .../Pages/ConnectionPage.xaml.cs | 3 +- .../Services/DashboardCredentialHandoff.cs | 324 +++++++++++++++--- .../Services/GatewayDashboardLauncher.cs | 5 +- .../SetupDashboardHandoffTests.cs | 180 ++++++++++ 6 files changed, 470 insertions(+), 47 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 853f49371..d59be89cf 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -313,7 +313,7 @@ private void WireAppCapabilityHandlers() out _, out _, ownedNow); - }, url, tlsHost, pinned.Id); + }, url, tlsHost, pinned.Id, DashboardCredentialHandoff.DestinationIdentity(pinned)); return new { url = publicUrl, diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 46869cde8..e2e37dd1b 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4222,7 +4222,8 @@ private Task LaunchPreparedDashboardAsync( var tlsHost = issued is not null && Uri.TryCreate(issued.Url, UriKind.Absolute, out var issuedUri) ? issuedUri.Host : null; - return launcher.OpenPreparedAsync(url, confirm, tlsHost, issued?.Id); + var identity = issued is null ? null : DashboardCredentialHandoff.DestinationIdentity(issued); + return launcher.OpenPreparedAsync(url, confirm, tlsHost, issued?.Id, identity); } private async Task ConfirmIssuedDashboardListenerAsync( diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 4c260fe83..c93e20236 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2665,7 +2665,8 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) () => SavedDashboardListenerStillOwnedAsync(pinned, pinned.SshTunnel), url, Uri.TryCreate(pinned.Url, UriKind.Absolute, out var savedUri) ? savedUri.Host : null, - pinned.Id); + pinned.Id, + DashboardCredentialHandoff.DestinationIdentity(pinned)); await global::Windows.System.Launcher.LaunchUriAsync(new Uri(launchUrl)); } catch (Exception ex) diff --git a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs index eed15f691..cbe4a9d03 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DashboardCredentialHandoff.cs @@ -29,31 +29,39 @@ internal static class DashboardCredentialHandoff public const int Port = 47831; private static readonly ConcurrentDictionary Live = new(); - private static readonly ConcurrentDictionary OriginPorts = new(); + private static readonly ConcurrentDictionary OriginPorts = new(); private static readonly ConcurrentDictionary Listeners = new(); private static readonly object ListenerGate = new(); + private static readonly JsonSerializerOptions StoreJson = new() + { + PropertyNameCaseInsensitive = true, + }; public static string Start( Func> owned, string destination, string? tlsHost = null, - string? originKey = null) + string? originKey = null, + string? destinationIdentity = null) { ArgumentNullException.ThrowIfNull(owned); ArgumentException.ThrowIfNullOrWhiteSpace(destination); var key = string.IsNullOrWhiteSpace(originKey) ? "default" : originKey; - var port = PortFor(key); + var identity = string.IsNullOrWhiteSpace(destinationIdentity) + ? ProxiedOriginIdentity(destination) + : destinationIdentity; + var binding = PortFor(key, identity); try { - EnsureListener(port); + EnsureListener(binding.Port); } catch (HttpListenerException ex) { throw new InvalidOperationException( - $"Dashboard origin 127.0.0.1:{port} is already in use. Close the program using that port and open the dashboard again.", + $"Dashboard origin 127.0.0.1:{binding.Port} is already in use. Close the program using that port and open the dashboard again.", ex); } - var handoff = new Handoff(owned, destination, tlsHost, port); + var handoff = new Handoff(owned, destination, tlsHost, binding.Port, binding.Session); Live[handoff.Nonce] = handoff; _ = handoff.ExpireUnusedAsync(); return handoff.BrowserUrl; @@ -66,36 +74,80 @@ public static string Start( internal static void ResetOriginsForTests() => OriginPorts.Clear(); - private static int PortFor(string key) + /// + /// Saved gateway identity, without the token. A retargeted record keeps its id + /// and must not keep the previous gateway's browser origin. + /// + internal static string DestinationIdentity(GatewayRecord record) + { + ArgumentNullException.ThrowIfNull(record); + var saved = string.IsNullOrWhiteSpace(record.Url) ? "" : SavedUrlIdentity(record.Url); + if (record.SshTunnel is not { } tunnel) + return saved; + return saved + "|" + tunnel.User + "@" + tunnel.Host + ":" + tunnel.SshPort + + ">" + tunnel.LocalPort + ":" + tunnel.RemotePort; + } + + private static OriginBinding PortFor(string key, string destinationIdentity) { - if (OriginPorts.TryGetValue(key, out var cached)) + var cacheKey = key + "\n" + destinationIdentity; + if (OriginPorts.TryGetValue(cacheKey, out var cached)) return cached; lock (ListenerGate) { - if (OriginPorts.TryGetValue(key, out cached)) + if (OriginPorts.TryGetValue(cacheKey, out cached)) return cached; var saved = ReadStore(); - if (!saved.TryGetValue(key, out var port)) + var match = saved.FirstOrDefault(entry => + entry.Key == key && entry.Destination == destinationIdentity); + if (match is null) + { + // An older file stored only the gateway id. Adopt that port for the + // first destination seen, then leave it so a later destination + // cannot inherit the same browser profile. + var legacy = saved.FirstOrDefault(entry => + entry.Key == key && entry.Destination.Length == 0); + if (legacy is not null) + { + legacy.Destination = destinationIdentity; + if (string.IsNullOrEmpty(legacy.Session)) + legacy.Session = NewSessionId(); + match = legacy; + WriteStore(saved); + } + } + + if (match is null) + { + var port = key == "default" || !PortTaken(Port, saved) ? Port : AllocatePort(saved); + match = new OriginStoreEntry + { + Key = key, + Destination = destinationIdentity, + Port = port, + Session = NewSessionId(), + }; + saved.Add(match); + WriteStore(saved); + } + else if (string.IsNullOrEmpty(match.Session)) { - // The previous handoff used 47831 for every gateway. Keep that - // origin for the first gateway so its browser profile stays put. - // Later gateways get their own port, saved for the next process. - port = key == "default" || !PortTaken(Port, saved) ? Port : AllocatePort(saved); - saved[key] = port; + match.Session = NewSessionId(); WriteStore(saved); } - OriginPorts[key] = port; - return port; + cached = new OriginBinding(match.Port, match.Session); + OriginPorts[cacheKey] = cached; + return cached; } } - private static bool PortTaken(int port, Dictionary saved) => - saved.ContainsValue(port) || OriginPorts.Values.Contains(port); + private static bool PortTaken(int port, List saved) => + saved.Exists(entry => entry.Port == port) || OriginPorts.Values.Any(item => item.Port == port); - private static int AllocatePort(Dictionary saved) + private static int AllocatePort(List saved) { for (var attempt = 0; attempt < 8; attempt++) { @@ -120,15 +172,33 @@ private static int FreePort() private static string StoreFilePath() => OriginStorePath ?? Path.Combine(AppIdentity.ResolveRoamingDataDirectory(), "dashboard-origins.json"); - private static Dictionary ReadStore() + private static List ReadStore() { try { var path = StoreFilePath(); if (!File.Exists(path)) - return new Dictionary(StringComparer.Ordinal); - var saved = JsonSerializer.Deserialize>(File.ReadAllText(path)); - return saved ?? new Dictionary(StringComparer.Ordinal); + return []; + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + throw new JsonException("Dashboard origin file is not an object."); + if (doc.RootElement.TryGetProperty("Entries", out var entries) || + doc.RootElement.TryGetProperty("entries", out entries)) + { + return JsonSerializer.Deserialize(doc.RootElement.GetRawText(), StoreJson)?.Entries ?? []; + } + + var legacy = new List(); + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.Number && + property.Value.TryGetInt32(out var port)) + { + legacy.Add(new OriginStoreEntry { Key = property.Name, Port = port }); + } + } + + return legacy; } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { @@ -136,18 +206,49 @@ private static Dictionary ReadStore() } } - private static void WriteStore(Dictionary saved) + private static void WriteStore(List saved) { var path = StoreFilePath(); var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - var json = JsonSerializer.Serialize(saved); + var json = JsonSerializer.Serialize(new OriginStoreFile { Entries = saved }, StoreJson); var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; File.WriteAllText(temp, json); File.Move(temp, path, overwrite: true); } + private static string ProxiedOriginIdentity(string destination) + { + var uri = new Uri(destination); + return uri.Scheme + "://" + uri.IdnHost + ":" + uri.Port; + } + + private static string SavedUrlIdentity(string savedUrl) + { + if (!Uri.TryCreate(savedUrl, UriKind.Absolute, out var saved)) + return savedUrl; + var path = string.IsNullOrEmpty(saved.AbsolutePath) ? "/" : saved.AbsolutePath; + return saved.Scheme + "://" + saved.IdnHost + ":" + saved.Port + path; + } + + private static string NewSessionId() => Convert.ToHexString(Guid.NewGuid().ToByteArray()); + + private sealed record OriginBinding(int Port, string Session); + + private sealed class OriginStoreFile + { + public List Entries { get; set; } = []; + } + + private sealed class OriginStoreEntry + { + public string Key { get; set; } = ""; + public string Destination { get; set; } = ""; + public int Port { get; set; } + public string Session { get; set; } = ""; + } + private static void EnsureListener(int port) { lock (ListenerGate) @@ -247,24 +348,34 @@ context.Request.LocalEndPoint is IPEndPoint local private static bool TrySession(string path, int requestPort, out Handoff session, out string upstreamPath) { + Handoff? match = null; + var matchedPrefix = ""; foreach (var candidate in Live.Values) { if (candidate.Port != requestPort) continue; - var prefix = "/s/" + candidate.Nonce; + var prefix = "/s/" + candidate.SessionId; if (!path.Equals(prefix, StringComparison.Ordinal) && !path.StartsWith(prefix + "/", StringComparison.Ordinal)) continue; - session = candidate; - upstreamPath = path[prefix.Length..]; - if (string.IsNullOrEmpty(upstreamPath)) - upstreamPath = "/"; - return true; + if (match is not null && candidate.Generation < match.Generation) + continue; + match = candidate; + matchedPrefix = prefix; } - session = null!; - upstreamPath = "/"; - return false; + if (match is null) + { + session = null!; + upstreamPath = "/"; + return false; + } + + session = match; + upstreamPath = path[matchedPrefix.Length..]; + if (string.IsNullOrEmpty(upstreamPath)) + upstreamPath = "/"; + return true; } private static bool TrySessionFromReferer( @@ -357,6 +468,111 @@ internal static string RewriteRootAbsolute(string html, string sessionPrefix) System.Text.RegularExpressions.RegexOptions.IgnoreCase); } + /// + /// The Control UI requests assets from the base path the Gateway declares. + /// That path has to include this session. Referer is not a substitute: + /// the Gateway sends Referrer-Policy: no-referrer, and this proxy keeps that header. + /// + internal static string RewriteDeclaredResourceBase(string body, string sessionPrefix) + { + var prefix = sessionPrefix.TrimEnd('/'); + body = System.Text.RegularExpressions.Regex.Replace( + body, + "window\\.__OPENCLAW_CONTROL_UI_BASE_PATH__\\s*=\\s*(\"[^\"]*\"|'[^']*')", + match => "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=" + + JsonSerializer.Serialize(CombineResourceBase(prefix, Unquote(match.Groups[1].Value)))); + body = System.Text.RegularExpressions.Regex.Replace( + body, + " " "\"basePath\":" + JsonSerializer.Serialize(CombineResourceBase(prefix, match.Groups[1].Value))); + } + + internal static string AdaptSetCookie(string setCookie, string sessionPrefix) + { + var parts = setCookie.Split(';'); + var kept = new List { parts[0].Trim() }; + var sawPath = false; + foreach (var raw in parts.Skip(1)) + { + var part = raw.Trim(); + if (part.Length == 0) + continue; + var name = part.Split('=', 2)[0].Trim(); + if (name.Equals("Domain", StringComparison.OrdinalIgnoreCase) || + name.Equals("Secure", StringComparison.OrdinalIgnoreCase)) + continue; + if (name.Equals("SameSite", StringComparison.OrdinalIgnoreCase) && + part.Contains("None", StringComparison.OrdinalIgnoreCase)) + { + kept.Add("SameSite=Lax"); + continue; + } + if (name.Equals("Path", StringComparison.OrdinalIgnoreCase)) + { + sawPath = true; + var value = part.Split('=', 2).Length == 2 ? part.Split('=', 2)[1].Trim() : "/"; + kept.Add("Path=" + CookiePath(value, sessionPrefix)); + continue; + } + + kept.Add(part); + } + + if (!sawPath) + kept.Add("Path=" + sessionPrefix.TrimEnd('/')); + return string.Join("; ", kept); + } + + private static string Unquote(string literal) + { + if (literal.Length >= 2 && (literal[0] == '"' || literal[0] == '\'')) + return literal[1..^1]; + return literal; + } + + private static string CombineResourceBase(string sessionPrefix, string declared) + { + var prefix = sessionPrefix.TrimEnd('/'); + if (string.IsNullOrEmpty(declared) || declared == "/" || declared == "./") + return prefix + "/"; + if (declared.StartsWith(prefix + "/", StringComparison.Ordinal) || + declared.Equals(prefix, StringComparison.Ordinal)) + return declared.EndsWith('/') ? declared : declared + "/"; + var path = declared.StartsWith('/') ? declared : "/" + declared; + var combined = prefix + path.TrimEnd('/'); + return combined + "/"; + } + + private static string CookiePath(string path, string sessionPrefix) + { + var prefix = sessionPrefix.TrimEnd('/'); + if (string.IsNullOrEmpty(path) || path == "/") + return prefix; + if (path.StartsWith(prefix + "/", StringComparison.Ordinal) || path.Equals(prefix, StringComparison.Ordinal)) + return path; + if (!path.StartsWith('/')) + path = "/" + path; + return prefix + path; + } + + private static List ReadSetCookies(string headerText) + { + var cookies = new List(); + foreach (var line in headerText.Split("\r\n")) + { + const string name = "Set-Cookie:"; + if (line.StartsWith(name, StringComparison.OrdinalIgnoreCase)) + cookies.Add(line[name.Length..].Trim()); + } + + return cookies; + } + internal static void CopySecurityHeaders(string headerText, HttpListenerResponse response) { string[] names = @@ -407,18 +623,23 @@ private sealed class Handoff private readonly string _tlsHost; private readonly int _port; private int _delivered; + private static int _nextGeneration; - public Handoff(Func> owned, string destination, string? tlsHost, int port) + public Handoff(Func> owned, string destination, string? tlsHost, int port, string sessionId) { _owned = owned; _destination = destination; _tlsHost = string.IsNullOrWhiteSpace(tlsHost) ? new Uri(destination).Host : tlsHost; _port = port; + SessionId = sessionId; + Generation = Interlocked.Increment(ref _nextGeneration); Nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray()); BrowserUrl = $"http://127.0.0.1:{port}/d/{Nonce}"; } public string Nonce { get; } + public string SessionId { get; } + public int Generation { get; } public string BrowserUrl { get; } public int Port => _port; public Task Owned() => _owned(); @@ -456,12 +677,15 @@ private string SameOriginDestination() { var destination = new Uri(_destination); var hash = destination.Fragment; - var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{_port}/s/{Nonce}/"); + // The launch nonce is only the one-use /d/ page. The Control UI stores + // settings under gatewayUrl, including its path, so that path stays + // on the saved session for this destination. + var gateway = Uri.EscapeDataString($"ws://127.0.0.1:{_port}/s/{SessionId}/"); hash = string.IsNullOrEmpty(hash) ? "#gatewayUrl=" + gateway : hash + "&gatewayUrl=" + gateway; var path = string.IsNullOrEmpty(destination.AbsolutePath) ? "/" : destination.AbsolutePath; - return $"http://127.0.0.1:{_port}/s/{Nonce}{path}{destination.Query}{hash}"; + return $"http://127.0.0.1:{_port}/s/{SessionId}{path}{destination.Query}{hash}"; } public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) @@ -532,11 +756,22 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) } context.Response.StatusCode = StatusCode(payload); CopySecurityHeaders(headerText, context.Response); + var sessionPrefix = "/s/" + SessionId; + foreach (var cookie in ReadSetCookies(headerText)) + { + try + { + context.Response.AppendHeader("Set-Cookie", AdaptSetCookie(cookie, sessionPrefix)); + } + catch (ArgumentException) + { + } + } if (ReadHeader(headerText, "Location") is { } location) { try { - context.Response.Headers["Location"] = RewriteLocation(location, "/s/" + Nonce); + context.Response.Headers["Location"] = RewriteLocation(location, sessionPrefix); } catch (ArgumentException) { @@ -545,10 +780,15 @@ public async Task ProxyAsync(HttpListenerContext context, string upstreamPath) if (ReadContentType(headerText) is { } contentType) { context.Response.ContentType = contentType; - if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + var textBody = contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("json", StringComparison.OrdinalIgnoreCase); + if (textBody) { - var html = Encoding.UTF8.GetString(body); - body = Encoding.UTF8.GetBytes(RewriteRootAbsolute(html, "/s/" + Nonce)); + var text = Encoding.UTF8.GetString(body); + if (contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase)) + text = RewriteRootAbsolute(text, sessionPrefix); + text = RewriteDeclaredResourceBase(text, sessionPrefix); + body = Encoding.UTF8.GetBytes(text); } } context.Response.ContentLength64 = body.Length; diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs index a96ade0a7..b0344856b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDashboardLauncher.cs @@ -41,7 +41,8 @@ public async Task OpenPreparedAsync( string url, Func>? confirmReady = null, string? tlsHost = null, - string? originKey = null) + string? originKey = null, + string? destinationIdentity = null) { // The ownership check that built this URL can be stale by the time the // browser starts. Refuse the launch when that check no longer holds. @@ -52,7 +53,7 @@ public async Task OpenPreparedAsync( // is released only when that page is requested and ownership still holds. var launchUrl = confirmReady is null ? url - : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost, originKey); + : DashboardCredentialHandoff.Start(confirmReady, url, tlsHost, originKey, destinationIdentity); return await OpenUrlAsync(launchUrl); } diff --git a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs index b084165b3..15fe07299 100644 --- a/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs +++ b/tests/OpenClaw.Tray.Tests/SetupDashboardHandoffTests.cs @@ -322,6 +322,186 @@ public void DashboardHandoff_ReusesSavedOriginAfterRestart() Assert.Equal(port, new Uri(DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a")).Port); } + [Fact] + public void DashboardHandoff_RetargetedSavedGatewayGetsANewOrigin() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + var tunnel = new SshTunnelConfig("root", "gateway.example", 18789, 45678, false, 22); + var firstRecord = new GatewayRecord + { + Id = "gateway-a", + Url = "wss://gateway-a.example/mount/", + SshTunnel = tunnel, + }; + var retargeted = firstRecord with { Url = "wss://gateway-b.example/mount/" }; + const string proxied = "http://127.0.0.1:45678/mount/"; + var first = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: firstRecord.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(firstRecord)); + var second = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: retargeted.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(retargeted)); + Assert.NotEqual(new Uri(first).Port, new Uri(second).Port); + + DashboardCredentialHandoff.ResetOriginsForTests(); + var restored = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + proxied, + originKey: firstRecord.Id, + destinationIdentity: DashboardCredentialHandoff.DestinationIdentity(firstRecord)); + Assert.Equal(new Uri(first).Port, new Uri(restored).Port); + } + + [Fact] + public void DashboardHandoff_LegacyOriginFileDoesNotFollowARetarget() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + var path = origins.Combine("origins.json"); + DashboardCredentialHandoff.OriginStorePath = path; + DashboardCredentialHandoff.ResetOriginsForTests(); + File.WriteAllText(path, "{\"gateway-a\":" + DashboardCredentialHandoff.Port + "}"); + var kept = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + "https://gateway-a.example/mount/", + originKey: "gateway-a"); + Assert.Equal(DashboardCredentialHandoff.Port, new Uri(kept).Port); + var moved = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + "https://gateway-b.example/mount/", + originKey: "gateway-a"); + Assert.NotEqual(DashboardCredentialHandoff.Port, new Uri(moved).Port); + } + + [Fact] + public async Task DashboardHandoff_GatewayUrlStaysOnTheSavedSession() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + const string destination = "https://gateway-a.example/mount/#section"; + var first = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + var second = DashboardCredentialHandoff.Start(() => Task.FromResult(true), destination, originKey: "gateway-a"); + using var http = new HttpClient(); + var firstUrl = GatewayUrlFrom(await http.GetStringAsync(first)); + var secondUrl = GatewayUrlFrom(await http.GetStringAsync(second)); + Assert.Equal(firstUrl, secondUrl); + var launchNonce = new Uri(first).AbsolutePath["/d/".Length..]; + Assert.DoesNotContain(launchNonce, firstUrl, StringComparison.Ordinal); + Assert.Contains("/s/", firstUrl, StringComparison.Ordinal); + } + + [Fact] + public void DashboardHandoff_ResourceBaseAndCookieFollowTheSession() + { + const string session = "/s/STABLE"; + var html = DashboardCredentialHandoff.RewriteDeclaredResourceBase( + "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/\";", + session); + Assert.Contains("window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/s/STABLE/\"", html, StringComparison.Ordinal); + Assert.Contains("", html, StringComparison.Ordinal); + Assert.Equal( + "{\"basePath\":\"/s/STABLE/openclaw/\",\"ok\":true}", + DashboardCredentialHandoff.RewriteDeclaredResourceBase("{\"basePath\":\"/openclaw\",\"ok\":true}", session)); + Assert.Equal( + "{\"ok\":true}", + DashboardCredentialHandoff.RewriteDeclaredResourceBase("{\"ok\":true}", session)); + var cookie = DashboardCredentialHandoff.AdaptSetCookie( + "sid=abc; Path=/; Domain=gateway.example; Secure; HttpOnly; SameSite=None", + session); + Assert.Contains("sid=abc", cookie, StringComparison.Ordinal); + Assert.Contains("Path=/s/STABLE", cookie, StringComparison.Ordinal); + Assert.Contains("HttpOnly", cookie, StringComparison.Ordinal); + Assert.Contains("SameSite=Lax", cookie, StringComparison.Ordinal); + Assert.DoesNotContain("Domain=", cookie, StringComparison.Ordinal); + Assert.DoesNotContain("Secure", cookie, StringComparison.Ordinal); + } + + [Fact] + public async Task DashboardHandoff_ProxiesSessionAssetsWithoutRefererAndKeepsCookies() + { + using var origins = new TempDirectory("ocwn-1484-origins-"); + DashboardCredentialHandoff.OriginStorePath = origins.Combine("origins.json"); + DashboardCredentialHandoff.ResetOriginsForTests(); + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var upstreamPort = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + for (var request = 0; request < 2; request++) + { + using var client = await listener.AcceptTcpClientAsync(); + await using var stream = client.GetStream(); + var buffer = new byte[4096]; + var read = 0; + while (read < 4 || !Encoding.ASCII.GetString(buffer, 0, read).Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var count = await stream.ReadAsync(buffer.AsMemory(read)); + if (count == 0) + break; + read += count; + } + + var head = Encoding.ASCII.GetString(buffer, 0, read); + var htmlBody = "window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/\";"; + var jsonBody = "{\"basePath\":\"/\"}"; + var response = head.Contains("GET /__openclaw/control-ui-config.json", StringComparison.Ordinal) + ? "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + jsonBody.Length + + "\r\nConnection: close\r\n\r\n" + jsonBody + : "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: sid=abc; Path=/; Domain=gateway.example; Secure\r\nSet-Cookie: other=1; Path=/plugin; HttpOnly\r\nContent-Length: " + + htmlBody.Length + "\r\nConnection: close\r\n\r\n" + htmlBody; + await stream.WriteAsync(Encoding.ASCII.GetBytes(response)); + } + }); + + var open = DashboardCredentialHandoff.Start( + () => Task.FromResult(true), + $"http://127.0.0.1:{upstreamPort}/mount/", + originKey: "asset-gateway"); + using var handler = new HttpClientHandler { UseCookies = false }; + using var http = new HttpClient(handler); + var handoff = await http.GetStringAsync(open); + var marker = "location.replace("; + var start = handoff.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var jsonStart = start + marker.Length; + var jsonEnd = handoff.IndexOf(")", jsonStart, StringComparison.Ordinal); + var sessionUrl = System.Text.Json.JsonSerializer.Deserialize(handoff[jsonStart..jsonEnd]); + Assert.NotNull(sessionUrl); + var page = await http.GetAsync(sessionUrl); + var html = await page.Content.ReadAsStringAsync(); + Assert.Contains("window.__OPENCLAW_CONTROL_UI_BASE_PATH__=\"/s/", html, StringComparison.Ordinal); + Assert.True(page.Headers.TryGetValues("Set-Cookie", out var cookies)); + var cookieText = string.Join("\n", cookies); + Assert.Contains("sid=abc", cookieText, StringComparison.Ordinal); + Assert.Contains("other=1", cookieText, StringComparison.Ordinal); + Assert.DoesNotContain("Domain=", cookieText, StringComparison.Ordinal); + var session = new Uri(sessionUrl); + var slash = session.AbsolutePath.IndexOf('/', 3); + var sessionRoot = slash < 0 ? session.AbsolutePath : session.AbsolutePath[..slash]; + var asset = new Uri($"http://127.0.0.1:{session.Port}{sessionRoot}/__openclaw/control-ui-config.json"); + var config = await http.GetStringAsync(asset); + Assert.Contains("\"basePath\":\"/s/", config, StringComparison.Ordinal); + Assert.DoesNotContain("\"basePath\":\"/\"", config, StringComparison.Ordinal); + } + + private static string GatewayUrlFrom(string page) + { + const string marker = "gatewayUrl="; + var start = page.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0); + var value = page[(start + marker.Length)..]; + var end = value.IndexOfAny(['&', '"', '<']); + if (end >= 0) + value = value[..end]; + return Uri.UnescapeDataString(value); + } + [Fact] public async Task AcceptedProcessId_MatchesTheIpv6LoopbackConnection() {