From a91c30930147c6772d3beee9a48f1792aef2f7bb Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 18:40:25 -0700 Subject: [PATCH 1/7] fix(dashboard): put the route on the path and the token in the fragment - Drop userinfo from the opened dashboard URL - Stop appending the shared token inside an existing fragment - Keep the route off the query string Signed-off-by: Sebastien Tardif --- .../Helpers/GatewayDashboardUrlBuilder.cs | 76 ++++++++++++++++--- .../GatewayDashboardUrlBuilderTests.cs | 26 +++++++ 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 60391616a..9e9e8db04 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -10,21 +10,77 @@ public static string Build( { ArgumentException.ThrowIfNullOrWhiteSpace(gatewayUrl); - var baseUrl = gatewayUrl - .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) - .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase) - .TrimEnd('/'); + if (!Uri.TryCreate(gatewayUrl.Trim(), UriKind.Absolute, out var uri)) + throw new ArgumentException("Gateway URL must be absolute.", nameof(gatewayUrl)); - var url = string.IsNullOrWhiteSpace(path) - ? baseUrl - : $"{baseUrl}/{path.TrimStart('/')}"; + var scheme = ToHttpScheme(uri.Scheme); + var url = $"{scheme}://{FormatHost(uri)}{FormatPort(scheme, uri.Port)}{JoinPath(uri.AbsolutePath, path)}{WithoutTokenQuery(uri.Query)}"; if (appendSharedGatewayToken && !string.IsNullOrEmpty(sharedGatewayToken)) + url += $"#token={Uri.EscapeDataString(sharedGatewayToken)}"; + + return url; + } + + private static string ToHttpScheme(string scheme) + { + if (scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) { - var separator = url.Contains('#') ? "&" : "#"; - url = $"{url}{separator}token={Uri.EscapeDataString(sharedGatewayToken)}"; + return "https"; } - return url; + return "http"; + } + + private static string FormatHost(Uri uri) + { + if (uri.HostNameType == UriHostNameType.IPv6) + return $"[{uri.IdnHost}]"; + + return uri.IdnHost; + } + + private static string FormatPort(string scheme, int port) + { + if (port <= 0) + return string.Empty; + + if ((scheme == "http" && port == 80) || (scheme == "https" && port == 443)) + return string.Empty; + + return $":{port}"; + } + + private static string JoinPath(string absolutePath, string? route) + { + var path = string.IsNullOrEmpty(absolutePath) ? "/" : absolutePath; + if (!string.IsNullOrWhiteSpace(route)) + path = $"{path.TrimEnd('/')}/{route.Trim().TrimStart('/')}"; + + if (path.Length > 1) + path = path.TrimEnd('/'); + + return path == "/" ? string.Empty : path; + } + + private static string WithoutTokenQuery(string query) + { + if (string.IsNullOrEmpty(query) || query == "?") + return string.Empty; + + var body = query[0] == '?' ? query[1..] : query; + var kept = new List(); + foreach (var part in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var nameEnd = part.IndexOf('='); + var name = nameEnd >= 0 ? part[..nameEnd] : part; + if (name.Equals("token", StringComparison.OrdinalIgnoreCase)) + continue; + + kept.Add(part); + } + + return kept.Count == 0 ? string.Empty : "?" + string.Join('&', kept); } } diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index 367c8c2ff..422fc4bf0 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -27,4 +27,30 @@ public void Build_DoesNotAppendNonSharedToken() Assert.Equal("http://localhost:4317/config", url); } + + [Fact] + public void Build_PutsRouteOnPathAndTokenInFragment() + { + var url = GatewayDashboardUrlBuilder.Build( + "ws://user:secret@host:18789/ui?x=1#old", + "config", + "tok", + appendSharedGatewayToken: true); + + Assert.Equal("http://host:18789/ui/config?x=1#token=tok", url); + Assert.DoesNotContain("user:secret", url); + } + + [Fact] + public void Build_ReplacesExistingTokenFragment() + { + var url = GatewayDashboardUrlBuilder.Build( + "ws://localhost:4317#token=old", + null, + "tok", + appendSharedGatewayToken: true); + + Assert.Equal("http://localhost:4317#token=tok", url); + Assert.DoesNotContain("&token=", url); + } } From fa97a095b4150998086aace5e5c0f1bee753c9ec Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 19:08:51 -0700 Subject: [PATCH 2/7] ci: retrigger after setup E2E gateway restart flake Signed-off-by: Sebastien Tardif From 06f243f3ab26028f94c8963d274c4938ff6526b1 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 13:08:44 +0200 Subject: [PATCH 3/7] fix(dashboard): compose route and gateway queries without leaking tokens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c998b504-ca22-49c5-8712-15da55226686 --- .../Helpers/GatewayDashboardUrlBuilder.cs | 17 +++++++++++-- .../GatewayDashboardUrlBuilderTests.cs | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 9e9e8db04..f89294a9d 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -13,8 +13,21 @@ public static string Build( if (!Uri.TryCreate(gatewayUrl.Trim(), UriKind.Absolute, out var uri)) throw new ArgumentException("Gateway URL must be absolute.", nameof(gatewayUrl)); + var route = path?.Trim() ?? string.Empty; + var fragmentStart = route.IndexOf('#'); + if (fragmentStart >= 0) + route = route[..fragmentStart]; + + var queryStart = route.IndexOf('?'); + var routePath = queryStart < 0 ? route : route[..queryStart]; + var routeQuery = WithoutTokenQuery(queryStart < 0 ? string.Empty : route[queryStart..]); + var baseQuery = WithoutTokenQuery(uri.Query); + var query = routeQuery + (baseQuery.Length == 0 + ? string.Empty + : routeQuery.Length == 0 ? baseQuery : "&" + baseQuery[1..]); + var scheme = ToHttpScheme(uri.Scheme); - var url = $"{scheme}://{FormatHost(uri)}{FormatPort(scheme, uri.Port)}{JoinPath(uri.AbsolutePath, path)}{WithoutTokenQuery(uri.Query)}"; + var url = $"{scheme}://{FormatHost(uri)}{FormatPort(scheme, uri.Port)}{JoinPath(uri.AbsolutePath, routePath)}{query}"; if (appendSharedGatewayToken && !string.IsNullOrEmpty(sharedGatewayToken)) url += $"#token={Uri.EscapeDataString(sharedGatewayToken)}"; @@ -75,7 +88,7 @@ private static string WithoutTokenQuery(string query) { var nameEnd = part.IndexOf('='); var name = nameEnd >= 0 ? part[..nameEnd] : part; - if (name.Equals("token", StringComparison.OrdinalIgnoreCase)) + if (Uri.UnescapeDataString(name).Equals("token", StringComparison.OrdinalIgnoreCase)) continue; kept.Add(part); diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index 422fc4bf0..f9242c6a3 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -53,4 +53,29 @@ public void Build_ReplacesExistingTokenFragment() Assert.Equal("http://localhost:4317#token=tok", url); Assert.DoesNotContain("&token=", url); } + + [Fact] + public void Build_JoinsRouteQueryBeforeGatewayQueryAndDropsOldFragments() + { + var url = GatewayDashboardUrlBuilder.Build( + "wss://gateway.example/ui?x=1&to%6ben=old#old-base", + "config?tab=one&token=old-route#old-route", + "new token", + appendSharedGatewayToken: true); + + Assert.Equal("https://gateway.example/ui/config?tab=one&x=1#token=new%20token", url); + Assert.DoesNotContain("old", url); + } + + [Fact] + public void Build_DoesNotRetainOldTokenWhenNoSharedTokenIsAppended() + { + var url = GatewayDashboardUrlBuilder.Build( + "ws://localhost:4317/ui?token=old&x=1#old-base", + "config?tab=one&to%6ben=old-route#old-route", + "device-token", + appendSharedGatewayToken: false); + + Assert.Equal("http://localhost:4317/ui/config?tab=one&x=1", url); + } } From c869ed5984989a449baafe7e6067c378a461c18c Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 14:35:49 -0700 Subject: [PATCH 4/7] fix(dashboard): reject a gateway URL whose scheme is not http A scheme-less value such as localhost:18789 parses as an absolute URI with scheme localhost, and the dashboard builder then opened the wrong host. Accept only http, https, ws, and wss. Test: GatewayDashboardUrlBuilderTests 8 passed. Signed-off-by: Sebastien Tardif --- .../Helpers/GatewayDashboardUrlBuilder.cs | 13 +++++++++++-- .../GatewayDashboardUrlBuilderTests.cs | 13 +++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index f89294a9d..a758aac2c 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -10,8 +10,11 @@ public static string Build( { ArgumentException.ThrowIfNullOrWhiteSpace(gatewayUrl); - if (!Uri.TryCreate(gatewayUrl.Trim(), UriKind.Absolute, out var uri)) - throw new ArgumentException("Gateway URL must be absolute.", nameof(gatewayUrl)); + if (!Uri.TryCreate(gatewayUrl.Trim(), UriKind.Absolute, out var uri) || + !IsDashboardScheme(uri.Scheme)) + throw new ArgumentException( + "Gateway URL must be an absolute http, https, ws, or wss URL.", + nameof(gatewayUrl)); var route = path?.Trim() ?? string.Empty; var fragmentStart = route.IndexOf('#'); @@ -35,6 +38,12 @@ public static string Build( return url; } + private static bool IsDashboardScheme(string scheme) => + scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) || + scheme.Equals("wss", StringComparison.OrdinalIgnoreCase); + private static string ToHttpScheme(string scheme) { if (scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index f9242c6a3..f0d4c728c 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -67,6 +67,19 @@ public void Build_JoinsRouteQueryBeforeGatewayQueryAndDropsOldFragments() Assert.DoesNotContain("old", url); } + [Fact] + public void Build_RejectsSchemeLessGatewayInput() + { + var error = Assert.Throws(() => + GatewayDashboardUrlBuilder.Build( + "localhost:18789/ui", + "config", + "tok", + appendSharedGatewayToken: true)); + + Assert.Equal("gatewayUrl", error.ParamName); + } + [Fact] public void Build_DoesNotRetainOldTokenWhenNoSharedTokenIsAppended() { From fa5b14269f8a6a19a530fc8cc758ead43dc04aa0 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 08:53:06 -0700 Subject: [PATCH 5/7] fix(dashboard): recover when a saved gateway URL is rejected A nonempty unsupported address still passes credential resolution, then the dashboard builder throws. Open Dashboard, the saved-row dashboard action, and the app-capability handler now catch that rejection. The tray opens Connection settings or shows the error. The capability caller returns the error instead of throwing. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3121 passed, 0 failed. GatewayDashboardUrlBuilderTests: 9 passed. Signed-off-by: Sebastien Tardif --- .../App.CapabilityHandlers.cs | 13 ++++++----- src/OpenClaw.Tray.WinUI/App.xaml.cs | 16 +++++++++----- .../Helpers/GatewayDashboardUrlBuilder.cs | 22 +++++++++++++++++++ .../Pages/ConnectionPage.xaml.cs | 17 +++++++++----- .../GatewayDashboardUrlBuilderTests.cs | 16 ++++++++++++++ 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index fc591680b..80a097f7e 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -239,11 +239,14 @@ 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 url = GatewayDashboardUrlBuilder.Build( - gatewayUrl, - path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + if (!GatewayDashboardUrlBuilder.TryBuild( + gatewayUrl, + path, + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, + out var url, + out var urlError)) + return new { error = urlError }; return new { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 19400cb73..f21027d96 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3924,11 +3924,17 @@ private void OpenDashboard(string? path = null) return; } - var url = GatewayDashboardUrlBuilder.Build( - gatewayUrl, - path, - token, - !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken); + if (!GatewayDashboardUrlBuilder.TryBuild( + gatewayUrl, + path, + token, + !isBootstrapToken && credentialSource == CredentialResolver.SourceSharedGatewayToken, + out var url, + out var urlError)) + { + ShowConnectionSettingsForPairingIssue("Dashboard", urlError); + return; + } try { diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index a758aac2c..d782dfef6 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -38,6 +38,28 @@ public static string Build( return url; } + public static bool TryBuild( + string gatewayUrl, + string? path, + string? sharedGatewayToken, + bool appendSharedGatewayToken, + out string url, + out string error) + { + try + { + url = Build(gatewayUrl, path, sharedGatewayToken, appendSharedGatewayToken); + error = string.Empty; + return true; + } + catch (ArgumentException ex) + { + url = string.Empty; + error = ex.Message; + return false; + } + } + private static bool IsDashboardScheme(string scheme) => scheme.Equals("http", StringComparison.OrdinalIgnoreCase) || scheme.Equals("https", StringComparison.OrdinalIgnoreCase) || diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 992ece1b8..37a605a73 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -2588,11 +2588,18 @@ private async Task OnSavedRowOpenDashboardAsync(object sender) } } - var url = GatewayDashboardUrlBuilder.Build( - rec.Url, - path: null, - rec.SharedGatewayToken, - appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken)); + if (!GatewayDashboardUrlBuilder.TryBuild( + rec.Url, + path: null, + rec.SharedGatewayToken, + appendSharedGatewayToken: !string.IsNullOrWhiteSpace(rec.SharedGatewayToken), + out var url, + out var urlError)) + { + CurrentApp.ShowTransientConnectionError(urlError); + return; + } + await global::Windows.System.Launcher.LaunchUriAsync(new Uri(url)); } catch (Exception ex) diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index f0d4c728c..d68109ebc 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -80,6 +80,22 @@ public void Build_RejectsSchemeLessGatewayInput() Assert.Equal("gatewayUrl", error.ParamName); } + [Fact] + public void TryBuild_UnsupportedScheme_ReturnsFalseWithoutAUrl() + { + var ok = GatewayDashboardUrlBuilder.TryBuild( + "ftp://gateway.example", + "config", + "tok", + appendSharedGatewayToken: true, + out var url, + out var error); + + Assert.False(ok); + Assert.Equal(string.Empty, url); + Assert.Contains("http, https, ws, or wss", error, StringComparison.Ordinal); + } + [Fact] public void Build_DoesNotRetainOldTokenWhenNoSharedTokenIsAppended() { From e684d78dbcd51d0884bd0f4bcaa65c2995ac72d6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:09:27 -0700 Subject: [PATCH 6/7] fix(dashboard): keep the supported session selector in dashboard URLs A route or saved fragment session such as agent:main:review was dropped when the shared token was written into the hash. Keep the first query session, including an empty value, otherwise the first route-fragment session, ahead of the saved URL. Other fragment parameters stay removed. Signed-off-by: Sebastien Tardif --- .../Helpers/GatewayDashboardUrlBuilder.cs | 29 +++++++- .../GatewayDashboardUrlBuilderTests.cs | 71 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index d782dfef6..5672e4580 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -18,6 +18,7 @@ public static string Build( var route = path?.Trim() ?? string.Empty; var fragmentStart = route.IndexOf('#'); + var routeFragment = fragmentStart >= 0 ? route[fragmentStart..] : string.Empty; if (fragmentStart >= 0) route = route[..fragmentStart]; @@ -32,10 +33,17 @@ public static string Build( var scheme = ToHttpScheme(uri.Scheme); var url = $"{scheme}://{FormatHost(uri)}{FormatPort(scheme, uri.Port)}{JoinPath(uri.AbsolutePath, routePath)}{query}"; + // The SPA prefers the first query session, even when empty, over a fragment session. + var session = FindSessionParameter(query) is null + ? FindSessionParameter(routeFragment) ?? FindSessionParameter(uri.Fragment) + : null; + var fragment = new List(); + if (session is not null) + fragment.Add(session); if (appendSharedGatewayToken && !string.IsNullOrEmpty(sharedGatewayToken)) - url += $"#token={Uri.EscapeDataString(sharedGatewayToken)}"; + fragment.Add($"token={Uri.EscapeDataString(sharedGatewayToken)}"); - return url; + return fragment.Count == 0 ? url : $"{url}#{string.Join('&', fragment)}"; } public static bool TryBuild( @@ -66,6 +74,23 @@ private static bool IsDashboardScheme(string scheme) => scheme.Equals("ws", StringComparison.OrdinalIgnoreCase) || scheme.Equals("wss", StringComparison.OrdinalIgnoreCase); + private static string? FindSessionParameter(string parameters) + { + if (string.IsNullOrEmpty(parameters)) + return null; + + var body = parameters[0] is '?' or '#' ? parameters[1..] : parameters; + foreach (var part in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var nameEnd = part.IndexOf('='); + var name = nameEnd >= 0 ? part[..nameEnd] : part; + if (Uri.UnescapeDataString(name).Equals("session", StringComparison.Ordinal)) + return nameEnd >= 0 ? $"session{part[nameEnd..]}" : "session="; + } + + return null; + } + private static string ToHttpScheme(string scheme) { if (scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) || diff --git a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs index d68109ebc..25c3d412c 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -107,4 +107,75 @@ public void Build_DoesNotRetainOldTokenWhenNoSharedTokenIsAppended() Assert.Equal("http://localhost:4317/ui/config?tab=one&x=1", url); } + + [Theory] + [InlineData("ws://gateway.example", "chat#session=agent%3Amain%3Areview", "http://gateway.example/chat#session=agent%3Amain%3Areview")] + [InlineData("ws://gateway.example#session=agent%3Amain%3Areview", null, "http://gateway.example#session=agent%3Amain%3Areview")] + [InlineData("ws://gateway.example#session=base", "chat#ses%73ion=route%26one%2Btwo+three", "http://gateway.example/chat#session=route%26one%2Btwo+three")] + [InlineData("ws://gateway.example#session=base", "chat#session=first&session=second", "http://gateway.example/chat#session=first")] + [InlineData("ws://gateway.example#session=base", "chat#session=&session=second", "http://gateway.example/chat#session=")] + [InlineData("ws://gateway.example#session=base", "chat#session", "http://gateway.example/chat#session=")] + public void Build_PreservesOnlyTheFirstFragmentSession( + string gatewayUrl, string? path, string expected) + { + Assert.Equal(expected, GatewayDashboardUrlBuilder.Build( + gatewayUrl, path, "test-auth-token", appendSharedGatewayToken: false)); + Assert.Equal(expected + "&token=test-auth-token", GatewayDashboardUrlBuilder.Build( + gatewayUrl, path, "test-auth-token", appendSharedGatewayToken: true)); + } + + [Theory] + [InlineData("ws://gateway.example", "chat?session=query#session=fragment", "http://gateway.example/chat?session=query")] + [InlineData("ws://gateway.example?session=base-query", "chat#session=fragment", "http://gateway.example/chat?session=base-query")] + [InlineData("ws://gateway.example?session=base-query", "chat?session=route-query#session=fragment", "http://gateway.example/chat?session=route-query&session=base-query")] + [InlineData("ws://gateway.example", "chat?ses%73ion=encoded%3Aquery#session=fragment", "http://gateway.example/chat?ses%73ion=encoded%3Aquery")] + [InlineData("ws://gateway.example", "chat?session=&session=second#session=fragment", "http://gateway.example/chat?session=&session=second")] + [InlineData("ws://gateway.example", "chat?session#session=fragment", "http://gateway.example/chat?session")] + public void Build_PreservesQuerySessionPrecedenceIncludingEmptySelection( + string gatewayUrl, string path, string expected) + { + Assert.Equal(expected, GatewayDashboardUrlBuilder.Build( + gatewayUrl, path, "test-auth-token", appendSharedGatewayToken: false)); + Assert.Equal(expected + "#token=test-auth-token", GatewayDashboardUrlBuilder.Build( + gatewayUrl, path, "test-auth-token", appendSharedGatewayToken: true)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Build_SessionAllowlistDoesNotRestoreFragmentAuthOrEndpointOverrides(bool appendToken) + { + var url = GatewayDashboardUrlBuilder.Build( + "ws://gateway.example?token=old&x=1#gatewayUrl=wss%3A%2F%2Fother.example&password=old&token=old&session=base", + "chat?to%6ben=old-route#token=old-route&session=agent%3Amain%3Areview&token=duplicate&gatewayUrl=wss%3A%2F%2Fother.example&password=old-route", + "test-auth-token", + appendToken); + + Assert.Equal( + "http://gateway.example/chat?x=1#session=agent%3Amain%3Areview" + + (appendToken ? "&token=test-auth-token" : string.Empty), + url); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Build_PreservesSessionWithoutAnAvailableSharedToken(string? sharedToken) + { + Assert.Equal("http://gateway.example/chat#session=agent%3Amain%3Areview", + GatewayDashboardUrlBuilder.Build( + "ws://gateway.example", "chat#session=agent%3Amain%3Areview", + sharedToken, appendSharedGatewayToken: true)); + } + + [Theory] + [InlineData("chat#old")] + [InlineData("chat#SESSION=ignored")] + [InlineData("chat#token=old&password=old&gatewayUrl=wss%3A%2F%2Fother.example")] + public void Build_DoesNotInventMissingSessionSelection(string path) + { + Assert.Equal("http://gateway.example/chat#token=test-auth-token", + GatewayDashboardUrlBuilder.Build( + "ws://gateway.example", path, "test-auth-token", appendSharedGatewayToken: true)); + } } From 17def2bb8987cdc7fda5a5db4db6926ecc98f5be Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:13:35 -0700 Subject: [PATCH 7/7] fix(dashboard): keep view and an unreplaced fragment token Session selection stays the supported selector. A non-secret view parameter stays in the fragment, and an existing fragment token stays when no replacement token is supplied. Password, gatewayUrl, and other fragment content stay out. Signed-off-by: Sebastien Tardif --- .../Helpers/GatewayDashboardUrlBuilder.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 5672e4580..7fe8be91f 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -40,8 +40,17 @@ public static string Build( var fragment = new List(); if (session is not null) fragment.Add(session); + AppendPreservedFragmentFields(fragment, routeFragment); + AppendPreservedFragmentFields(fragment, uri.Fragment); if (appendSharedGatewayToken && !string.IsNullOrEmpty(sharedGatewayToken)) fragment.Add($"token={Uri.EscapeDataString(sharedGatewayToken)}"); + else if (appendSharedGatewayToken) + { + var existingToken = FindNamedParameter(routeFragment, "token") + ?? FindNamedParameter(uri.Fragment, "token"); + if (existingToken is not null) + fragment.Add(existingToken); + } return fragment.Count == 0 ? url : $"{url}#{string.Join('&', fragment)}"; } @@ -91,6 +100,43 @@ private static bool IsDashboardScheme(string scheme) => return null; } + private static void AppendPreservedFragmentFields(List fragment, string parameters) + { + if (string.IsNullOrEmpty(parameters)) + return; + + var body = parameters[0] is '?' or '#' ? parameters[1..] : parameters; + foreach (var part in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var nameEnd = part.IndexOf('='); + if (nameEnd < 0) + continue; + + var name = Uri.UnescapeDataString(part[..nameEnd]); + if (!name.Equals("view", StringComparison.OrdinalIgnoreCase)) + continue; + + fragment.Add(part); + } + } + + private static string? FindNamedParameter(string parameters, string parameterName) + { + if (string.IsNullOrEmpty(parameters)) + return null; + + var body = parameters[0] is '?' or '#' ? parameters[1..] : parameters; + foreach (var part in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var nameEnd = part.IndexOf('='); + var name = nameEnd >= 0 ? part[..nameEnd] : part; + if (Uri.UnescapeDataString(name).Equals(parameterName, StringComparison.OrdinalIgnoreCase)) + return part; + } + + return null; + } + private static string ToHttpScheme(string scheme) { if (scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) ||