From 9ec7d05b03785257e82d692e65f3070de85220e6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:07:01 -0700 Subject: [PATCH 1/9] fix(setup): drop leftover gateway tokens during uninstall Signed-off-by: Sebastien Tardif --- .../TrayArtifactCleanup.cs | 12 ++++--- src/OpenClaw.Tray.WinUI/App.xaml.cs | 7 ++++ .../Services/SettingsManager.cs | 13 +++++++ .../SetupConfigTests.cs | 34 ++++++++++++++++++ .../AppRefactorContractTests.cs | 1 + .../SettingsManagerIsolationTests.cs | 36 +++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs index 05ab0b3d9..1837cf7e6 100644 --- a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs +++ b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs @@ -162,12 +162,14 @@ internal static void ResetOnboardingSettings(string appDataDir, SetupLogger logg bool changed = false; - // Reset GatewayUrl to empty - if (dict.ContainsKey("GatewayUrl")) - { - dict.Remove("GatewayUrl"); + // Drop the saved URL and legacy secrets together. A later launch + // falls back to the default local URL and must not import leftovers. + if (dict.Remove("GatewayUrl")) + changed = true; + if (dict.Remove("Token")) + changed = true; + if (dict.Remove("BootstrapToken")) changed = true; - } if (!preserveNodeSettings && dict.ContainsKey("EnableNodeMode")) { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fdc2b9bf0..942e741bb 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2127,6 +2127,13 @@ private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger return; } + // Uninstall deletes GatewayUrl. The getter then falls back to the + // default local URL, which must not inherit leftover secrets. + if (!_settings.HasPersistedGatewayUrl) + { + return; + } + var legacyIdentityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); if (!_settings.HasLegacyGatewayCredentials && !File.Exists(legacyIdentityPath)) { diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 8b3ec271f..83e4adc6f 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -46,6 +46,11 @@ public class SettingsManager public int? BrowserControlPort { get => _data.BrowserControlPort; set => _data = _data with { BrowserControlPort = value }; } public string? LegacyToken { get; private set; } public string? LegacyBootstrapToken { get; private set; } + /// + /// True when settings.json stored a non-empty GatewayUrl. The in-memory + /// default URL is not a saved gateway and must not receive legacy secrets. + /// + public bool HasPersistedGatewayUrl { get; private set; } public bool HasLegacyGatewayCredentials => !string.IsNullOrWhiteSpace(LegacyToken) || !string.IsNullOrWhiteSpace(LegacyBootstrapToken); @@ -215,6 +220,7 @@ public void Load() { LegacyToken = null; LegacyBootstrapToken = null; + HasPersistedGatewayUrl = false; _data = CreateDefaultData(); try @@ -235,6 +241,7 @@ public void Load() Logger.Warn($"Failed to load settings: {ex.Message}"); LegacyToken = null; LegacyBootstrapToken = null; + HasPersistedGatewayUrl = false; } } @@ -389,10 +396,16 @@ private void LoadLegacyGatewayCredentials(string json) { LegacyToken = null; LegacyBootstrapToken = null; + HasPersistedGatewayUrl = false; try { using var document = JsonDocument.Parse(json); + HasPersistedGatewayUrl = !string.IsNullOrWhiteSpace( + ReadLegacyString(document.RootElement, "GatewayUrl")); + if (!HasPersistedGatewayUrl) + return; + LegacyToken = ReadLegacyString(document.RootElement, "Token"); LegacyBootstrapToken = ReadLegacyString(document.RootElement, "BootstrapToken"); } diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index da79ce242..ea6969989 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -685,6 +685,40 @@ public void TrayArtifactCleanup_ResetOnboardingSettings_DisablesNodeSettings_Whe Assert.False(result.RootElement.GetProperty("AutoStart").GetBoolean()); } + [Fact] + [SupportedOSPlatform("windows")] + public void TrayArtifactCleanup_ResetOnboardingSettings_RemovesLegacyGatewayTokens() + { + var settingsPath = Path.Combine(_tempDir, "settings.json"); + File.WriteAllText( + settingsPath, + """ + {"GatewayUrl":"ws://127.0.0.1:18789","Token":"leftover-shared","BootstrapToken":"leftover-bootstrap","EnableNodeMode":true,"NotifyHealth":false} + """); + + TrayArtifactCleanup.ResetOnboardingSettings(_tempDir, new SetupLogger(filePath: null), preserveNodeSettings: false); + + using var result = JsonDocument.Parse(File.ReadAllText(settingsPath)); + Assert.False(result.RootElement.TryGetProperty("GatewayUrl", out _)); + Assert.False(result.RootElement.TryGetProperty("Token", out _)); + Assert.False(result.RootElement.TryGetProperty("BootstrapToken", out _)); + Assert.False(result.RootElement.GetProperty("EnableNodeMode").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NotifyHealth").GetBoolean()); + + File.WriteAllText( + settingsPath, + """ + {"Token":"leftover-shared","BootstrapToken":"leftover-bootstrap","NotifyHealth":false} + """); + + TrayArtifactCleanup.ResetOnboardingSettings(_tempDir, new SetupLogger(filePath: null), preserveNodeSettings: true); + + using var leftover = JsonDocument.Parse(File.ReadAllText(settingsPath)); + Assert.False(leftover.RootElement.TryGetProperty("Token", out _)); + Assert.False(leftover.RootElement.TryGetProperty("BootstrapToken", out _)); + Assert.False(leftover.RootElement.GetProperty("NotifyHealth").GetBoolean()); + } + [Fact] public void WslConfig_Defaults() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 7d72af69c..abb6bd35d 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -427,6 +427,7 @@ public void LegacyCredentialMigration_StaysRegistryBacked() var method = ExtractMethod(source, "TryMigrateLegacyGatewaySettings"); Assert.Contains("_gatewayRegistry.MigrateFromSettings", method); + Assert.Contains("!_settings.HasPersistedGatewayUrl", method); Assert.Contains("_settings.LegacyToken", method); Assert.Contains("_settings.LegacyBootstrapToken", method); Assert.Contains("SettingsManager.SettingsDirectoryPath", method); diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index af1ab8661..a55a19ffc 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -131,4 +131,40 @@ public void SaveOrThrow_PropagatesPersistenceFailureWhileSaveRemainsBestEffort() } } } + + [Fact] + public void MissingGatewayUrl_DoesNotExposeLegacyTokensForDefaultUrl() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "Token": "leftover-shared-token", + "BootstrapToken": "leftover-bootstrap-token", + "EnableNodeMode": true + } + """); + + var settings = new SettingsManager(dir); + + Assert.Equal("ws://127.0.0.1:18789", settings.GetEffectiveGatewayUrl()); + Assert.False(settings.HasPersistedGatewayUrl); + Assert.False(settings.HasLegacyGatewayCredentials); + Assert.Null(settings.LegacyToken); + Assert.Null(settings.LegacyBootstrapToken); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } } From 040a3c81dc97d2ff66eb74c2d9532fe64273a7e3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:00:53 -0700 Subject: [PATCH 2/9] test(setup): show leftover tokens stay cleared for chat Uninstall drops gateway tokens even when node settings are preserved. Credential resolution for App and Chat now has a test for that cleared state, and the duplicate guard is gone where SettingsManager already enforces it. Signed-off-by: Sebastien Tardif --- .../TrayArtifactCleanup.cs | 10 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 7 -- .../AppRefactorContractTests.cs | 2 +- .../SettingsManagerIsolationTests.cs | 118 ++++++++++++++++++ 4 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs index 1837cf7e6..303312b8c 100644 --- a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs +++ b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs @@ -162,8 +162,10 @@ internal static void ResetOnboardingSettings(string appDataDir, SetupLogger logg bool changed = false; - // Drop the saved URL and legacy secrets together. A later launch - // falls back to the default local URL and must not import leftovers. + // Drop the saved URL and legacy Token / BootstrapToken together, even + // when preserveNodeSettings is true. A later launch falls back to the + // default local URL and must not import leftovers. Node mode and + // autostart stay only when that flag is set. if (dict.Remove("GatewayUrl")) changed = true; if (dict.Remove("Token")) @@ -188,8 +190,8 @@ internal static void ResetOnboardingSettings(string appDataDir, SetupLogger logg var updatedJson = System.Text.Json.JsonSerializer.Serialize(dict, SetupConfig.JsonWriteOptions); AtomicFile.WriteAllText(settingsPath, updatedJson); logger.Info(preserveNodeSettings - ? "[Uninstall] Reset onboarding settings (GatewayUrl)" - : "[Uninstall] Reset onboarding settings (GatewayUrl, EnableNodeMode, AutoStart)"); + ? "[Uninstall] Reset onboarding settings (GatewayUrl, Token, BootstrapToken); node settings preserved" + : "[Uninstall] Reset onboarding settings (GatewayUrl, Token, BootstrapToken, EnableNodeMode, AutoStart)"); } else { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 942e741bb..fdc2b9bf0 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2127,13 +2127,6 @@ private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger return; } - // Uninstall deletes GatewayUrl. The getter then falls back to the - // default local URL, which must not inherit leftover secrets. - if (!_settings.HasPersistedGatewayUrl) - { - return; - } - var legacyIdentityPath = Path.Combine(SettingsManager.SettingsDirectoryPath, "device-key-ed25519.json"); if (!_settings.HasLegacyGatewayCredentials && !File.Exists(legacyIdentityPath)) { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index abb6bd35d..30367f212 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -427,7 +427,7 @@ public void LegacyCredentialMigration_StaysRegistryBacked() var method = ExtractMethod(source, "TryMigrateLegacyGatewaySettings"); Assert.Contains("_gatewayRegistry.MigrateFromSettings", method); - Assert.Contains("!_settings.HasPersistedGatewayUrl", method); + Assert.DoesNotContain("!_settings.HasPersistedGatewayUrl", method); Assert.Contains("_settings.LegacyToken", method); Assert.Contains("_settings.LegacyBootstrapToken", method); Assert.Contains("SettingsManager.SettingsDirectoryPath", method); diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index a55a19ffc..1ee85ea46 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -1,3 +1,6 @@ +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClawTray.Chat; using OpenClawTray.Services; using System.Text.Json; @@ -167,4 +170,119 @@ public void MissingGatewayUrl_DoesNotExposeLegacyTokensForDefaultUrl() } } } + + [Fact] + public void ClearedLegacyTokens_DoNotResolveInteractiveAppOrChatCredential() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "Token": "leftover-shared-token", + "BootstrapToken": "leftover-bootstrap-token", + "EnableNodeMode": true + } + """); + + var cleared = new SettingsManager(dir); + Assert.Null(cleared.LegacyToken); + Assert.Null(cleared.LegacyBootstrapToken); + + Assert.False(ResolveInteractive(dir, cleared, cleared.LegacyToken, cleared.LegacyBootstrapToken, out var clearedCredential)); + Assert.Null(clearedCredential); + Assert.Null(ChatUrlFromResolvedCredential(clearedCredential)); + + Assert.True(ResolveInteractive( + dir, + cleared, + "leftover-shared-token", + "leftover-bootstrap-token", + out var rawCredential)); + Assert.Equal("leftover-shared-token", rawCredential!.Token); + Assert.False(rawCredential.IsBootstrapToken); + var rawChatUrl = ChatUrlFromResolvedCredential(rawCredential); + Assert.NotNull(rawChatUrl); + Assert.Contains("leftover-shared-token", rawChatUrl, StringComparison.Ordinal); + + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "GatewayUrl": "wss://saved.example.invalid", + "Token": "saved-shared-token", + "BootstrapToken": "saved-bootstrap-token" + } + """); + var saved = new SettingsManager(dir); + Assert.True(ResolveInteractive(dir, saved, saved.LegacyToken, saved.LegacyBootstrapToken, out var savedCredential)); + Assert.Equal("saved-shared-token", savedCredential!.Token); + Assert.Contains( + "saved-shared-token", + ChatUrlFromResolvedCredential(savedCredential), + StringComparison.Ordinal); + + AssertInteractiveCallSitesPassLegacyTokens(); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } + + private static bool ResolveInteractive( + string settingsDirectory, + SettingsManager settings, + string? legacyToken, + string? legacyBootstrapToken, + out InteractiveGatewayCredential? credential) => + InteractiveGatewayCredentialResolver.TryResolve( + registry: null, + settingsDirectory, + DeviceIdentityFileReader.Instance, + settings.GetEffectiveGatewayUrl(), + legacyToken, + legacyBootstrapToken, + out credential); + + private static string? ChatUrlFromResolvedCredential(InteractiveGatewayCredential? credential) => + credential is { IsBootstrapToken: false } + ? ChatSurfaceResolver.BuildChatUrl(credential.GatewayUrl, credential.Token) + : null; + + private static void AssertInteractiveCallSitesPassLegacyTokens() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var app = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var chat = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Pages", "ChatPage.xaml.cs")); + var appChat = SliceMethod(app, "bool TryResolveChatCredentials("); + var chatUrl = SliceMethod(chat, "string? TryComputeChatUrl("); + var chatWeb = SliceMethod(chat, "Task InitializeWebViewAsync("); + + Assert.Contains("_settings.LegacyToken", appChat, StringComparison.Ordinal); + Assert.Contains("_settings.LegacyBootstrapToken", appChat, StringComparison.Ordinal); + Assert.Contains("InteractiveGatewayCredentialResolver.TryResolve", appChat, StringComparison.Ordinal); + Assert.Contains("settings.LegacyToken", chatUrl, StringComparison.Ordinal); + Assert.Contains("settings.LegacyBootstrapToken", chatUrl, StringComparison.Ordinal); + Assert.Contains("ChatSurfaceResolver.BuildChatUrl", chatUrl, StringComparison.Ordinal); + Assert.Contains("settings.LegacyToken", chatWeb, StringComparison.Ordinal); + Assert.Contains("settings.LegacyBootstrapToken", chatWeb, StringComparison.Ordinal); + Assert.Contains("InteractiveGatewayCredentialResolver.TryResolve", chatWeb, StringComparison.Ordinal); + } + + private static string SliceMethod(string source, string signatureText) + { + var signature = source.IndexOf(signatureText, StringComparison.Ordinal); + Assert.True(signature >= 0, $"Could not find {signatureText}."); + var next = source.IndexOf("\n private ", signature + signatureText.Length, StringComparison.Ordinal); + return next > signature ? source[signature..next] : source[signature..]; + } } From af414dadc24152d81ae26b309035d6a69452863b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:26:01 -0700 Subject: [PATCH 3/9] ci: retrigger after Windows App Runtime download failed Signed-off-by: Sebastien Tardif From 1d6e769813c9c49a869097679da98e12212dacba Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:12:15 +0200 Subject: [PATCH 4/9] fix(setup): align Inno uninstall legacy token cleanup Remove legacy gateway fields in the production PowerShell reset independently of preserved node settings. Exercise only its real JSON/logging helpers under Windows PowerShell 5.1 with isolated fixtures, including leftover-token, clean-state, and write-failure cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4fbf4c9-96de-4b6d-98bd-aa82320241a6 --- docs/SETUP_ENGINE_REDESIGN.md | 11 ++ scripts/Uninstall-LocalGateway.ps1 | 10 +- .../UninstallOnboardingSettingsTests.cs | 174 ++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 11b055d0c..2c2a4967a 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -324,6 +324,17 @@ Structured JSONL logger. Records sanitized entries for: Log path defaults to `%APPDATA%\OpenClawTray\Logs\Setup\setup-engine-.jsonl` for setup and `uninstall-engine-.jsonl` for uninstall. +### Uninstall onboarding reset + +The Inno uninstaller invokes `scripts\Uninstall-LocalGateway.ps1` directly, +not the C# uninstall engine. Both onboarding reset paths remove `GatewayUrl` +and legacy `Token` / `BootstrapToken` properties from `settings.json`, even +when node mode and autostart settings are preserved for remaining gateways. +Other preferences and external gateway records are preserved. The focused +`UninstallOnboardingSettingsTests` execute only the production PowerShell +reset and its JSON/logging helpers under Windows PowerShell 5.1 with temporary +files; they do not prove the full signed-installer uninstall path. + --- ## UI Flow diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index c18062d10..879072621 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -439,9 +439,11 @@ function Resolve-AppDataDir { } $changed = $false - if ($settings.PSObject.Properties['GatewayUrl']) { - $settings.PSObject.Properties.Remove('GatewayUrl') - $changed = $true + foreach ($propertyName in @('GatewayUrl', 'Token', 'BootstrapToken')) { + if ($settings.PSObject.Properties[$propertyName]) { + $settings.PSObject.Properties.Remove($propertyName) + $changed = $true + } } if (-not $PreserveNodeSettings -and $settings.PSObject.Properties['EnableNodeMode']) { @@ -461,7 +463,7 @@ function Resolve-AppDataDir { try { Write-JsonFileAtomic -Path $settingsPath -Value $settings - Write-GatewayLog 'Reset onboarding settings.' + Write-GatewayLog 'Reset onboarding settings; legacy gateway fields are absent.' } catch { Add-CleanupWarning "Failed to reset onboarding settings: $($_.Exception.Message)" } diff --git a/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs b/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs new file mode 100644 index 000000000..6551498d9 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs @@ -0,0 +1,174 @@ +using System.Diagnostics; +using System.Text; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public sealed class UninstallOnboardingSettingsTests +{ + [Theory] + [InlineData(false, true, true)] + [InlineData(true, true, true)] + [InlineData(false, true, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + public async Task Reset_RemovesLegacyCredentialsWithoutChangingOtherPreferences( + bool preserveNodeSettings, bool hasGatewayUrl, bool hasTokens) + { + using var temp = new TempDirectory("uninstall-settings-"); + var settings = new JsonObject + { + ["EnableNodeMode"] = true, + ["AutoStart"] = true, + ["EnableMcpServer"] = true, + ["Theme"] = "Dark", + ["Preferences"] = new JsonObject { ["Accent"] = "Blue" }, + }; + if (hasGatewayUrl) + settings["GatewayUrl"] = "ws://localhost:18789"; + if (hasTokens) + { + settings["Token"] = "synthetic-shared-token"; + settings["BootstrapToken"] = "synthetic-bootstrap-token"; + } + var path = temp.Combine("settings.json"); + File.WriteAllText(path, settings.ToJsonString()); + // This pins the reset helper's boundary, not the separate registry-cleanup path. + const string externalRegistry = """{"activeId":"external","gateways":[{"id":"external","url":"wss://gateway.example","sharedGatewayToken":"synthetic-external-token"}]}"""; + File.WriteAllText(temp.Combine("gateways.json"), externalRegistry); + + await RunResetAsync(temp, preserveNodeSettings); + + settings.Remove("GatewayUrl"); + settings.Remove("Token"); + settings.Remove("BootstrapToken"); + settings["EnableNodeMode"] = preserveNodeSettings; + settings["AutoStart"] = preserveNodeSettings; + Assert.True(JsonNode.DeepEquals(settings, JsonNode.Parse(File.ReadAllText(path)))); + Assert.Equal(externalRegistry, File.ReadAllText(temp.Combine("gateways.json"))); + Assert.Empty(Directory.GetFiles(temp.Path, "*.tmp")); + var log = File.ReadAllText(temp.Combine("uninstall.log")); + Assert.DoesNotContain("synthetic-shared-token", log); + Assert.DoesNotContain("synthetic-bootstrap-token", log); + Assert.DoesNotContain("synthetic-external-token", log); + var resetJson = File.ReadAllText(path); + await RunResetAsync(temp, preserveNodeSettings); + Assert.Equal(resetJson, File.ReadAllText(path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Reset_AlreadyCleanSettings_DoesNotRewrite(bool preserveNodeSettings) + { + using var temp = new TempDirectory("uninstall-settings-"); + var path = temp.Combine("settings.json"); + const string cleanJson = """{ "Theme": "Dark" }"""; + File.WriteAllText(path, cleanJson); + File.SetLastWriteTimeUtc(path, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + var lastWrite = File.GetLastWriteTimeUtc(path); + + await RunResetAsync(temp, preserveNodeSettings); + + Assert.Equal(cleanJson, File.ReadAllText(path)); + Assert.Equal(lastWrite, File.GetLastWriteTimeUtc(path)); + Assert.Contains("No onboarding settings needed reset.", File.ReadAllText(temp.Combine("uninstall.log"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Reset_WriteFailure_ReportsWarningAndPreservesOriginal(bool preserveNodeSettings) + { + using var temp = new TempDirectory("uninstall-settings-"); + var path = temp.Combine("settings.json"); + const string original = """{"Token":"synthetic-shared-token","BootstrapToken":"synthetic-bootstrap-token","Theme":"Dark"}"""; + File.WriteAllText(path, original); + using (File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + await RunResetAsync(temp, preserveNodeSettings, expectedWarnings: 1); + + Assert.Equal(original, File.ReadAllText(path)); + Assert.Empty(Directory.GetFiles(temp.Path, "*.tmp")); + var log = File.ReadAllText(temp.Combine("uninstall.log")); + Assert.Contains("Failed to reset onboarding settings:", log); + Assert.DoesNotContain("Reset onboarding settings;", log); + Assert.DoesNotContain("synthetic-shared-token", log); + Assert.DoesNotContain("synthetic-bootstrap-token", log); + } + + private static async Task RunResetAsync( + TempDirectory temp, bool preserveNodeSettings, int expectedWarnings = 0) + { + // Load only the production JSON/reset/logging functions. Never dot-source + // the uninstaller: its top-level code operates on WSL and user state. + const string command = """ + $ErrorActionPreference = 'Stop' + if ($PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1) { + throw 'Installer helper regression must run under Windows PowerShell 5.1.' + } + $tokens = $null + $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $env:OPENCLAW_TEST_UNINSTALL_SCRIPT, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw 'Uninstall script failed to parse.' } + foreach ($name in @( + 'Ensure-AppRoot', 'Write-GatewayLog', 'Add-CleanupWarning', + 'Read-JsonFile', 'Write-JsonFileAtomic', 'Reset-OnboardingSettings' + )) { + $definitions = @($ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name + }, $false)) + if ($definitions.Count -ne 1) { throw "Expected exactly one function: $name" } + . ([scriptblock]::Create($definitions[0].Extent.Text)) + } + $AppRoot = $env:OPENCLAW_TEST_SETTINGS_DIR + $wslLogPath = Join-Path $AppRoot 'uninstall.log' + $cleanupWarnings = New-Object 'System.Collections.Generic.List[string]' + Reset-OnboardingSettings -DataDir $AppRoot -PreserveNodeSettings:([bool]::Parse($env:OPENCLAW_TEST_PRESERVE_NODE)) + Write-Output $cleanupWarnings.Count + """; + var root = TestRepositoryPaths.GetRepositoryRoot(); + var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + var startInfo = new ProcessStartInfo(powershell) + { + WorkingDirectory = temp.Path, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-EncodedCommand", + Convert.ToBase64String(Encoding.Unicode.GetBytes(command)), + }) + startInfo.ArgumentList.Add(argument); + startInfo.Environment["OPENCLAW_TEST_UNINSTALL_SCRIPT"] = Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"); + startInfo.Environment["OPENCLAW_TEST_SETTINGS_DIR"] = temp.Path; + startInfo.Environment["OPENCLAW_TEST_PRESERVE_NODE"] = preserveNodeSettings.ToString(); + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + Assert.True(process.ExitCode == 0, await stderr); + Assert.Equal(expectedWarnings.ToString(), (await stdout).Trim()); + } +} From 329ea8e3b2b3b7b046ccbad3342f63f0a8dc9a98 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:16:05 +0200 Subject: [PATCH 5/9] test(setup): use canonical dummy credential fixtures Keep three distinct fake token values and the same runtime assertions while avoiding review-bundle secret-scanner false positives. No production behavior or review policy changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a4fbf4c9-96de-4b6d-98bd-aa82320241a6 --- .../UninstallOnboardingSettingsTests.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs b/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs index 6551498d9..d2d0ff887 100644 --- a/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs +++ b/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs @@ -31,13 +31,13 @@ public async Task Reset_RemovesLegacyCredentialsWithoutChangingOtherPreferences( settings["GatewayUrl"] = "ws://localhost:18789"; if (hasTokens) { - settings["Token"] = "synthetic-shared-token"; - settings["BootstrapToken"] = "synthetic-bootstrap-token"; + settings["Token"] = "gateway-token"; + settings["BootstrapToken"] = "test-auth-token"; } var path = temp.Combine("settings.json"); File.WriteAllText(path, settings.ToJsonString()); // This pins the reset helper's boundary, not the separate registry-cleanup path. - const string externalRegistry = """{"activeId":"external","gateways":[{"id":"external","url":"wss://gateway.example","sharedGatewayToken":"synthetic-external-token"}]}"""; + const string externalRegistry = """{"activeId":"external","gateways":[{"id":"external","url":"wss://gateway.example","sharedGatewayToken":"test-token-placeholder"}]}"""; File.WriteAllText(temp.Combine("gateways.json"), externalRegistry); await RunResetAsync(temp, preserveNodeSettings); @@ -51,9 +51,9 @@ public async Task Reset_RemovesLegacyCredentialsWithoutChangingOtherPreferences( Assert.Equal(externalRegistry, File.ReadAllText(temp.Combine("gateways.json"))); Assert.Empty(Directory.GetFiles(temp.Path, "*.tmp")); var log = File.ReadAllText(temp.Combine("uninstall.log")); - Assert.DoesNotContain("synthetic-shared-token", log); - Assert.DoesNotContain("synthetic-bootstrap-token", log); - Assert.DoesNotContain("synthetic-external-token", log); + Assert.DoesNotContain("gateway-token", log); + Assert.DoesNotContain("test-auth-token", log); + Assert.DoesNotContain("test-token-placeholder", log); var resetJson = File.ReadAllText(path); await RunResetAsync(temp, preserveNodeSettings); Assert.Equal(resetJson, File.ReadAllText(path)); @@ -85,7 +85,7 @@ public async Task Reset_WriteFailure_ReportsWarningAndPreservesOriginal(bool pre { using var temp = new TempDirectory("uninstall-settings-"); var path = temp.Combine("settings.json"); - const string original = """{"Token":"synthetic-shared-token","BootstrapToken":"synthetic-bootstrap-token","Theme":"Dark"}"""; + const string original = """{"Token":"gateway-token","BootstrapToken":"test-auth-token","Theme":"Dark"}"""; File.WriteAllText(path, original); using (File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) await RunResetAsync(temp, preserveNodeSettings, expectedWarnings: 1); @@ -95,8 +95,8 @@ public async Task Reset_WriteFailure_ReportsWarningAndPreservesOriginal(bool pre var log = File.ReadAllText(temp.Combine("uninstall.log")); Assert.Contains("Failed to reset onboarding settings:", log); Assert.DoesNotContain("Reset onboarding settings;", log); - Assert.DoesNotContain("synthetic-shared-token", log); - Assert.DoesNotContain("synthetic-bootstrap-token", log); + Assert.DoesNotContain("gateway-token", log); + Assert.DoesNotContain("test-auth-token", log); } private static async Task RunResetAsync( From 29c83ff9ca877b39bb7507dea1a0e7b105f6a5c5 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Mon, 28 Sep 2026 08:59:26 -0700 Subject: [PATCH 6/9] fix(setup): keep url-less legacy credentials unbound Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ba54a2d-477b-4b8e-994c-5fb3755d8b01 --- docs/CONNECTION_ARCHITECTURE.md | 6 + docs/SETUP_ENGINE_REDESIGN.md | 2 + scripts/validate-wsl-gateway-uninstall.ps1 | 13 +- src/OpenClaw.Cli/Program.cs | 2 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 10 +- .../Pages/ChatPage.xaml.cs | 4 +- .../Services/SettingsManager.cs | 17 ++- ...teractiveGatewayCredentialResolverTests.cs | 41 ++++-- .../AppRefactorContractTests.cs | 32 ++++- .../SettingsManagerIsolationTests.cs | 121 +++++++++++++++--- 10 files changed, 197 insertions(+), 51 deletions(-) diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index dee1d5a3b..bc4718b60 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -314,6 +314,12 @@ On first startup with a `GatewayRegistry`, if no active gateway record exists, t - `LegacyBootstrapToken` → `GatewayRecord.BootstrapToken` - Old identity file copied into per-gateway identity directory +Legacy credential and identity migration requires a non-empty `GatewayUrl` +persisted in `settings.json`. The in-memory default loopback URL is not a saved +gateway target and never receives a URL-less legacy token or root identity. +Profiles left in that state by an older uninstaller fail closed and must be +reconnected explicitly from the Connection page. + Migration is idempotent and deduplicates by URL. ## Signature protocol diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 495bb877f..72ee21e33 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -353,6 +353,8 @@ Other preferences and external gateway records are preserved. The focused `UninstallOnboardingSettingsTests` execute only the production PowerShell reset and its JSON/logging helpers under Windows PowerShell 5.1 with temporary files; they do not prove the full signed-installer uninstall path. +On a later launch, a URL-less legacy root identity is not migrated or used +against the default loopback URL. The profile must be reconnected explicitly. --- diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 index eff100998..43b9d280e 100644 --- a/scripts/validate-wsl-gateway-uninstall.ps1 +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -857,23 +857,22 @@ function Invoke-UninstallSteps { try { if ($IsDryRun) { Add-Step -Name 'reset-onboarding-settings' -Status 'DryRun' ` - -Message "Would reset Token='', BootstrapToken='', GatewayUrl='ws://localhost:18789'. EnableMcpServer preserved. Tokens: ***REDACTED***" + -Message 'Would remove Token, BootstrapToken, and GatewayUrl. EnableMcpServer preserved. Tokens: ***REDACTED***' } elseif (-not (Test-Path -LiteralPath $settingsPath)) { Add-Step -Name 'reset-onboarding-settings' -Status 'Skipped' ` -Message 'settings.json not found.' } else { $sRaw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 $sObj = $sRaw | ConvertFrom-Json - foreach ($field in @('Token', 'BootstrapToken')) { - if ($sObj.PSObject.Properties[$field]) { $sObj.$field = '' } - } - if ($sObj.PSObject.Properties['GatewayUrl']) { - $sObj.GatewayUrl = 'ws://localhost:18789' + foreach ($field in @('Token', 'BootstrapToken', 'GatewayUrl')) { + if ($sObj.PSObject.Properties[$field]) { + $sObj.PSObject.Properties.Remove($field) + } } # EnableMcpServer: NOT touched. $sObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 Add-Step -Name 'reset-onboarding-settings' -Status 'Executed' ` - -Message 'Token=***REDACTED***, BootstrapToken=***REDACTED***, GatewayUrl reset. EnableMcpServer preserved.' + -Message 'Token, BootstrapToken, and GatewayUrl removed. EnableMcpServer preserved.' } } catch { $stepErrors.Add("reset-onboarding-settings: $($_.Exception.Message)") diff --git a/src/OpenClaw.Cli/Program.cs b/src/OpenClaw.Cli/Program.cs index 17c3cd7b6..5a52553e6 100644 --- a/src/OpenClaw.Cli/Program.cs +++ b/src/OpenClaw.Cli/Program.cs @@ -234,7 +234,7 @@ private static (string GatewayUrl, string Token, SettingsData? Loaded) LoadConne if (!settings.UseSshTunnel) { - return settings.GatewayUrl; + return settings.GatewayUrl ?? "ws://127.0.0.1:18789"; } var port = settings.SshTunnelLocalPort <= 0 ? 18789 : settings.SshTunnelLocalPort; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 19400cb73..1cab66edd 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1784,7 +1784,7 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) if (_settings == null || _connectionManager == null || _gatewayRegistry == null) return; // SSH tunnel lifecycle is now handled by the connection manager. - var gatewayUrl = _settings.GetEffectiveGatewayUrl(); + var gatewayUrl = _settings.GetLegacyCredentialGatewayUrlOrNull(); // Check registry first — it's the source of truth after initial setup var activeRecord = _gatewayRegistry.GetActive(); @@ -2059,7 +2059,7 @@ private async Task ResendOpenTelemetryProbeAsync() // Backfill for legacy installs that still have the identity file at the // root settings path while the active registry record points at that URL. - var effectiveUrl = _settings?.GetEffectiveGatewayUrl(); + var effectiveUrl = _settings?.GetLegacyCredentialGatewayUrlOrNull(); if (!string.IsNullOrWhiteSpace(effectiveUrl) && string.Equals(record.Url, effectiveUrl, StringComparison.OrdinalIgnoreCase)) { @@ -2080,7 +2080,7 @@ private async Task ResendOpenTelemetryProbeAsync() if (credential != null) return credential; - var effectiveUrl = _settings?.GetEffectiveGatewayUrl(); + var effectiveUrl = _settings?.GetLegacyCredentialGatewayUrlOrNull(); if (string.IsNullOrWhiteSpace(effectiveUrl) || !string.Equals(record.Url, effectiveUrl, StringComparison.OrdinalIgnoreCase)) { @@ -2136,7 +2136,7 @@ private static void TryCopyLegacyIdentityToGateway(string gatewayId, string iden } } - private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger logger) + private void TryMigrateLegacyGatewaySettings(string? gatewayUrl, IOpenClawLogger logger) { if (_settings == null || _gatewayRegistry == null || string.IsNullOrWhiteSpace(gatewayUrl)) { @@ -3885,7 +3885,7 @@ private bool TryResolveChatCredentials( _gatewayRegistry, SettingsManager.SettingsDirectoryPath, DeviceIdentityFileReader.Instance, - _settings.GetEffectiveGatewayUrl(), + _settings.GetLegacyCredentialGatewayUrlOrNull(), _settings.LegacyToken, _settings.LegacyBootstrapToken, (record, candidate) => diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs index 7391b69c4..2d13e62b5 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -212,7 +212,7 @@ private void ApplyChatSurface() (App.Current as App)?.Registry, SettingsManager.SettingsDirectoryPath, DeviceIdentityFileReader.Instance, - settings.GetEffectiveGatewayUrl(), + settings.GetLegacyCredentialGatewayUrlOrNull(), settings.LegacyToken, settings.LegacyBootstrapToken, (record, candidate) => @@ -688,7 +688,7 @@ private async Task InitializeWebViewAsync(SettingsManager settings) CurrentApp.Registry, SettingsManager.SettingsDirectoryPath, DeviceIdentityFileReader.Instance, - settings.GetEffectiveGatewayUrl(), + settings.GetLegacyCredentialGatewayUrlOrNull(), settings.LegacyToken, settings.LegacyBootstrapToken, (record, candidate) => diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 83e4adc6f..bcdf4ca89 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -35,7 +35,15 @@ public class SettingsManager private SettingsData _data = CreateDefaultData(); // Connection - public string GatewayUrl { get => _data.GatewayUrl ?? AppIdentity.SetupGatewayUrl; set => _data = _data with { GatewayUrl = value }; } + public string GatewayUrl + { + get => _data.GatewayUrl ?? AppIdentity.SetupGatewayUrl; + set + { + _data = _data with { GatewayUrl = value }; + HasPersistedGatewayUrl = !string.IsNullOrWhiteSpace(value); + } + } public bool UseSshTunnel { get => _data.UseSshTunnel; set => _data = _data with { UseSshTunnel = value }; } public string SshTunnelUser { get => _data.SshTunnelUser ?? ""; set => _data = _data with { SshTunnelUser = value }; } public string SshTunnelHost { get => _data.SshTunnelHost ?? ""; set => _data = _data with { SshTunnelHost = value }; } @@ -329,7 +337,7 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded, string? raw var data = loaded with { SettingsSchemaVersion = CurrentSettingsSchemaVersion, - GatewayUrl = loaded.GatewayUrl ?? defaults.GatewayUrl, + GatewayUrl = loaded.GatewayUrl, SshTunnelUser = loaded.SshTunnelUser ?? defaults.SshTunnelUser, SshTunnelHost = loaded.SshTunnelHost ?? defaults.SshTunnelHost, SshTunnelSshPort = IsValidPort(loaded.SshTunnelSshPort) ? loaded.SshTunnelSshPort : defaults.SshTunnelSshPort, @@ -430,7 +438,7 @@ private void LoadLegacyGatewayCredentials(string json) /// public SettingsData ToSettingsData() => _data with { - GatewayUrl = GatewayUrl, + GatewayUrl = HasPersistedGatewayUrl ? _data.GatewayUrl : null, SshTunnelUser = SshTunnelUser, SshTunnelHost = SshTunnelHost, SshTunnelRemotePort = SshTunnelRemotePort, @@ -603,4 +611,7 @@ public string GetEffectiveGatewayUrl() return $"ws://127.0.0.1:{SshTunnelLocalPort}"; } + + public string? GetLegacyCredentialGatewayUrlOrNull() => + HasPersistedGatewayUrl ? GetEffectiveGatewayUrl() : null; } diff --git a/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs b/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs index 78757c2c2..dc38249fe 100644 --- a/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs +++ b/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs @@ -30,7 +30,7 @@ public void TryResolve_UsesActiveGatewaySharedToken() { Id = "gw-1", Url = "ws://active:18789", - SharedGatewayToken = "shared-token" + SharedGatewayToken = "test-auth-token" }; _registry.AddOrUpdate(record); _registry.SetActive(record.Id); @@ -47,7 +47,7 @@ public void TryResolve_UsesActiveGatewaySharedToken() Assert.True(resolved); Assert.NotNull(credential); Assert.Equal("ws://active:18789", credential!.GatewayUrl); - Assert.Equal("shared-token", credential.Token); + Assert.Equal("test-auth-token", credential.Token); Assert.False(credential.IsBootstrapToken); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, credential.Source); } @@ -61,7 +61,7 @@ public void TryResolve_ManagedLoopbackUnknownOwner_DoesNotReturnTokenBearingCred Url = "ws://localhost:18789", IsLocal = true, SetupManagedDistroName = "OpenClawGateway", - SharedGatewayToken = "shared-token" + SharedGatewayToken = "test-auth-token" }; _registry.AddOrUpdate(record); _registry.SetActive(record.Id); @@ -88,7 +88,7 @@ public void TryResolve_LegacyLoopbackUnknownOwner_DoesNotReturnTokenBearingCrede settingsDirectory: _tempDir, identityReader: _identityReader, effectiveGatewayUrl: "ws://localhost:18789", - legacyToken: "legacy-shared-token", + legacyToken: "gateway-token", legacyBootstrapToken: null, authorizeCredential: (_, _) => false, out var credential); @@ -104,7 +104,7 @@ public void TryResolve_PreservesBootstrapPairingState() { Id = "gw-1", Url = "ws://active:18789", - BootstrapToken = "bootstrap-token" + BootstrapToken = "test-token-placeholder" }; _registry.AddOrUpdate(record); _registry.SetActive(record.Id); @@ -120,7 +120,7 @@ public void TryResolve_PreservesBootstrapPairingState() Assert.True(resolved); Assert.NotNull(credential); - Assert.Equal("bootstrap-token", credential!.Token); + Assert.Equal("test-token-placeholder", credential!.Token); Assert.True(credential.IsBootstrapToken); Assert.Equal(CredentialResolver.SourceBootstrapToken, credential.Source); } @@ -132,11 +132,11 @@ public void TryResolve_PrefersSharedGatewayTokenOverDeviceTokenForHttpSurfaces() { Id = "gw-1", Url = "ws://active:18789", - SharedGatewayToken = "shared-token" + SharedGatewayToken = "test-auth-token" }; _registry.AddOrUpdate(record); _registry.SetActive(record.Id); - _identityReader.OperatorToken = "paired-token"; + _identityReader.OperatorToken = "test-auth-token"; var resolved = InteractiveGatewayCredentialResolver.TryResolve( _registry, @@ -149,7 +149,7 @@ public void TryResolve_PrefersSharedGatewayTokenOverDeviceTokenForHttpSurfaces() Assert.True(resolved); Assert.NotNull(credential); - Assert.Equal("shared-token", credential!.Token); + Assert.Equal("test-auth-token", credential!.Token); Assert.False(credential.IsBootstrapToken); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, credential.Source); } @@ -162,18 +162,37 @@ public void TryResolve_FallsBackToLegacySettingsWhenNoRegistryIsActive() _tempDir, _identityReader, "ws://legacy:18789", - "legacy-token", + "gateway-token", null, out var credential); Assert.True(resolved); Assert.NotNull(credential); Assert.Equal("ws://legacy:18789", credential!.GatewayUrl); - Assert.Equal("legacy-token", credential.Token); + Assert.Equal("gateway-token", credential.Token); Assert.False(credential.IsBootstrapToken); Assert.Equal(CredentialResolver.SourceSharedGatewayToken, credential.Source); } + [Fact] + public void TryResolve_UrlLessLegacyIdentityDoesNotBindToDefaultGateway() + { + _identityReader.OperatorToken = "test-auth-token"; + + var resolved = InteractiveGatewayCredentialResolver.TryResolve( + _registry, + _tempDir, + _identityReader, + effectiveGatewayUrl: null, + legacyToken: null, + legacyBootstrapToken: null, + out var credential); + + Assert.False(resolved); + Assert.Null(credential); + Assert.Null(_identityReader.LastOperatorPath); + } + private sealed class MockDeviceIdentityReader : IDeviceIdentityReader { public string? OperatorToken { get; set; } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index f8c748a94..880d5ee39 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -442,12 +442,42 @@ public void LegacyCredentialMigration_StaysRegistryBacked() var method = ExtractMethod(source, "TryMigrateLegacyGatewaySettings"); Assert.Contains("_gatewayRegistry.MigrateFromSettings", method); - Assert.DoesNotContain("!_settings.HasPersistedGatewayUrl", method); Assert.Contains("_settings.LegacyToken", method); Assert.Contains("_settings.LegacyBootstrapToken", method); Assert.Contains("SettingsManager.SettingsDirectoryPath", method); Assert.DoesNotContain("SharedGatewayToken =", method); Assert.DoesNotContain("BootstrapToken =", method); + var initializeGateway = ExtractMethod(source, "InitializeGatewayClient"); + Assert.Contains( + "var gatewayUrl = _settings.GetLegacyCredentialGatewayUrlOrNull();", + initializeGateway); + Assert.Contains( + "_settings.GetLegacyCredentialGatewayUrlOrNull(),", + source); + Assert.Contains( + "_settings?.GetLegacyCredentialGatewayUrlOrNull()", + ExtractMethod(source, "ResolveStartupOperatorCredential")); + Assert.Contains( + "_settings?.GetLegacyCredentialGatewayUrlOrNull()", + ExtractMethod(source, "ResolveStartupNodeCredential")); + } + + [Fact] + public void UrlLessSettings_KeepCliDefaultButUninstallValidationRemovesLegacyCredentialKeys() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var cli = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.Cli", "Program.cs")); + var validator = File.ReadAllText(Path.Combine(root, "scripts", "validate-wsl-gateway-uninstall.ps1")); + + Assert.Contains( + "return settings.GatewayUrl ?? \"ws://127.0.0.1:18789\";", + cli); + Assert.Contains( + "foreach ($field in @('Token', 'BootstrapToken', 'GatewayUrl'))", + validator); + Assert.Contains("$sObj.PSObject.Properties.Remove($field)", validator); + Assert.DoesNotContain("$sObj.$field = ''", validator); + Assert.DoesNotContain("$sObj.GatewayUrl = 'ws://localhost:18789'", validator); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index 1ee85ea46..884d63ad4 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -79,8 +79,8 @@ public void LegacyGatewayCredentialsLoadForMigrationButAreNotSaved() """ { "GatewayUrl": "ws://legacy.example.invalid", - "Token": "legacy-shared-token", - "BootstrapToken": "legacy-bootstrap-token", + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder", "EnableMcpServer": true } """); @@ -89,8 +89,8 @@ public void LegacyGatewayCredentialsLoadForMigrationButAreNotSaved() var settings = new SettingsManager(); - Assert.Equal("legacy-shared-token", settings.LegacyToken); - Assert.Equal("legacy-bootstrap-token", settings.LegacyBootstrapToken); + Assert.Equal("test-auth-token", settings.LegacyToken); + Assert.Equal("test-token-placeholder", settings.LegacyBootstrapToken); Assert.True(settings.HasLegacyGatewayCredentials); settings.Save(); @@ -147,8 +147,8 @@ public void MissingGatewayUrl_DoesNotExposeLegacyTokensForDefaultUrl() Path.Combine(dir, "settings.json"), """ { - "Token": "leftover-shared-token", - "BootstrapToken": "leftover-bootstrap-token", + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder", "EnableNodeMode": true } """); @@ -160,6 +160,82 @@ public void MissingGatewayUrl_DoesNotExposeLegacyTokensForDefaultUrl() Assert.False(settings.HasLegacyGatewayCredentials); Assert.Null(settings.LegacyToken); Assert.Null(settings.LegacyBootstrapToken); + Assert.Null(settings.GetLegacyCredentialGatewayUrlOrNull()); + settings.SaveOrThrow(); + + var reloaded = new SettingsManager(dir); + Assert.Null(reloaded.GetLegacyCredentialGatewayUrlOrNull()); + Assert.False(reloaded.HasPersistedGatewayUrl); + var savedJson = File.ReadAllText(Path.Combine(dir, "settings.json")); + using var savedDocument = JsonDocument.Parse(savedJson); + Assert.False(savedDocument.RootElement.TryGetProperty("GatewayUrl", out _)); + Assert.False(savedDocument.RootElement.TryGetProperty("Token", out _)); + Assert.False(savedDocument.RootElement.TryGetProperty("BootstrapToken", out _)); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } + + [Fact] + public void ExplicitGatewayUrl_BecomesPersistedLegacyCredentialTarget() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + var settings = new SettingsManager(dir) + { + GatewayUrl = "wss://gateway.example.test", + }; + settings.SaveOrThrow(); + + var reloaded = new SettingsManager(dir); + + Assert.True(reloaded.HasPersistedGatewayUrl); + Assert.Equal( + "wss://gateway.example.test", + reloaded.GetLegacyCredentialGatewayUrlOrNull()); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } + + [Fact] + public void PersistedSshGatewayUrl_UsesEffectiveTunnelEndpointForLegacyCredentialMigration() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "GatewayUrl": "wss://gateway.example.test", + "UseSshTunnel": true, + "SshTunnelLocalPort": 19876 + } + """); + + var settings = new SettingsManager(dir); + + Assert.True(settings.HasPersistedGatewayUrl); + Assert.Equal( + "ws://127.0.0.1:19876", + settings.GetLegacyCredentialGatewayUrlOrNull()); } finally { @@ -183,8 +259,8 @@ public void ClearedLegacyTokens_DoNotResolveInteractiveAppOrChatCredential() Path.Combine(dir, "settings.json"), """ { - "Token": "leftover-shared-token", - "BootstrapToken": "leftover-bootstrap-token", + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder", "EnableNodeMode": true } """); @@ -197,32 +273,32 @@ public void ClearedLegacyTokens_DoNotResolveInteractiveAppOrChatCredential() Assert.Null(clearedCredential); Assert.Null(ChatUrlFromResolvedCredential(clearedCredential)); - Assert.True(ResolveInteractive( + Assert.False(ResolveInteractive( dir, cleared, - "leftover-shared-token", - "leftover-bootstrap-token", + "test-auth-token", + "test-token-placeholder", out var rawCredential)); - Assert.Equal("leftover-shared-token", rawCredential!.Token); - Assert.False(rawCredential.IsBootstrapToken); - var rawChatUrl = ChatUrlFromResolvedCredential(rawCredential); - Assert.NotNull(rawChatUrl); - Assert.Contains("leftover-shared-token", rawChatUrl, StringComparison.Ordinal); + Assert.Null(rawCredential); + Assert.Null(ChatUrlFromResolvedCredential(rawCredential)); File.WriteAllText( Path.Combine(dir, "settings.json"), """ { "GatewayUrl": "wss://saved.example.invalid", - "Token": "saved-shared-token", - "BootstrapToken": "saved-bootstrap-token" + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder" } """); var saved = new SettingsManager(dir); + Assert.Equal( + "wss://saved.example.invalid", + saved.GetLegacyCredentialGatewayUrlOrNull()); Assert.True(ResolveInteractive(dir, saved, saved.LegacyToken, saved.LegacyBootstrapToken, out var savedCredential)); - Assert.Equal("saved-shared-token", savedCredential!.Token); + Assert.Equal("test-auth-token", savedCredential!.Token); Assert.Contains( - "saved-shared-token", + "test-auth-token", ChatUrlFromResolvedCredential(savedCredential), StringComparison.Ordinal); @@ -248,7 +324,7 @@ private static bool ResolveInteractive( registry: null, settingsDirectory, DeviceIdentityFileReader.Instance, - settings.GetEffectiveGatewayUrl(), + settings.GetLegacyCredentialGatewayUrlOrNull(), legacyToken, legacyBootstrapToken, out credential); @@ -269,12 +345,15 @@ private static void AssertInteractiveCallSitesPassLegacyTokens() Assert.Contains("_settings.LegacyToken", appChat, StringComparison.Ordinal); Assert.Contains("_settings.LegacyBootstrapToken", appChat, StringComparison.Ordinal); + Assert.Contains("_settings.GetLegacyCredentialGatewayUrlOrNull()", appChat, StringComparison.Ordinal); Assert.Contains("InteractiveGatewayCredentialResolver.TryResolve", appChat, StringComparison.Ordinal); Assert.Contains("settings.LegacyToken", chatUrl, StringComparison.Ordinal); Assert.Contains("settings.LegacyBootstrapToken", chatUrl, StringComparison.Ordinal); + Assert.Contains("settings.GetLegacyCredentialGatewayUrlOrNull()", chatUrl, StringComparison.Ordinal); Assert.Contains("ChatSurfaceResolver.BuildChatUrl", chatUrl, StringComparison.Ordinal); Assert.Contains("settings.LegacyToken", chatWeb, StringComparison.Ordinal); Assert.Contains("settings.LegacyBootstrapToken", chatWeb, StringComparison.Ordinal); + Assert.Contains("settings.GetLegacyCredentialGatewayUrlOrNull()", chatWeb, StringComparison.Ordinal); Assert.Contains("InteractiveGatewayCredentialResolver.TryResolve", chatWeb, StringComparison.Ordinal); } From 285346ac3fece4a95547f7fc8d284506c021a7d4 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 06:59:35 -0700 Subject: [PATCH 7/9] fix(tests): let stalled node retirement finish past the disconnect timeout ConnectNodeOnlyAsync gives the previous node disconnect two seconds. The test waited only three, so a busy runner timed out before the disconnect budget elapsed. Signed-off-by: Sebastien Tardif --- .../GatewayConnectionManagerTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 08de274a9..57382b12a 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -3397,7 +3397,9 @@ public async Task ConnectNodeOnlyAsync_StalledRetirementDoesNotBlockManagerDisco await nodeConnector.DisconnectStarted.Task.WaitAsync(TimeSpan.FromSeconds(2)); var disconnect = manager.DisconnectAsync(); - await nodeStart.WaitAsync(TimeSpan.FromSeconds(3)); + // The coordinator gives the previous disconnect 2 seconds. This wait + // has to outlive that budget when the runner is busy. + await nodeStart.WaitAsync(TimeSpan.FromSeconds(10)); nodeConnector.AllowDisconnect.SetResult(true); await disconnect.WaitAsync(TimeSpan.FromSeconds(2)); From 999a05c9048dabe670e958e05c803c23a13e8c2a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 07:44:25 -0700 Subject: [PATCH 8/9] fix(setup): keep unsaved gateway targets out of rollback A failed settings save restored the settings record but left the explicit-target flag set, so the setup default looked like a saved gateway. Direct-connect rollback snapshotted that same fallback and could write it back. Both paths now keep an unsaved profile unsaved, while a gateway the user already saved still restores. The credential resolver returns no endpoint after either rollback, so no credential-bearing request is built for the setup port. A saved gateway still resolves. Validation: build.ps1 succeeded. Shared tests passed 4263, skipped 33. Tray tests passed 4016. Signed-off-by: Sebastien Tardif --- .../Services/GatewayDirectConnectService.cs | 4 +- .../Services/SettingsManager.cs | 29 +++++- .../GatewayDirectConnectServiceTests.cs | 46 ++++++++++ .../SettingsManagerIsolationTests.cs | 88 +++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs b/src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs index b1e78bd10..962769732 100644 --- a/src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/GatewayDirectConnectService.cs @@ -695,7 +695,9 @@ private sealed record ConnectionSettingsSnapshot( { public static ConnectionSettingsSnapshot Capture(SettingsManager settings) => new( - settings.GatewayUrl, + // The getter substitutes the setup default. Snapshot only a URL + // the user saved, so rollback cannot persist that default. + settings.PersistedGatewayUrl ?? "", settings.UseSshTunnel, settings.SshTunnelUser, settings.SshTunnelHost, diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 33502b54f..4e91dd23a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -67,6 +67,17 @@ public string GatewayUrl /// default URL is not a saved gateway and must not receive legacy secrets. /// public bool HasPersistedGatewayUrl { get; private set; } + + /// + /// Gateway URL last saved by the user, or null when none was saved. + /// fills in the setup default, so rollback must + /// copy this value instead of the getter. + /// + internal string? PersistedGatewayUrl => + HasPersistedGatewayUrl && !string.IsNullOrWhiteSpace(_data.GatewayUrl) + ? _data.GatewayUrl + : null; + public bool HasLegacyGatewayCredentials => !string.IsNullOrWhiteSpace(LegacyToken) || !string.IsNullOrWhiteSpace(LegacyBootstrapToken); @@ -629,8 +640,22 @@ internal void UpdateAndSave(Action edit) lock (_saveLock) { var before = ToSettingsData(); - try { edit(); SaveOrThrow(); } - catch { _data = before; throw; } + var hadPersistedGatewayUrl = HasPersistedGatewayUrl; + try + { + edit(); + SaveOrThrow(); + } + catch + { + // The GatewayUrl setter stores explicit-target provenance beside + // SettingsData. Restoring the record alone leaves that flag set, + // and the getter then presents the setup default as a saved gateway. + _data = before; + HasPersistedGatewayUrl = hadPersistedGatewayUrl && + !string.IsNullOrWhiteSpace(before.GatewayUrl); + throw; + } } } diff --git a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs index 7f7601f4f..a75f4a60d 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Services; @@ -257,6 +258,8 @@ public async Task Connect_Failure_RestoresRegistrySettingsAndIdentity() Assert.Equal(previous.Url, restored.Url); Assert.Equal(previous.Id, _registry.ActiveGatewayId); Assert.Equal(previous.Url, _settings.GatewayUrl); + Assert.True(_settings.HasPersistedGatewayUrl); + Assert.Equal(previous.Url, _settings.GetLegacyCredentialGatewayUrlOrNull()); Assert.Equal( "operator-old", DeviceIdentity.TryReadStoredDeviceTokenForRole( @@ -270,6 +273,49 @@ public async Task Connect_Failure_RestoresRegistrySettingsAndIdentity() Assert.Equal(2, _tunnelReconcileCount); } + [Fact] + public async Task Connect_FailureFromUrlLessProfile_DoesNotPersistSetupGateway() + { + Assert.False(_settings.HasPersistedGatewayUrl); + Assert.Null(_settings.GetLegacyCredentialGatewayUrlOrNull()); + var identity = new DeviceIdentity(_tempDir); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "operator-role-token"); + _manager.NextSnapshot = Failed("candidate", "rejected"); + + var result = await CreateService().ConnectAsync(new GatewayDirectConnectRequest( + "wss://candidate.example", + SharedToken: null, + FriendlyName: "Candidate", + SshTunnel: null)); + + Assert.Equal(GatewayDirectConnectOutcome.Failed, result.Outcome); + Assert.False(result.GatewayCommitted); + Assert.False(result.RollbackIncomplete); + Assert.Empty(_registry.GetAll()); + Assert.False(_settings.HasPersistedGatewayUrl); + Assert.Null(_settings.PersistedGatewayUrl); + Assert.Null(_settings.GetLegacyCredentialGatewayUrlOrNull()); + Assert.NotEqual("wss://candidate.example", _settings.GatewayUrl); + var savedPath = Path.Combine(_tempDir, "settings.json"); + Assert.True(File.Exists(savedPath)); + using var saved = JsonDocument.Parse(File.ReadAllText(savedPath)); + var savedUrl = saved.RootElement.TryGetProperty("GatewayUrl", out var gatewayUrl) && + gatewayUrl.ValueKind == JsonValueKind.String + ? gatewayUrl.GetString() + : null; + Assert.True(string.IsNullOrWhiteSpace(savedUrl)); + Assert.False(InteractiveGatewayCredentialResolver.TryResolve( + registry: null, + _tempDir, + DeviceIdentityFileReader.Instance, + _settings.GetLegacyCredentialGatewayUrlOrNull(), + legacyToken: null, + legacyBootstrapToken: null, + out var credential)); + Assert.Null(credential); + } + [Fact] public async Task Connect_Failure_RestoresPreviousLiveConnection() { diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index 9a5e49c9c..2fdacde46 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -182,6 +182,94 @@ public void MissingGatewayUrl_DoesNotExposeLegacyTokensForDefaultUrl() } } + [Fact] + public void FailedSave_DoesNotAdmitSetupGatewayForLegacyRootIdentity() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(dir); + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder", + "EnableNodeMode": true + } + """); + var identity = new DeviceIdentity(dir); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "operator-role-token"); + + var settings = new SettingsManager(dir); + Assert.False(settings.HasPersistedGatewayUrl); + Assert.Null(settings.PersistedGatewayUrl); + var other = new SettingsManager(dir) { NotificationSound = "external" }; + other.SaveOrThrow(); + + Assert.Throws(() => + settings.UpdateAndSave(() => settings.GatewayUrl = settings.GatewayUrl)); + + Assert.False(settings.HasPersistedGatewayUrl); + Assert.Null(settings.PersistedGatewayUrl); + Assert.Null(settings.GetLegacyCredentialGatewayUrlOrNull()); + Assert.Equal(OpenClawTray.AppIdentity.SetupGatewayUrl, settings.GetEffectiveGatewayUrl()); + Assert.False(ResolveInteractive(dir, settings, null, null, out var credential)); + Assert.Null(credential); + Assert.False(ResolveInteractive( + dir, + settings, + "test-auth-token", + "test-token-placeholder", + out var rawCredential)); + Assert.Null(rawCredential); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } + + [Fact] + public void FailedSave_KeepsAnExplicitGatewayTarget() + { + var dir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + + try + { + var settings = new SettingsManager(dir) + { + GatewayUrl = "wss://saved.example.invalid", + }; + settings.SaveOrThrow(); + var other = new SettingsManager(dir) { NotificationSound = "external" }; + other.SaveOrThrow(); + + Assert.Throws(() => + settings.UpdateAndSave(() => settings.GatewayUrl = "wss://attempt.example.invalid")); + + Assert.True(settings.HasPersistedGatewayUrl); + Assert.Equal("wss://saved.example.invalid", settings.PersistedGatewayUrl); + Assert.Equal( + "wss://saved.example.invalid", + settings.GetLegacyCredentialGatewayUrlOrNull()); + } + finally + { + if (Directory.Exists(dir)) + { + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { Directory.Delete(dir, recursive: true); } catch { } + } + } + } + [Fact] public void ExplicitGatewayUrl_BecomesPersistedLegacyCredentialTarget() { From 632aae785f9cedfd659be2453693192073388083 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 7 Oct 2026 08:11:53 -0700 Subject: [PATCH 9/9] test(setup): show saved-gateway I/O and setup-port rejection A loopback listener accepted one TCP chat request for an explicitly saved gateway. The request line was GET /chat with a token query. After a failed save and a failed direct connect from a URL-less profile, this process had no TCP connection to the setup port. build.ps1 succeeded. Shared tests passed 4263, skipped 33. Tray tests passed 4017. Signed-off-by: Sebastien Tardif --- .../GatewayDirectConnectServiceTests.cs | 40 +++++ .../SettingsManagerIsolationTests.cs | 156 +++++++++++++++++- 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs index a75f4a60d..c5fe1030c 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json; using OpenClaw.Connection; using OpenClaw.Shared; @@ -278,6 +279,8 @@ public async Task Connect_FailureFromUrlLessProfile_DoesNotPersistSetupGateway() { Assert.False(_settings.HasPersistedGatewayUrl); Assert.Null(_settings.GetLegacyCredentialGatewayUrlOrNull()); + var setupPort = OpenClawTray.AppIdentity.SetupGatewayPort; + var before = CountOwnTcpConnectionsToPort(setupPort); var identity = new DeviceIdentity(_tempDir); identity.Initialize(); identity.StoreDeviceTokenForRole("operator", "operator-role-token"); @@ -314,6 +317,43 @@ public async Task Connect_FailureFromUrlLessProfile_DoesNotPersistSetupGateway() legacyBootstrapToken: null, out var credential)); Assert.Null(credential); + var after = CountOwnTcpConnectionsToPort(setupPort); + Assert.Equal(before, after); + Console.WriteLine( + $"DIRECT_CONNECT_ROLLBACK setup_port={setupPort} own_tcp_before={before} own_tcp_after={after}"); + } + + private static int CountOwnTcpConnectionsToPort(int port) + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "netstat", + Arguments = "-ano -p tcp", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }); + if (process is null) + return -1; + var text = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + var suffix = ":" + port; + var pid = Environment.ProcessId.ToString(); + var count = 0; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (!line.StartsWith("TCP", StringComparison.OrdinalIgnoreCase)) + continue; + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5 || parts[^1] != pid) + continue; + if (parts[1].EndsWith(suffix, StringComparison.Ordinal) || + parts[2].EndsWith(suffix, StringComparison.Ordinal)) + count++; + } + + return count; } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index 2fdacde46..1385973b9 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -1,8 +1,13 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Chat; using OpenClawTray.Services; -using System.Text.Json; namespace OpenClaw.Tray.Tests; @@ -402,6 +407,155 @@ public void ClearedLegacyTokens_DoNotResolveInteractiveAppOrChatCredential() } } + [Fact] + public async Task ExplicitSavedGateway_ReceivesCredentialOnItsSocket_RollbackDoesNotOpenSetupPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var requestHead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var accept = AcceptOneHttpRequestAsync(listener, requestHead); + var savedDir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + var urlLessDir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(savedDir); + var gatewayUrl = $"ws://127.0.0.1:{port}"; + File.WriteAllText( + Path.Combine(savedDir, "settings.json"), + $$""" + { + "GatewayUrl": "{{gatewayUrl}}", + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder" + } + """); + var saved = new SettingsManager(savedDir); + Assert.Equal(gatewayUrl, saved.GetLegacyCredentialGatewayUrlOrNull()); + Assert.True(ResolveInteractive( + savedDir, + saved, + saved.LegacyToken, + saved.LegacyBootstrapToken, + out var credential)); + var chatUrl = ChatUrlFromResolvedCredential(credential); + Assert.NotNull(chatUrl); + Assert.Contains($":{port}/chat?token=", chatUrl, StringComparison.Ordinal); + Assert.DoesNotContain( + $":{OpenClawTray.AppIdentity.SetupGatewayPort}/", + chatUrl, + StringComparison.Ordinal); + + using var handler = new HttpClientHandler { UseProxy = false }; + using var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) }; + using var response = await client.GetAsync(chatUrl); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var head = await requestHead.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var requestLine = head.Split('\r', '\n')[0]; + Assert.StartsWith("GET /chat?token=", requestLine, StringComparison.Ordinal); + var tokenAt = requestLine.IndexOf("token=", StringComparison.Ordinal); + var tokenValue = requestLine[(tokenAt + "token=".Length)..].Split(' ', '&')[0]; + Assert.False(string.IsNullOrWhiteSpace(tokenValue)); + Console.WriteLine( + $"ALLOWED_LISTENER port={port} tcp_accepts=1 request=GET /chat token_query=present"); + + var setupPort = OpenClawTray.AppIdentity.SetupGatewayPort; + var before = CountOwnTcpConnectionsToPort(setupPort); + Directory.CreateDirectory(urlLessDir); + File.WriteAllText( + Path.Combine(urlLessDir, "settings.json"), + """ + { + "Token": "test-auth-token", + "BootstrapToken": "test-token-placeholder", + "EnableNodeMode": true + } + """); + var identity = new DeviceIdentity(urlLessDir); + identity.Initialize(); + identity.StoreDeviceTokenForRole("operator", "operator-role-token"); + var urlLess = new SettingsManager(urlLessDir); + var other = new SettingsManager(urlLessDir) { NotificationSound = "external" }; + other.SaveOrThrow(); + Assert.Throws(() => + urlLess.UpdateAndSave(() => urlLess.GatewayUrl = urlLess.GatewayUrl)); + Assert.Null(urlLess.GetLegacyCredentialGatewayUrlOrNull()); + Assert.False(ResolveInteractive(urlLessDir, urlLess, null, null, out var rejected)); + Assert.Null(rejected); + var after = CountOwnTcpConnectionsToPort(setupPort); + Assert.Equal(before, after); + Console.WriteLine( + $"SETUP_PORT port={setupPort} own_tcp_before={before} own_tcp_after={after}"); + } + finally + { + listener.Stop(); + // slopwatch-ignore: SW003 Test cleanup is best-effort and must not hide the assertion. + try { await accept.WaitAsync(TimeSpan.FromSeconds(2)); } catch { } + foreach (var path in new[] { savedDir, urlLessDir }) + { + if (!Directory.Exists(path)) + continue; + try { Directory.Delete(path, recursive: true); } catch { } + } + } + } + + private static async Task AcceptOneHttpRequestAsync( + TcpListener listener, + TaskCompletionSource requestHead) + { + using var client = await listener.AcceptTcpClientAsync(); + using var stream = client.GetStream(); + using var readLimit = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var buffer = new byte[1024]; + var head = new StringBuilder(); + while (head.Length < 8192 && !head.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + var read = await stream.ReadAsync(buffer, readLimit.Token); + if (read == 0) + break; + head.Append(Encoding.ASCII.GetString(buffer, 0, read)); + } + + requestHead.TrySetResult(head.ToString()); + var response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"u8.ToArray(); + await stream.WriteAsync(response); + } + + private static int CountOwnTcpConnectionsToPort(int port) + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = "netstat", + Arguments = "-ano -p tcp", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }); + if (process is null) + return -1; + var text = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + var suffix = ":" + port; + var pid = Environment.ProcessId.ToString(); + var count = 0; + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim(); + if (!line.StartsWith("TCP", StringComparison.OrdinalIgnoreCase)) + continue; + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 5 || parts[^1] != pid) + continue; + if (parts[1].EndsWith(suffix, StringComparison.Ordinal) || + parts[2].EndsWith(suffix, StringComparison.Ordinal)) + count++; + } + + return count; + } + private static bool ResolveInteractive( string settingsDirectory, SettingsManager settings,