diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index 14aa5c30c..2f1821601 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -241,11 +241,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/Helpers/GatewayDashboardUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs index 18ccde573..7fe8be91f 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayDashboardUrlBuilder.cs @@ -10,26 +10,192 @@ public static string Build( { ArgumentException.ThrowIfNullOrWhiteSpace(gatewayUrl); - var endpoint = new Uri(gatewayUrl - .Replace("ws://", "http://", StringComparison.OrdinalIgnoreCase) - .Replace("wss://", "https://", StringComparison.OrdinalIgnoreCase), UriKind.Absolute); - if (endpoint.Scheme is not ("http" or "https")) - throw new ArgumentException("Dashboard requires an HTTP endpoint.", nameof(gatewayUrl)); - - var baseUrl = endpoint.GetLeftPart(UriPartial.Path).TrimEnd('/'); - var url = string.IsNullOrWhiteSpace(path) - ? baseUrl + endpoint.Query - : $"{baseUrl}/{path.TrimStart('/')}"; - var fragment = endpoint.Fragment; + 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('#'); + var routeFragment = fragmentStart >= 0 ? route[fragmentStart..] : string.Empty; + 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, 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); + 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)}"; + } + + 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) || + 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 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) || + scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return "https"; + } + + 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 fields = fragment.TrimStart('#').Split('&', StringSplitOptions.RemoveEmptyEntries) - .Where(field => !Uri.UnescapeDataString(field.Split('=', 2)[0]) - .Equals("token", StringComparison.OrdinalIgnoreCase)); - fragment = "#" + string.Join("&", fields.Append("token=" + Uri.EscapeDataString(sharedGatewayToken))); + var nameEnd = part.IndexOf('='); + var name = nameEnd >= 0 ? part[..nameEnd] : part; + if (Uri.UnescapeDataString(name).Equals("token", StringComparison.OrdinalIgnoreCase)) + continue; + + kept.Add(part); } - return url + fragment; + return kept.Count == 0 ? string.Empty : "?" + string.Join('&', kept); } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 13376773a..b21d96042 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 3e020a50f..3a8bd127d 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDashboardUrlBuilderTests.cs @@ -44,4 +44,155 @@ 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); + } + + [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_RejectsSchemeLessGatewayInput() + { + var error = Assert.Throws(() => + GatewayDashboardUrlBuilder.Build( + "localhost:18789/ui", + "config", + "tok", + appendSharedGatewayToken: true)); + + 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() + { + 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); + } + + [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)); + } }