diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index ed47c79b9..6e1cc1d6f 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -793,6 +793,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 b5c4cdd5a..0391cbd54 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -622,6 +622,19 @@ 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. +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. + --- ## UI Flow diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index e87a0239e..87f9e01eb 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -568,9 +568,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']) { @@ -590,7 +592,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/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.SetupEngine/TrayArtifactCleanup.cs b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs index 39973bba4..8e1396b48 100644 --- a/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs +++ b/src/OpenClaw.SetupEngine/TrayArtifactCleanup.cs @@ -163,12 +163,16 @@ 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 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")) + changed = true; + if (dict.Remove("BootstrapToken")) changed = true; - } if (!preserveNodeSettings && dict.ContainsKey("EnableNodeMode")) { @@ -187,8 +191,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 ced073008..6ce52469f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1840,7 +1840,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(); @@ -2115,7 +2115,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)) { @@ -2136,7 +2136,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)) { @@ -2192,7 +2192,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)) { @@ -3971,7 +3971,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 e6050159a..e8556b992 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChatPage.xaml.cs @@ -826,7 +826,7 @@ private async Task ApplyWebViewSurfaceAsync(SettingsManager settings, int genera // Capture on the UI thread (continuation resumes here); resolve off it. var registry = CurrentApp.Registry; var authorizer = CurrentApp.InteractiveEndpointAuthorizer; - var gatewayUrl = settings.GetEffectiveGatewayUrl(); + var gatewayUrl = settings.GetLegacyCredentialGatewayUrlOrNull(); var legacyToken = settings.LegacyToken; var legacyBootstrapToken = settings.LegacyBootstrapToken; credential = await Task.Run(() => ResolveChatCredential( 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 b44e2fc6f..4e91dd23a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -43,7 +43,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 }; } @@ -54,6 +62,22 @@ 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; } + + /// + /// 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); @@ -229,6 +253,7 @@ public void Load() _persistedJson = null; LegacyToken = null; LegacyBootstrapToken = null; + HasPersistedGatewayUrl = false; _data = CreateDefaultData(); try @@ -251,6 +276,7 @@ public void Load() Logger.Warn($"Failed to load settings: {ex.Message}"); LegacyToken = null; LegacyBootstrapToken = null; + HasPersistedGatewayUrl = false; } if (loadSucceeded && _hasPersistenceConflict) { @@ -345,7 +371,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, @@ -412,10 +438,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"); } @@ -440,7 +472,7 @@ private void LoadLegacyGatewayCredentials(string json) /// public SettingsData ToSettingsData() => _data with { - GatewayUrl = GatewayUrl, + GatewayUrl = HasPersistedGatewayUrl ? _data.GatewayUrl : null, SshTunnelUser = SshTunnelUser, SshTunnelHost = SshTunnelHost, SshTunnelRemotePort = SshTunnelRemotePort, @@ -608,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; + } } } @@ -698,4 +744,7 @@ public string GetEffectiveGatewayUrl() return $"ws://127.0.0.1:{SshTunnelLocalPort}"; } + + public string? GetLegacyCredentialGatewayUrlOrNull() => + HasPersistedGatewayUrl ? GetEffectiveGatewayUrl() : null; } 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)); diff --git a/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs b/tests/OpenClaw.Connection.Tests/InteractiveGatewayCredentialResolverTests.cs index 11397c457..1f49c27dd 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); @@ -122,7 +122,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); @@ -138,7 +138,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); @@ -154,7 +154,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); } @@ -166,11 +166,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, @@ -183,7 +183,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); } @@ -196,18 +196,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.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 1ad84a4ce..29cb8c560 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -803,6 +803,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 73a94b7eb..1dc0bd1c0 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -449,6 +449,37 @@ public void LegacyCredentialMigration_StaysRegistryBacked() 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/GatewayDirectConnectServiceTests.cs b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs index 7f7601f4f..c5fe1030c 100644 --- a/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/GatewayDirectConnectServiceTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.Text.Json; using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Services; @@ -257,6 +259,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 +274,88 @@ 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 setupPort = OpenClawTray.AppIdentity.SetupGatewayPort; + var before = CountOwnTcpConnectionsToPort(setupPort); + 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); + 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] public async Task Connect_Failure_RestoresPreviousLiveConnection() { diff --git a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs index af1ab8661..1385973b9 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsManagerIsolationTests.cs @@ -1,5 +1,13 @@ -using OpenClawTray.Services; +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; namespace OpenClaw.Tray.Tests; @@ -76,8 +84,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 } """); @@ -86,8 +94,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(); @@ -131,4 +139,468 @@ 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": "test-auth-token", + "BootstrapToken": "test-token-placeholder", + "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); + 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 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() + { + 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 + { + 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 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": "test-auth-token", + "BootstrapToken": "test-token-placeholder", + "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.False(ResolveInteractive( + dir, + cleared, + "test-auth-token", + "test-token-placeholder", + out var rawCredential)); + Assert.Null(rawCredential); + Assert.Null(ChatUrlFromResolvedCredential(rawCredential)); + + File.WriteAllText( + Path.Combine(dir, "settings.json"), + """ + { + "GatewayUrl": "wss://saved.example.invalid", + "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("test-auth-token", savedCredential!.Token); + Assert.Contains( + "test-auth-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 { } + } + } + } + + [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, + string? legacyToken, + string? legacyBootstrapToken, + out InteractiveGatewayCredential? credential) => + InteractiveGatewayCredentialResolver.TryResolve( + registry: null, + settingsDirectory, + DeviceIdentityFileReader.Instance, + settings.GetLegacyCredentialGatewayUrlOrNull(), + 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, "async Task ApplyWebViewSurfaceAsync("); + + 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("ResolveChatCredential", chatUrl, StringComparison.Ordinal); + Assert.Contains("ChatSurfaceResolver.BuildChatUrl", chatUrl, StringComparison.Ordinal); + Assert.DoesNotContain("settings.GetEffectiveGatewayUrl()", chatUrl, 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..]; + } } diff --git a/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs b/tests/OpenClaw.Tray.Tests/UninstallOnboardingSettingsTests.cs new file mode 100644 index 000000000..d2d0ff887 --- /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"] = "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":"test-token-placeholder"}]}"""; + 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("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)); + } + + [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":"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); + + 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("gateway-token", log); + Assert.DoesNotContain("test-auth-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()); + } +}