From 0abcdf4c0e996112a3832bf9e4c16f7d8f7a5eb7 Mon Sep 17 00:00:00 2001 From: Joel Fernandes Date: Mon, 12 Oct 2026 19:48:27 -0400 Subject: [PATCH 1/2] fix(setup): clean up owned Gateways after Companion uninstall Register user-scoped cleanup outside the Companion MSIX so Windows Settings removal retires verified Companion-owned Gateways. Bind WSL ownership to its registration and preserve borrowed, replaced, or shared native installations. Settle pending cleanup before reconnecting, retire removed managed connection credentials, and offer preservation when cleanup cannot safely proceed. Check Store Gateway updates while retaining development package selection. --- docs/ARCHITECTURE.md | 11 +- docs/ONBOARDING_WIZARD.md | 12 +- docs/SETUP_ENGINE_REDESIGN.md | 6 +- docs/uninstall-msix.md | 22 +- .../Pages/CapabilitiesPage.xaml | 2 +- .../Pages/NativeGatewaySetupPage.xaml.cs | 19 +- .../CreateWslInstanceStep.cs | 2 + .../GatewayPackageCleanup.cs | 155 ++++++++++++++ .../GatewayPackageCleanup.ps1 | 200 ++++++++++++++++++ .../ManagedDistroOwnership.cs | 4 +- .../NativeGatewayMsixInstaller.cs | 5 +- .../NativeGatewayPackageAcquisition.cs | 6 +- .../OpenClaw.SetupEngine.csproj | 1 + src/OpenClaw.Tray.WinUI/App.xaml.cs | 22 ++ .../Pages/SettingsPage.xaml | 4 +- .../Services/NativeRestartAdmission.cs | 4 + .../Strings/en-us/Resources.resw | 6 +- .../Strings/fr-fr/Resources.resw | 6 +- .../Strings/nl-nl/Resources.resw | 6 +- .../Strings/pt-br/Resources.resw | 6 +- .../Strings/zh-cn/Resources.resw | 6 +- .../Strings/zh-tw/Resources.resw | 6 +- .../NativeGatewayMsixInstallerTests.cs | 13 +- .../NativeGatewayPackageAcquisitionTests.cs | 23 +- .../NativeGatewaySetupUxContractTests.cs | 2 +- 25 files changed, 487 insertions(+), 62 deletions(-) create mode 100644 src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41c88c7731..5350ff0faf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,7 +108,7 @@ disconnect, replacement, or close. This cache is not used for authorization. | Trusted Store and existing development Gateway registration identities | `NativeGatewayPackageIdentity` | authoritative | | Current-user Gateway package registration, health and package-qualified alias discovery | `NativeGatewayPackageResolver` | authoritative | | Prospective Companion-to-native-Gateway state/config/workspace path mapping | `NativeGatewayDataPathMapper`; `NativeGatewayPackageResolver` supplies package and known-folder context. Independent of existing-log resolution | authoritative | -| Missing-package acquisition, one installation attempt and bounded registration verification | `NativeGatewayPackageAcquisition` | authoritative | +| Store package install/update, one acquisition attempt and bounded registration verification | `NativeGatewayPackageAcquisition` | authoritative | | Shared Windows capability and permission selection, with runtime-specific install review | `CapabilitiesPage` | authoritative | | Native profile draft creation and canonical state/config launch paths | `NativeGatewaySetupService` + `NativeGatewayPaths` | authoritative | | Native onboarding capability admission and default/remembered gateway choice policy | `NativeGatewaySetupEligibility`, consuming `MxcAvailability` session probe metadata | authoritative | @@ -213,6 +213,7 @@ disconnect, replacement, or close. This cache is not used for authorization. | Post-save settings change effect ordering, detached snapshot comparison, and concurrent save serialization | `SettingsChangeCoordinator` | authoritative | | Exactly-once ordered app shutdown sequencing | `AppShutdownCoordinator` | authoritative | | App composition-root startup sequencing | `AppBootstrapper` (planned) | planned | +| Packaged uninstall cleanup, preservation recovery and removed-target reconciliation | `GatewayPackageCleanup`; App only invokes its startup gate and presents the preservation choice | authoritative | | Inno migration records, preparation, and pre-start completion guard | `MigrationRecordCodec`, `MigrationPreparation`, `MigrationInventory`, `InnoMigrationStartupGuard` | authoritative | | Cross-session migration exclusion and source activity inspection | `MigrationOperationLock`, `InnoSourceActivityVerifier` (App only retains the runtime handle through process exit) | authoritative | | Store migration startup admission and finalization | `InnoInstallationDetector`, `InnoSourceRemovalVerifier`, `MigrationStartupRecordReader`, `MigrationInventoryCapture`, `StoreMigrationStartupCoordinator`, `StoreMigrationFinalizationCoordinator`, `StoreMigrationStartupGuard` | authoritative | @@ -269,7 +270,7 @@ classifies these unavailable probes as network failures. `NativeGatewayMsixInstaller.InstallAsync` invokes the signed-in user's App Installer alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\winget.exe`) through the existing `CommandRunner`, without a shell or elevation. The fixed command is -`install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity --no-upgrade`. +`install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity`. The native review explains that selecting **Set up gateway** authorizes installation and accepts the package and Store source agreements. Microsoft Store still owns architecture/package selection, signature validation and deployment. There is no @@ -328,9 +329,9 @@ unset, selection, resolution and aliases are identical to the Store path. After native capability/permission review, `NativeGatewaySetupPage` starts automatically. It rechecks device support, then calls -`NativeGatewayPackageAcquisition.EnsureAsync`. Only the typed -`NativeGatewayPackageNotInstalledException` starts WinGet installation, once per attempt. -Healthy registration skips installation; duplicate registration, unhealthy packages +`NativeGatewayPackageAcquisition.EnsureAsync`. Missing packages and existing Store +packages start WinGet install/update once per attempt; development packages skip it. +Duplicate registration, unhealthy packages and missing aliases fail explicitly instead of triggering reinstall loops. The cancellable acquisition deadline is five minutes including installation and registration verification, with one-second polling after WinGet completes. diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index b8307a61b6..3ddb502958 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -194,15 +194,15 @@ from `PATH`. Native setup uses the same Windows capabilities and permission selection as WSL, with native package installation consent on that same page. Selecting **Set up gateway** starts native progress without WSL, Local AI or Tailscale provisioning and without an additional review page. -A missing package is installed automatically using -`winget install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity --no-upgrade`; +A Store package is installed or refreshed automatically using +`winget install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity`; unhealthy registration or unavailable aliases show an explicit repair error. The fixed product is [OpenClaw Gateway in Microsoft Store](https://apps.microsoft.com/detail/9NV70LV3D6XC). No MSIX version or download URL is pinned; Store selects the current compatible -release. Existing healthy installations are not silently upgraded during setup. +release. Native setup checks for available Store updates before reusing a healthy Store package. Existing development profiles keep their package identity. To explicitly refresh one, run `winget install --id 9NV70LV3D6XC --source msstore --accept-package-agreements --accept-source-agreements` -without `--no-upgrade`. WinGet may report that no newer package is available. +WinGet reporting that no newer package is available is a successful no-op. An unfinished draft saved against a different package family (for example, the older development Gateway) offers **Discard and set up again**, just like a @@ -241,8 +241,8 @@ runs one current-user `Repair-WinGetPackageManager -Force -Latest` bootstrap and The repair installs Microsoft's WinGet client module; it never bypasses certificate validation. Other failures show explicit repair guidance and **Retry setup**. The normal path has no separate install, availability-check or wizard-launch buttons. -An already installed healthy package skips installation. Retry checks registration -again before invoking WinGet. Companion does not download Gateway packages directly, +An already installed healthy development/patch package skips installation. Store packages are checked for updates. +Retry checks registration again before invoking WinGet. Companion does not download Gateway packages directly, change certificate trust, or uninstall packages on cancellation. Progress reuses the WSL spinner/checkmark rows. Normal completion uses the same diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index b5c4cdd5ab..4b07989a95 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -117,7 +117,7 @@ the owned `NativeGatewaySetupConnection` authorization and upstream checks Windows package registration and package-qualified aliases. `NativeGatewayMsixInstaller` installs the fixed Store product using the current-user App Installer alias and `CommandRunner`: -`winget install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity --no-upgrade`. +`winget install --id 9NV70LV3D6XC --source msstore --silent --accept-package-agreements --accept-source-agreements --disable-interactivity`. The shared capabilities page shows the native installation and agreement disclosure before its **Set up gateway** action; other routes retain **Next**. This preserves consent without adding a separate native review step. @@ -129,8 +129,8 @@ diagnostics and retry guidance instead of opening a manual Store page. the original development identity for existing installations. Multiple matching registrations fail explicitly for new setup. Existing runtime profiles resolve only their saved family, allowing both packages to coexist without an implicit migration. -`NativeGatewayPackageAcquisition` invokes installation once only for missing -registration. Installation and verified package readiness share a five-minute +`NativeGatewayPackageAcquisition` invokes acquisition once for a missing or existing Store +package; an existing development package is reused without Store migration. Installation and verified package readiness share a five-minute deadline. Cancellation stops the WinGet request, but Windows deployment may continue. Repair errors and timeouts stay visible, with explicit retry rather than repeated installer launches; retries recheck registration before installing. diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md index 083de59be1..647d0a872d 100644 --- a/docs/uninstall-msix.md +++ b/docs/uninstall-msix.md @@ -5,7 +5,7 @@ --- -## MSIX Cannot Auto-Clean WSL State +## Automatic cleanup for managed Gateways **Feasibility verdict:** `runFullTrust` MSIX packages do **not** support a `CustomInstall` / `CustomUninstall` extension that runs an arbitrary EXE @@ -13,8 +13,22 @@ at uninstall time. The supported extension points (`windows.startupTask`, `windows.appExecutionAlias`, `com.extension`, `windows.protocol`, etc.) do not include an uninstall hook. -Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray -→ Uninstall** will silently leave behind: +Companion registers a user-scoped cleanup helper outside its MSIX payload when it +creates a managed WSL Gateway or acquires a missing native Store Gateway. The helper +removes the captured WSL registration and disk directory, or the native package it +acquired, after Companion removal. WSL ownership requires its creation marker and +matching registered disk path and registration identity. Replacements, unknown directories +and unreadable inventories are preserved. Native ownership is recorded only after verified +acquisition and bound to the Gateway's LocalState marker; other installed Companion variants +defer removal of the shared package. Unverified failed or cancelled acquisitions remain unclaimed. +The package-local ownership marker and successful Windows Remove receipt +let first launch settle cleanup after a fast reinstall; normal updates retain the +marker. Older owned installations are adopted prospectively, without treating historical +uninstalls as authority to delete a currently used Gateway. A pending conflict offers +preservation and continuation. Startup retires only removed managed connections and their +credentials, preserving unrelated Gateways, settings and local MCP state. + +Older installations without verified ownership may still leave behind: - **WSL distro** - `OpenClawGateway` remains in `wsl --list`. - **Roaming app data** under `%APPDATA%\OpenClawTray\` (device key, settings, @@ -30,7 +44,7 @@ Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray --- -## Recommended: Run "Remove Local Gateway" Before Uninstalling MSIX +## Remove a Gateway while keeping Companion, or clean up an older installation 1. Open the tray icon. 2. Navigate to **Settings → Local Gateway**. diff --git a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml index 1cdc840069..a560edf575 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml @@ -17,7 +17,7 @@ + Text="By selecting Set up gateway, you authorize WinGet to install or update the Gateway package from Microsoft Store and accept the package and Store source agreements. If WinGet is not ready, setup may repair App Installer before retrying. Companion then verifies the package, prepares a dedicated profile, and opens Gateway setup. Provider sign-in stays interactive. This path does not install WSL, Local AI, or Tailscale." /> diff --git a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs index c1c3fea36c..6446c2674a 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/NativeGatewaySetupPage.xaml.cs @@ -141,13 +141,16 @@ private async Task ConfigureAsync(CancellationToken cancellationToken) throw new InvalidOperationException(NativeGatewayEligibilityText.Get(eligibility)); SetCurrentStep(1); StatusText.Text = SetupLocalization.GetString("Onboarding_Native_Checking"); + var acquiredMissingPackage = false; await NativeGatewayPackageAcquisition.EnsureAsync( - _resolver, InstallAsync, + _resolver, async ct => { acquiredMissingPackage = await InstallAsync(ct); }, () => StatusText.Text = SetupLocalization.GetString("Onboarding_Native_VerifyingPackage"), cancellationToken); + using var logger = new SetupLogger(filePath: null); + if (acquiredMissingPackage) + await GatewayPackageCleanup.RegisterAsync(new CommandRunner(logger), ownNativePackage: true, ct: CancellationToken.None); cancellationToken.ThrowIfCancellationRequested(); SetCurrentStep(2); - using var logger = new SetupLogger(filePath: null); var appLogger = new SetupOpenClawLogger(logger); var registry = new GatewayRegistry(window.DataDir, logger: appLogger); var progressDispatcher = DispatcherQueue; @@ -185,11 +188,19 @@ void ReportStage(NativeGatewaySetupStage stage) => DispatchProgress(() => window.NavigateToNativeAiSetup(session); } - private async Task InstallAsync(CancellationToken cancellationToken) + private async Task InstallAsync(CancellationToken cancellationToken) { StatusText.Text = SetupLocalization.GetString("Onboarding_Native_InstallingPackage"); using var logger = new SetupLogger(filePath: null); - await _installer.InstallAsync(new CommandRunner(logger), cancellationToken); + var commands = new CommandRunner(logger); + var missing = false; + try { await _resolver.ResolveAsync(cancellationToken); } + catch (NativeGatewayPackageNotInstalledException) + { + missing = true; + } + await _installer.InstallAsync(commands, cancellationToken); + return missing; } private void SetCurrentStep(int index) diff --git a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs index 68dd1c0ba0..69772da766 100644 --- a/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs +++ b/src/OpenClaw.SetupEngine/CreateWslInstanceStep.cs @@ -91,6 +91,8 @@ or ArgumentException $"Fresh WSL install failed for '{distro}' from '{baseDistro}' (exit {install.ExitCode}): {FirstNonEmpty(install.Stderr, install.Stdout)}{cleanupError}"); } + await GatewayPackageCleanup.RegisterAsync(ctx.Commands, distro, installPath, ct: ct); + var verify = await VerifyFreshDistro(ctx, distro, installPath, ct); if (!verify.IsSuccess) { diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs new file mode 100644 index 0000000000..d35a5248e1 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs @@ -0,0 +1,155 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenClaw.Connection; +using OpenClaw.Shared; + +namespace OpenClaw.SetupEngine; + +/// Registers user-scoped cleanup that survives removal of the Companion MSIX. +public static class GatewayPackageCleanup +{ + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetCurrentPackageFamilyName(ref uint length, StringBuilder family); + + public static async Task InitializeAsync(ICommandRunner commands, string dataDir, string localDataDir, + string distroName, CancellationToken ct = default, Func? preserve = null) + { + if (!OperatingSystem.IsWindows()) return true; + try + { + var directory = await RunAsync(commands, false, null, null, false, ct); + if (directory is not null) await ReconcileAsync(directory, dataDir, localDataDir, ct); + if (directory is not null && File.Exists(Path.Combine(directory, "owned.json"))) + await RunAsync(commands, true, null, null, false, ct); + if (ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distroName) && + DistroInstallPathPolicy.TryGetManagedInstallPath(localDataDir, distroName, out var path, out _) && + ManagedDistroOwnership.HasRegisteredDistroEvidence(dataDir, localDataDir, distroName, path, + new WindowsWslRegistrationInspector(), out _)) + { + // Adopt retained installations prospectively. Past uninstall receipts do not authorize removal. + await RunAsync(commands, true, distroName, path, false, ct); + } + return true; + } + catch (Exception error) when (preserve is not null && error is (IOException or UnauthorizedAccessException or InvalidOperationException)) + { + if (!preserve(error.Message)) return false; + var directory = await RunAsync(commands, false, null, null, false, ct, preserve: true); + if (directory is not null) await ReconcileAsync(directory, dataDir, localDataDir, ct); + return true; + } + } + + public static Task RegisterAsync(ICommandRunner commands, string? distroName = null, + string? installPath = null, bool ownNativePackage = false, CancellationToken ct = default) => + RunAsync(commands, true, distroName, installPath, ownNativePackage, ct, createdDistro: distroName is not null); + + private static async Task RunAsync(ICommandRunner commands, bool register, string? distroName, + string? installPath, bool ownNativePackage, CancellationToken ct, bool preserve = false, bool createdDistro = false) + { + if (!OperatingSystem.IsWindows() || + Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 }) return null; + uint length = 256; + var family = new StringBuilder((int)length); + var result = GetCurrentPackageFamilyName(ref length, family); + if (result == 15700) return null; // Unpackaged installations retain their existing uninstaller. + if (result != 0) throw new InvalidOperationException("Companion package identity could not be read."); + var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".openclaw-companion", "cleanup", family.ToString()); + if (!register && !preserve && !File.Exists(Path.Combine(directory, "owned.json"))) return directory; + Directory.CreateDirectory(directory); + var scriptPath = Path.Combine(directory, "cleanup.ps1"); + using var source = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( + "OpenClaw.SetupEngine.GatewayPackageCleanup.ps1") + ?? throw new InvalidOperationException("The Gateway cleanup helper is missing."); + using var reader = new StreamReader(source); + await AtomicFile.WriteAllTextAsync(scriptPath, await reader.ReadToEndAsync(ct), ct); + var arguments = new List { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", scriptPath, "-Family", family.ToString() }; + if (register) arguments.Add("-Register"); + if (preserve) arguments.Add("-Preserve"); + if (createdDistro) arguments.Add("-CreatedDistro"); + if (distroName is not null) arguments.AddRange(["-DistroName", distroName, "-InstallPath", installPath!]); + if (ownNativePackage) arguments.Add("-OwnNativePackage"); + var registration = await commands.RunAsync(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), arguments.ToArray(), + TimeSpan.FromSeconds(register ? 30 : 180), ct: ct); + if (registration.TimedOut || registration.ExitCode != 0) + { + var diagnostic = SetupLogger.Sanitize(registration.Stderr); + throw new InvalidOperationException(register + ? "Automatic Gateway cleanup could not be registered. " + diagnostic[..Math.Min(diagnostic.Length, 2048)] + : "Previous managed Gateway cleanup remains pending. Reopen Companion to retry. " + diagnostic[..Math.Min(diagnostic.Length, 2048)]); + } + return directory; + } + + private sealed record Removal(string? DistroName, string? InstallPath, string? NativeFamily); + + private static async Task ReconcileAsync(string directory, string dataDir, string localDataDir, CancellationToken ct) + { + var receipt = Path.Combine(directory, "cleanup-result.json"); + if (!File.Exists(receipt)) return; + FileStream lease; + for (var attempt = 0; ; attempt++) + { + try { lease = File.Open(Path.Combine(directory, "cleanup.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); break; } + catch (IOException) when (attempt < 100) { await Task.Delay(100, ct); } + } + using (lease) + { + if (!File.Exists(receipt)) return; + var removals = JsonSerializer.Deserialize(File.ReadAllText(receipt)) + ?? throw new InvalidDataException("The Gateway cleanup receipt is invalid."); + var registry = new GatewayRegistry(dataDir); + registry.Load(); + var removed = registry.GetAll().Where(record => record.IsLocal && record.SshTunnel is null && removals.Any( + removal => removal.DistroName is { Length: > 0 } distro && string.Equals( + GatewayRecordEditing.ResolveManagedDistroName(record), distro, StringComparison.OrdinalIgnoreCase) || + removal.NativeFamily is { Length: > 0 } family && record.NativePackageFamilyName == family)).ToArray(); + foreach (var record in removed) + if (!Guid.TryParse(record.Id, out _) || !DeviceIdentity.BeginClearAllDeviceTokens(registry.GetIdentityDirectory(record.Id)).Success) + throw new IOException("The removed Gateway's credentials could not be retired."); + var settingsPath = Path.Combine(dataDir, "settings.json"); + var statePath = Path.Combine(localDataDir, "setup-state.json"); + JsonObject? state = null; + try { if (File.Exists(statePath)) state = JsonNode.Parse(File.ReadAllText(statePath)) as JsonObject; } + catch (JsonException) { } // Invalid state cannot bind legacy credentials to a removed Gateway. + var retiredState = removals.Any(removal => removal.DistroName is { Length: > 0 } distro && + string.Equals(state?["DistroName"]?.GetValue(), distro, StringComparison.OrdinalIgnoreCase) && + ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distro)); + using (PersistenceFileLease.Acquire(settingsPath)) + { + if (File.Exists(settingsPath) && JsonNode.Parse(File.ReadAllText(settingsPath)) is JsonObject settings) + { + var legacyUrl = settings["GatewayUrl"]?.GetValue(); + if (settings["UseSshTunnel"]?.GetValue() != true && (removed.Any(record => record.Url == legacyUrl || + GatewayRecordEditing.AreEquivalentLoopbackEndpoints(record.Url, legacyUrl) || + string.IsNullOrWhiteSpace(legacyUrl) && record.Id == registry.ActiveGatewayId) || + retiredState && GatewayRecordEditing.AreEquivalentLoopbackEndpoints(legacyUrl, state?["GatewayUrl"]?.GetValue()))) + { + if (!DeviceIdentity.BeginClearAllDeviceTokens(dataDir).Success) + throw new IOException("The removed Gateway's legacy credentials could not be retired."); + settings["GatewayUrl"] = ""; + settings.Remove("Token"); + settings.Remove("BootstrapToken"); + } + if (removed.Any(record => record.Id == settings["PreferredGatewayId"]?.GetValue())) settings["PreferredGatewayId"] = null; + AtomicFile.WriteAllText(settingsPath, settings.ToJsonString(new() { WriteIndented = true })); + } + } + foreach (var record in removed) registry.Remove(record.Id); + if (removed.Length > 0) registry.Save(); + if (retiredState) File.Delete(statePath); + foreach (var removal in removals) + if (removal.DistroName is { Length: > 0 } distro && removal.InstallPath is { Length: > 0 } path && + ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distro) && + !ManagedDistroOwnership.DeleteMarker(localDataDir, distro, path)) + throw new IOException("The removed Gateway's setup ownership could not be retired."); + File.Delete(receipt); + } + } +} diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 new file mode 100644 index 0000000000..8856e37ee2 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 @@ -0,0 +1,200 @@ +param( + [Parameter(Mandatory)][string]$Family, + [switch]$Register, + [switch]$Preserve, + [switch]$CreatedDistro, + [string]$DistroName, + [string]$InstallPath, + [switch]$OwnNativePackage, + [string]$OwnedSinceUtc +) +$ErrorActionPreference = 'Stop' +function Normalize-Path([string]$path) { + if ($path.StartsWith('\\?\UNC\', [StringComparison]::OrdinalIgnoreCase)) { $path = '\\' + $path.Substring(8) } + elseif ($path.StartsWith('\\?\')) { $path = $path.Substring(4) } + [IO.Path]::GetFullPath($path).TrimEnd('\') +} +function Assert-ManagedPath([string]$path) { + for ($item = (Normalize-Path $path); $item; $item = [IO.Path]::GetDirectoryName($item)) { + if ((Test-Path -LiteralPath $item) -and + (Get-Item -LiteralPath $item -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'Managed WSL install path contains a reparse point. Cleanup remains pending.' + } + } +} +if ($Family -notmatch '^[A-Za-z0-9._-]+$' -or ($DistroName -and $DistroName -notmatch '^[A-Za-z0-9._-]+$')) { + throw 'Invalid managed Gateway identity.' +} +$root = Join-Path $env:USERPROFILE ".openclaw-companion\cleanup\$Family" +$recordPath = Join-Path $root 'owned.json' +$taskName = "OpenClaw Gateway cleanup - $([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) - $Family" +$sentinel = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "Packages\$Family\LocalState\gateway-cleanup-owner.txt" +if (-not $Register -and -not $Preserve -and -not (Test-Path $recordPath)) { exit 0 } +function Read-WslRegistrations { + $key = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + if (Test-Path -LiteralPath $key -ErrorAction Stop) { + Get-ChildItem -LiteralPath $key -ErrorAction Stop | Get-ItemProperty -ErrorAction Stop + } +} +function Record-Removal([string]$distro, [string]$path, [string]$native) { + $resultPath = Join-Path $root 'cleanup-result.json' + $results = if (Test-Path $resultPath) { @(Get-Content $resultPath -Raw | ConvertFrom-Json) } else { @() } + $results += [pscustomobject]@{ DistroName = $distro; InstallPath = $path; NativeFamily = $native } + ConvertTo-Json -InputObject @($results) | Set-Content $resultPath -Encoding UTF8 +} +function Test-PackageRemovedSince([string]$since) { + $events = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-AppXDeploymentServer/Operational'; Id = 400; + StartTime = [DateTimeOffset]::Parse($since).LocalDateTime } -ErrorAction SilentlyContinue + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + foreach ($event in $events) { + $xml = [xml]$event.ToXml() + $operation = ($xml.Event.EventData.Data | Where-Object Name -eq 'DeploymentOperation').'#text' + $fullName = ($xml.Event.EventData.Data | Where-Object Name -eq 'PackageFullName').'#text' + $parts = $fullName -split '_' + if ($operation -eq '2' -and $xml.Event.System.Security.UserID -eq $sid -and $parts.Count -ge 5 -and + ($parts[0] + '_' + $parts[-1]) -eq $Family -and $parts[3] -in @('', '~')) { return $true } + } + return $false +} +function Register-CleanupTask { + $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $action = New-ScheduledTaskAction -Execute $powershell -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Family $Family" + $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) + $logon = New-ScheduledTaskTrigger -AtLogOn -User $principal.UserId + $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -StartWhenAvailable + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Trigger @($trigger, $logon) -Settings $settings -Force | Out-Null +} +New-Item -ItemType Directory -Path $root -Force | Out-Null +$lock = $null +$deadline = [DateTimeOffset]::UtcNow.AddSeconds(150) +while (-not $lock) { + try { $lock = [IO.File]::Open((Join-Path $root 'cleanup.lock'), 'OpenOrCreate', 'ReadWrite', 'None') } + catch [IO.IOException] { + if ([DateTimeOffset]::UtcNow -ge $deadline) { throw } + Start-Sleep -Milliseconds 200 + } +} +try { + $record = if (Test-Path $recordPath) { Get-Content $recordPath -Raw | ConvertFrom-Json } else { + [pscustomobject]@{ DistroName = $null; InstallPath = $null; OwnNativePackage = $false; InstallationId = $null; RegisteredUtc = $null } + } + foreach ($property in @('RegistrationId', 'NativeFamily', 'NativeInstallationId')) { + if ($property -notin $record.PSObject.Properties.Name) { $record | Add-Member NoteProperty $property $null } + } + if ($Preserve) { + Record-Removal $record.DistroName $record.InstallPath $record.NativeFamily + } elseif ($Register) { + if (-not (Test-Path $sentinel)) { + New-Item -ItemType Directory -Path (Split-Path $sentinel) -Force | Out-Null + [Guid]::NewGuid().ToString('N') | Set-Content $sentinel + } + $record.InstallationId = (Get-Content $sentinel -Raw).Trim() + $record.RegisteredUtc = [DateTimeOffset]::UtcNow.ToString('o') + if ($DistroName) { + if (-not [IO.Path]::IsPathRooted($InstallPath)) { throw 'The managed Gateway install path must be absolute.' } + $registrations = @(Read-WslRegistrations | Where-Object DistributionName -eq $DistroName) + if ($registrations.Count -ne 1 -or (Normalize-Path $registrations[0].BasePath) -ne (Normalize-Path $InstallPath) -or + (-not $CreatedDistro -and $record.DistroName -and $record.RegistrationId -ne $registrations[0].PSChildName)) { + throw 'Managed WSL registration could not be confirmed. Preserve existing Gateways or retry cleanup.' + } + $record.DistroName = $DistroName + $record.InstallPath = Normalize-Path $InstallPath + $record.RegistrationId = $registrations[0].PSChildName + } + if ($OwnNativePackage) { + $native = Get-AppxPackage -Name OpenClawFoundation.OpenClawGateway -PackageTypeFilter Main | + Where-Object Publisher -eq 'CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC' + if (@($native).Count -ne 1) { throw 'A completed Store Gateway deployment is required before claiming ownership.' } + $record.NativeFamily = $native.PackageFamilyName + $record.NativeInstallationId = [Guid]::NewGuid().ToString('N') + $nativeMarker = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "Packages\$($record.NativeFamily)\LocalState\companion-cleanup-owner.txt" + New-Item -ItemType Directory -Path (Split-Path $nativeMarker) -Force | Out-Null + $record.NativeInstallationId | Set-Content $nativeMarker + $record.OwnNativePackage = $true + } + $record | ConvertTo-Json | Set-Content $recordPath -Encoding UTF8 + Register-CleanupTask + exit 0 + } else { + $missingPath = Join-Path $root 'package-missing.txt' + $registered = [bool](Get-AppxPackage -PackageTypeFilter Main | Where-Object PackageFamilyName -eq $Family) + $sameInstallation = (Test-Path $sentinel) -and (Get-Content $sentinel -Raw).Trim() -eq $record.InstallationId + if ($sameInstallation) { + Remove-Item $missingPath -Force -ErrorAction SilentlyContinue + exit 0 + } + # Updates preserve LocalState. A successful Remove receipt plus a lost owner marker + # identifies uninstall, even if the same family was reinstalled before this check. + $removed = $false + if (-not $sameInstallation -and $record.RegisteredUtc) { + $removed = Test-PackageRemovedSince $record.RegisteredUtc + } + if (-not $removed) { + if ($registered) { exit 0 } + if (-not (Test-Path $missingPath)) { + [DateTimeOffset]::UtcNow.ToString('o') | Set-Content $missingPath + exit 0 + } + if (([DateTimeOffset]::UtcNow - [DateTimeOffset]::Parse((Get-Content $missingPath -Raw))).TotalSeconds -lt 30) { exit 0 } + if (Get-AppxPackage -PackageTypeFilter Main | Where-Object PackageFamilyName -eq $Family) { exit 0 } + } + if ($record.DistroName) { + Assert-ManagedPath $record.InstallPath + $registrations = @(Read-WslRegistrations | Where-Object DistributionName -eq $record.DistroName) + if ($registrations.Count -gt 1) { throw 'Duplicate managed WSL registrations. Cleanup was not started.' } + if ($registrations.Count -eq 1) { + $registeredPath = Normalize-Path $registrations[0].BasePath + if (-not $record.RegistrationId -or $registrations[0].PSChildName -ne $record.RegistrationId -or + $registeredPath -ne (Normalize-Path $record.InstallPath)) { throw 'Managed WSL ownership changed. Cleanup was not started.' } + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $env:SystemRoot 'System32\wsl.exe' + $start.Arguments = '--unregister ' + $record.DistroName + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $process = [Diagnostics.Process]::Start($start) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(120000)) { + try { $process.Kill() } catch { } + throw 'Managed WSL removal timed out. Cleanup remains pending.' + } + if ($process.ExitCode -ne 0) { throw 'Managed WSL removal failed. Cleanup remains pending.' } + } + if ($registrations.Count -eq 1 -and (Test-Path -LiteralPath $record.InstallPath)) { + Assert-ManagedPath $record.InstallPath + # WSL removes its disk. Never recursively delete unknown files left at this path. + if (-not @(Get-ChildItem -LiteralPath $record.InstallPath -Force -ErrorAction Stop).Count) { + Remove-Item -LiteralPath $record.InstallPath -Force + } + } + Record-Removal $record.DistroName $record.InstallPath $null + $record.DistroName = $null + $record | ConvertTo-Json | Set-Content $recordPath -Encoding UTF8 + } + if ($record.OwnNativePackage) { + $nativeMarker = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) "Packages\$($record.NativeFamily)\LocalState\companion-cleanup-owner.txt" + if ($record.NativeInstallationId -and (Test-Path $nativeMarker) -and + (Get-Content $nativeMarker -Raw).Trim() -eq $record.NativeInstallationId) { + # A shared Store package remains until the other installed Companion consumers leave. + if (Get-AppxPackage -PackageTypeFilter Main | Where-Object { + $_.PackageFamilyName -ne $Family -and $_.Name -in @('OpenClawFoundation.OpenClaw', 'OpenClawFoundation.OpenClaw.Dev') + }) { exit 0 } + Get-AppxPackage -Name OpenClawFoundation.OpenClawGateway -PackageTypeFilter Main | + Where-Object { $_.Publisher -eq 'CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC' -and $_.PackageFamilyName -eq $record.NativeFamily } | + Remove-AppxPackage -ErrorAction Stop + Record-Removal $null $null $record.NativeFamily + } + } + } + if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + } + if (Test-Path $recordPath) { Remove-Item $recordPath -Force } + 'complete' | Set-Content (Join-Path $root 'cleanup-result.txt') +} catch { + $_.Exception.Message | Set-Content (Join-Path $root 'cleanup-error.txt') + throw +} finally { $lock.Dispose() } diff --git a/src/OpenClaw.SetupEngine/ManagedDistroOwnership.cs b/src/OpenClaw.SetupEngine/ManagedDistroOwnership.cs index 7c0c0a2ce0..164d785cec 100644 --- a/src/OpenClaw.SetupEngine/ManagedDistroOwnership.cs +++ b/src/OpenClaw.SetupEngine/ManagedDistroOwnership.cs @@ -200,7 +200,7 @@ or ArgumentException } } - private static bool TryReadMarker( + internal static bool TryReadMarker( string localDataDir, [NotNullWhen(true)] out ManagedDistroMarker? marker) { @@ -236,7 +236,7 @@ or ArgumentException private static string MarkerPath(string localDataDir) => Path.Combine(localDataDir, MarkerFileName); - private sealed record ManagedDistroMarker( + internal sealed record ManagedDistroMarker( int SchemaVersion, string DistroName, string InstallPath, diff --git a/src/OpenClaw.SetupEngine/NativeGatewayMsixInstaller.cs b/src/OpenClaw.SetupEngine/NativeGatewayMsixInstaller.cs index 8ef668b381..a01535b298 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewayMsixInstaller.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewayMsixInstaller.cs @@ -9,6 +9,7 @@ public sealed class NativeGatewayMsixInstaller { public const string StoreProductId = "9NV70LV3D6XC"; private const int PinnedCertificateMismatch = unchecked((int)0x8A15005E); + private const int NoApplicableUpdate = unchecked((int)0x8A15002B); private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(5); private const string RepairScript = """ $ErrorActionPreference = 'Stop' @@ -34,7 +35,7 @@ Task Install() => commands.RunAsync( [ "install", "--id", StoreProductId, "--source", "msstore", "--silent", "--accept-package-agreements", "--accept-source-agreements", - "--disable-interactivity", "--no-upgrade", + "--disable-interactivity", ], CommandTimeout, ct: cancellationToken); @@ -47,7 +48,7 @@ Task Install() => commands.RunAsync( result = await Install(); cancellationToken.ThrowIfCancellationRequested(); } - if (!result.TimedOut && result.ExitCode == 0) + if (!result.TimedOut && result.ExitCode is 0 or NoApplicableUpdate) return; ThrowInstallFailure(result); } diff --git a/src/OpenClaw.SetupEngine/NativeGatewayPackageAcquisition.cs b/src/OpenClaw.SetupEngine/NativeGatewayPackageAcquisition.cs index ff21ed5ccc..7f71532abf 100644 --- a/src/OpenClaw.SetupEngine/NativeGatewayPackageAcquisition.cs +++ b/src/OpenClaw.SetupEngine/NativeGatewayPackageAcquisition.cs @@ -2,7 +2,7 @@ namespace OpenClaw.SetupEngine; -/// Installs missing packages and waits for verified current-user registration. +/// Refreshes Store packages and waits for verified current-user registration. public static class NativeGatewayPackageAcquisition { /// @@ -34,7 +34,9 @@ public static async Task EnsureAsync( { try { - return await ResolveAsync(); + var package = await ResolveAsync(); + if (!package.PackageFamilyName.StartsWith(NativeGatewayPackageIdentity.StoreName + "_", StringComparison.Ordinal)) + return package; } catch (NativeGatewayPackageNotInstalledException) { diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj index 1ea8f54637..fe9dfe3474 100644 --- a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -8,6 +8,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index ced0730084..6e9df3ab48 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -644,6 +644,28 @@ await _activationRouter.ForwardLaunchToPrimaryAsync(new LaunchActivationInput( // Store protocol URI for processing after setup _pendingProtocolUri = protocolUri; + if (!AppIdentity.IsIsolated && PackageHelper.IsPackaged) + { + using var cleanupLogger = new OpenClaw.SetupEngine.SetupLogger(filePath: null); + try + { + if (!await OpenClaw.SetupEngine.GatewayPackageCleanup.InitializeAsync( + new OpenClaw.SetupEngine.CommandRunner(cleanupLogger), + AppIdentity.ResolveRoamingDataDirectory(), AppIdentity.ResolveSetupLocalDataDirectory(), + AppIdentity.SetupDistroName, preserve: error => NativeRestartAdmission.PromptPreserveGateways(error, AppIdentity.DisplayName))) + { + Exit(); + return; + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException) + { + NativeRestartAdmission.Notify(error.Message, AppIdentity.DisplayName); + Exit(); + return; + } + } + var appUserModelIdRegistration = AppUserModelIdRegistrar.RegisterCurrentProcess(AppIdentity.AppUserModelId); if (appUserModelIdRegistration.Attempted && appUserModelIdRegistration.HResult < 0) { diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 45b8fcf622..ae44991eb3 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -484,8 +484,8 @@ Severity="Warning" IsOpen="False" IsClosable="False" - Title="MSIX installation detected" - Message="Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw."/> + Title="Gateway cleanup" + Message="Uninstalling OpenClaw in Windows Settings automatically removes managed Gateways. Use Remove Local Gateway below to remove a Gateway while keeping the app."/> MessageBoxW(IntPtr.Zero, text, title, 0x00000030 | 0x00010000); + public static bool PromptPreserveGateways(string text, string title) => MessageBoxW(IntPtr.Zero, + text + "\n\nPreserve existing Gateways and continue? Companion will forget these managed connections. You can set up or reconnect from Settings.", + title, 0x00000004 | 0x00000030 | 0x00000100 | 0x00010000) == 6; + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int MessageBoxW(IntPtr owner, string text, string caption, uint type); } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 08e8e3deda..4e41265666 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4991,10 +4991,10 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Open onboarding - MSIX installation detected + Gateway cleanup - Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw. + Uninstalling OpenClaw in Windows Settings automatically removes managed Gateways. Use Remove Local Gateway below to remove a Gateway while keeping the app. Removes the WSL distro (OpenClawGateway), its disk image, autostart entry, and clears gateway credentials. Your MCP token is preserved. Onboarding will reset. @@ -7799,7 +7799,7 @@ Your settings, gateway, files, and models stay where they are. Start and verify the Gateway Retry setup Preview only. No package will be installed or Gateway started. - By selecting Set up gateway, you authorize WinGet to install the missing Gateway package from Microsoft Store and accept the package and Store source agreements. If WinGet is not ready, setup may repair App Installer before retrying. Companion then verifies the package, prepares a dedicated profile, and opens Gateway setup. Provider sign-in stays interactive. This path does not install WSL, Local AI, or Tailscale. + By selecting Set up gateway, you authorize WinGet to install or update the Gateway package from Microsoft Store and accept the package and Store source agreements. If WinGet is not ready, setup may repair App Installer before retrying. Companion then verifies the package, prepares a dedicated profile, and opens Gateway setup. Provider sign-in stays interactive. This path does not install WSL, Local AI, or Tailscale. These Windows capabilities become usable after Node Mode connects and pairs with the Gateway. Windows permissions and command approvals still apply. Native Gateway configured {0}. You can change these choices in Companion Settings. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 402fc35c95..0b49f80f36 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4986,10 +4986,10 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Ouvrir l'intégration - Installation MSIX détectée + Nettoyage de la passerelle - La suppression d'OpenClaw via Paramètres Windows → Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw. + La désinstallation d’OpenClaw dans les paramètres Windows supprime automatiquement les passerelles gérées. Utilisez Supprimer la passerelle locale ci-dessous pour supprimer une passerelle tout en conservant l’application. Supprime la distribution WSL (OpenClawGateway), son image disque, l'entrée de démarrage automatique et efface les identifiants de la passerelle. Votre jeton MCP est conservé. L'intégration sera réinitialisée. @@ -7735,7 +7735,7 @@ Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place Démarrer et vérifier le Gateway Réessayer la configuration Aperçu uniquement. Aucun package ne sera installé et aucun Gateway ne sera démarré. - En sélectionnant Configurer le Gateway, vous autorisez WinGet à installer le package Gateway manquant depuis Microsoft Store et acceptez les accords du package et de la source Store. Si WinGet n'est pas prêt, la configuration peut réparer le programme d'installation d'applications avant de réessayer. Companion vérifie ensuite le package, prépare un profil dédié et ouvre la configuration du Gateway. La connexion au fournisseur reste interactive. Cette méthode n'installe ni WSL, ni l'IA locale, ni Tailscale. + En sélectionnant Configurer le Gateway, vous autorisez WinGet à installer ou mettre à jour le package Gateway depuis Microsoft Store et acceptez les accords du package et de la source Store. Si WinGet n'est pas prêt, la configuration peut réparer le programme d'installation d'applications avant de réessayer. Companion vérifie ensuite le package, prépare un profil dédié et ouvre la configuration du Gateway. La connexion au fournisseur reste interactive. Cette méthode n'installe ni WSL, ni l'IA locale, ni Tailscale. Ces fonctionnalités Windows deviennent utilisables une fois le mode nœud connecté et associé au Gateway. Les autorisations Windows et les approbations de commandes restent applicables. Gateway natif configuré {0}. Vous pouvez modifier ces choix dans les paramètres de Companion. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 65163da305..59313dcc15 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4987,10 +4987,10 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Onboarding openen - MSIX-installatie gedetecteerd + Gateway opruimen - OpenClaw verwijderen via Windows Instellingen → Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw. + Als u OpenClaw via Windows-instellingen verwijdert, worden beheerde gateways automatisch verwijderd. Gebruik hieronder Lokale gateway verwijderen om een gateway te verwijderen en de app te behouden. Verwijdert de WSL-distributie (OpenClawGateway), de schijfafbeelding, het item voor automatisch starten en wist de gateway-referenties. Uw MCP-token blijft behouden. Onboarding wordt gereset. @@ -7736,7 +7736,7 @@ Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. De Gateway starten en controleren Instelling opnieuw proberen Alleen een voorbeeld. Er wordt geen pakket geïnstalleerd of Gateway gestart. - Door Gateway instellen te selecteren, machtigt u WinGet om het ontbrekende Gateway-pakket vanuit Microsoft Store te installeren en accepteert u de pakket- en Store-bronovereenkomsten. Als WinGet nog niet gereed is, kan de installatie App Installer herstellen voordat opnieuw wordt geprobeerd. Companion controleert vervolgens het pakket, bereidt een apart profiel voor en opent de Gateway-instelling. Aanmelding bij de provider blijft interactief. Deze methode installeert geen WSL, lokale AI of Tailscale. + Door Gateway instellen te selecteren, machtigt u WinGet om het Gateway-pakket vanuit Microsoft Store te installeren of bij te werken en accepteert u de pakket- en Store-bronovereenkomsten. Als WinGet nog niet gereed is, kan de installatie App Installer herstellen voordat opnieuw wordt geprobeerd. Companion controleert vervolgens het pakket, bereidt een apart profiel voor en opent de Gateway-instelling. Aanmelding bij de provider blijft interactief. Deze methode installeert geen WSL, lokale AI of Tailscale. Deze Windows-functies zijn bruikbaar nadat de nodemodus verbinding heeft gemaakt en met de Gateway is gekoppeld. Windows-machtigingen en opdrachtgoedkeuringen blijven van toepassing. Native Gateway geconfigureerd {0}. U kunt deze keuzes wijzigen in de instellingen van Companion. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 49430bd91c..0b93360ac3 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -4965,10 +4965,10 @@ Os controles de arquivo, rede, área de transferência, tempo limite e aprovaç Abrir configuração inicial - Instalação MSIX detectada + Limpeza do Gateway - Remover o OpenClaw pelas Configurações do Windows → Aplicativos NÃO removerá o Gateway local (distro WSL, imagem de disco). Use Remover Gateway local abaixo PRIMEIRO e só depois desinstale o OpenClaw. + Desinstalar o OpenClaw nas Configurações do Windows remove automaticamente os Gateways gerenciados. Use Remover Gateway Local abaixo para remover um Gateway e manter o aplicativo. Remove a distro WSL (OpenClawGateway), sua imagem de disco, a entrada de inicialização automática, e apaga as credenciais do Gateway. Seu token MCP é preservado. A integração será reiniciada. @@ -7789,7 +7789,7 @@ Suas configurações, gateway, arquivos e modelos permanecem onde estão.Iniciar e verificar o Gateway Tentar configurar novamente Somente visualização. Nenhum pacote será instalado e nenhum Gateway será iniciado. - Ao selecionar Configurar Gateway, você autoriza o WinGet a instalar o pacote Gateway ausente pela Microsoft Store e aceita os contratos do pacote e da fonte Store. Se o WinGet não estiver pronto, a configuração poderá reparar o Instalador de Aplicativo antes de tentar novamente. O Companion verifica o pacote, prepara um perfil dedicado e abre a configuração do Gateway. A entrada no provedor permanece interativa. Este caminho não instala WSL, IA local nem Tailscale. + Ao selecionar Configurar Gateway, você autoriza o WinGet a instalar ou atualizar o pacote Gateway pela Microsoft Store e aceita os contratos do pacote e da fonte Store. Se o WinGet não estiver pronto, a configuração poderá reparar o Instalador de Aplicativo antes de tentar novamente. O Companion verifica o pacote, prepara um perfil dedicado e abre a configuração do Gateway. A entrada no provedor permanece interativa. Este caminho não instala WSL, IA local nem Tailscale. Esses recursos do Windows ficam disponíveis após o Modo Nó se conectar e emparelhar com o Gateway. As permissões do Windows e as aprovações de comandos continuam sendo aplicáveis. Gateway nativo configurado {0}. Você pode alterar essas escolhas nas configurações do Companion. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 8140385cce..aa97ebe60c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4986,10 +4986,10 @@ 打开引导 - 检测到 MSIX 安装 + 网关清理 - 通过 Windows 设置 → 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 + 在 Windows 设置中卸载 OpenClaw 会自动移除托管网关。使用下方的“移除本地网关”可移除网关并保留应用。 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。 @@ -7735,7 +7735,7 @@ 启动并验证 Gateway 重试设置 仅供预览。不会安装任何包或启动 Gateway。 - 选择“设置 Gateway”即表示您授权 WinGet 从 Microsoft Store 安装缺失的 Gateway 包,并接受包协议和商店源协议。如果 WinGet 尚未就绪,设置可能会先修复应用安装程序再重试。Companion 随后将验证包、准备专用配置文件并打开 Gateway 设置。提供商登录仍需您交互操作。此方式不会安装 WSL、本地 AI 或 Tailscale。 + 选择“设置 Gateway”即表示您授权 WinGet 从 Microsoft Store 安装或更新 Gateway 包,并接受包协议和商店源协议。如果 WinGet 尚未就绪,设置可能会先修复应用安装程序再重试。Companion 随后将验证包、准备专用配置文件并打开 Gateway 设置。提供商登录仍需您交互操作。此方式不会安装 WSL、本地 AI 或 Tailscale。 节点模式连接 Gateway 并完成配对后,即可使用这些 Windows 功能。Windows 权限和命令审批仍然适用。 原生 Gateway 已配置 {0}。您可以在 Companion 设置中更改这些选项。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 2d0a4796bc..7e81a45730 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4986,10 +4986,10 @@ 開啟引導 - 偵測到 MSIX 安裝 + 閘道清理 - 透過 Windows 設定 → 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 + 在 Windows 設定中解除安裝 OpenClaw 會自動移除受管理的閘道。使用下方的「移除本機閘道」可移除閘道並保留應用程式。 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。 @@ -7735,7 +7735,7 @@ 啟動並驗證 Gateway 重試設定 僅供預覽。不會安裝任何套件或啟動 Gateway。 - 選取「設定 Gateway」即表示您授權 WinGet 從 Microsoft Store 安裝缺少的 Gateway 套件,並接受套件合約和商店來源合約。如果 WinGet 尚未就緒,設定可能會先修復應用程式安裝程式再重試。Companion 隨後將驗證套件、準備專用設定檔並開啟 Gateway 設定。供應商登入仍需您互動操作。此方式不會安裝 WSL、本機 AI 或 Tailscale。 + 選取「設定 Gateway」即表示您授權 WinGet 從 Microsoft Store 安裝或更新 Gateway 套件,並接受套件合約和商店來源合約。如果 WinGet 尚未就緒,設定可能會先修復應用程式安裝程式再重試。Companion 隨後將驗證套件、準備專用設定檔並開啟 Gateway 設定。供應商登入仍需您互動操作。此方式不會安裝 WSL、本機 AI 或 Tailscale。 節點模式連線至 Gateway 並完成配對後,即可使用這些 Windows 功能。Windows 權限和命令核准仍然適用。 原生 Gateway 已設定 {0}。您可以在 Companion 設定中變更這些選項。 diff --git a/tests/OpenClaw.SetupEngine.Tests/NativeGatewayMsixInstallerTests.cs b/tests/OpenClaw.SetupEngine.Tests/NativeGatewayMsixInstallerTests.cs index b9ab3cfb11..5a34f9dfbb 100644 --- a/tests/OpenClaw.SetupEngine.Tests/NativeGatewayMsixInstallerTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/NativeGatewayMsixInstallerTests.cs @@ -2,8 +2,10 @@ namespace OpenClaw.SetupEngine.Tests; public sealed class NativeGatewayMsixInstallerTests { - [Fact] - public async Task Install_UsesCurrentUserWinGetAndFixedStoreProductExactlyOnce() + [Theory] + [InlineData(0)] + [InlineData(unchecked((int)0x8A15002B))] + public async Task Install_UsesCurrentUserWinGetAndAllowsUpgradeExactlyOnce(int exitCode) { using var cts = new CancellationTokenSource(); var commands = new Commands((executable, arguments, timeout, ct) => @@ -15,14 +17,15 @@ public async Task Install_UsesCurrentUserWinGetAndFixedStoreProductExactlyOnce() [ "install", "--id", "9NV70LV3D6XC", "--source", "msstore", "--silent", "--accept-package-agreements", "--accept-source-agreements", - "--disable-interactivity", "--no-upgrade", + "--disable-interactivity", ], arguments); Assert.Equal(TimeSpan.FromMinutes(5), timeout); Assert.Equal(cts.Token, ct); - return Task.FromResult(Result()); + return Task.FromResult(Result(exitCode)); }); - await new NativeGatewayMsixInstaller().InstallAsync(commands, cts.Token); + var installer = new NativeGatewayMsixInstaller(); + await installer.InstallAsync(commands, cts.Token); Assert.Equal(1, commands.Calls); } diff --git a/tests/OpenClaw.SetupEngine.Tests/NativeGatewayPackageAcquisitionTests.cs b/tests/OpenClaw.SetupEngine.Tests/NativeGatewayPackageAcquisitionTests.cs index 6a5dfb1338..0691063a76 100644 --- a/tests/OpenClaw.SetupEngine.Tests/NativeGatewayPackageAcquisitionTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/NativeGatewayPackageAcquisitionTests.cs @@ -7,15 +7,24 @@ public sealed class NativeGatewayPackageAcquisitionTests private static readonly NativeGatewayPackage Package = new( "OpenClawFoundation.OpenClawGateway_123456789abcd", "1.0.0.0", @"qualified\openclaw.exe", @"qualified\clawctl.exe"); - [Fact] - public async Task PresentPackage_DoesNotOpenInstallerOrReportWaiting() + [Theory] + [InlineData("OpenClawFoundation.OpenClawGateway", true)] + [InlineData("OpenClawFoundation.OpenClawGateway-patch", false)] + [InlineData("OpenClaw.Gateway", false)] + public async Task PresentPackage_RefreshesStoreAndPreservesDevelopmentPackages(string name, bool refresh) { - var resolver = new Resolver(_ => Task.FromResult(Package)); + var package = Package with { PackageFamilyName = name + "_test" }; + var updated = package with { Version = "2.0.0.0" }; + var installed = false; + var waiting = false; + var resolver = new Resolver(_ => Task.FromResult(installed ? updated : package)); var actual = await NativeGatewayPackageAcquisition.EnsureAsync(resolver, - _ => throw new InvalidOperationException("Must not install"), - () => throw new InvalidOperationException("Must not wait")); - Assert.Same(Package, actual); - Assert.Equal(1, resolver.Calls); + _ => { installed = true; return Task.CompletedTask; }, + () => waiting = true); + Assert.Same(refresh ? updated : package, actual); + Assert.Equal(refresh, installed); + Assert.Equal(refresh, waiting); + Assert.Equal(refresh ? 2 : 1, resolver.Calls); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs index 8891813658..040e84ea35 100644 --- a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs @@ -183,7 +183,7 @@ public void NativeSetup_IsDistinctFromWslAndRechecksCapabilityWithoutIsolationWa Assert.Contains("SetupInstallationStatus.Complete", source); Assert.Contains("SetupInstallationStatus.Failed", source); Assert.Contains("NativeGatewaySetupService", source); - Assert.Contains("_installer.InstallAsync(new CommandRunner(logger), cancellationToken)", source); + Assert.Contains("_installer.InstallAsync(commands, cancellationToken)", source); Assert.DoesNotContain("LaunchUriAsync", source); Assert.DoesNotContain("LaunchFileAsync", source); Assert.DoesNotContain("Architecture.Arm64", source); From 0a74e82eb1e1583687898a63ee28118a9118a01c Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:07:06 -0700 Subject: [PATCH 2/2] fix(setup): preserve unbound Gateways and recover invalid cleanup data Remove startup adoption of legacy WSL markers. Fail closed on malformed cleanup receipts before worker effects and on invalid settings before credential retirement. Keep recovery evidence and surface actionable startup errors. Cover exact PowerShell guard/receipt behavior and C# reconciliation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bc8d76f0-2703-4cbf-b7a1-f6af1c3bb319 --- docs/ARCHITECTURE.md | 2 +- docs/uninstall-msix.md | 13 +- .../GatewayPackageCleanup.cs | 73 ++-- .../GatewayPackageCleanup.ps1 | 28 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 4 +- .../GatewayPackageCleanupTests.cs | 373 ++++++++++++++++++ .../AppRefactorContractTests.cs | 19 + 7 files changed, 478 insertions(+), 34 deletions(-) create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayPackageCleanupTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9198b16186..3cba9e9cba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -214,7 +214,7 @@ disconnect, replacement, or close. This cache is not used for authorization. | Post-save settings change effect ordering, detached snapshot comparison, and concurrent save serialization | `SettingsChangeCoordinator` | authoritative | | Exactly-once ordered app shutdown sequencing | `AppShutdownCoordinator` | authoritative | | App composition-root startup sequencing | `AppBootstrapper` (planned) | planned | -| Packaged uninstall cleanup, preservation recovery and removed-target reconciliation | `GatewayPackageCleanup`; App only invokes its startup gate and presents the preservation choice | authoritative | +| Packaged uninstall cleanup, preservation recovery and removed-target reconciliation | `GatewayPackageCleanup`; App only invokes its startup gate and presents recovery. Legacy name/path markers never grant unattended deletion authority; malformed receipts/settings retain pending evidence and fail closed | authoritative | | Inno migration records, preparation, and pre-start completion guard | `MigrationRecordCodec`, `MigrationPreparation`, `MigrationInventory`, `InnoMigrationStartupGuard` | authoritative | | Cross-session migration exclusion and source activity inspection | `MigrationOperationLock`, `InnoSourceActivityVerifier` (App only retains the runtime handle through process exit) | authoritative | | Store migration startup admission and finalization | `InnoInstallationDetector`, `InnoSourceRemovalVerifier`, `MigrationStartupRecordReader`, `MigrationInventoryCapture`, `StoreMigrationStartupCoordinator`, `StoreMigrationFinalizationCoordinator`, `StoreMigrationStartupGuard` | authoritative | diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md index 647d0a872d..1fec84ab81 100644 --- a/docs/uninstall-msix.md +++ b/docs/uninstall-msix.md @@ -23,11 +23,20 @@ acquisition and bound to the Gateway's LocalState marker; other installed Compan defer removal of the shared package. Unverified failed or cancelled acquisitions remain unclaimed. The package-local ownership marker and successful Windows Remove receipt let first launch settle cleanup after a fast reinstall; normal updates retain the -marker. Older owned installations are adopted prospectively, without treating historical -uninstalls as authority to delete a currently used Gateway. A pending conflict offers +marker. Legacy WSL installations without a creation-bound cleanup registration are +preserved. A name/path marker cannot distinguish an original distro from a replacement +installed before the first upgraded launch, so startup never adopts it for unattended +removal. Use the explicit in-app removal flow for these installations. A pending conflict offers preservation and continuation. Startup retires only removed managed connections and their credentials, preserving unrelated Gateways, settings and local MCP state. +Malformed cleanup receipts stop the worker before removal. Malformed receipts or saved +settings also stop startup with a visible recovery error instead of entering the +preserve/disown path. Reconciliation validates settings before retiring credentials and +keeps a failed receipt for repair. Restore valid `cleanup-result.json` and `settings.json` +from a backup using the paths in the error, then reopen Companion. Invalid receipt recovery +does not discard the ownership record. + Older installations without verified ownership may still leave behind: - **WSL distro** - `OpenClawGateway` remains in `wsl --list`. diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs index d35a5248e1..befd4881e6 100644 --- a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs @@ -13,30 +13,29 @@ public static class GatewayPackageCleanup [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern int GetCurrentPackageFamilyName(ref uint length, StringBuilder family); - public static async Task InitializeAsync(ICommandRunner commands, string dataDir, string localDataDir, - string distroName, CancellationToken ct = default, Func? preserve = null) + public static Task InitializeAsync(ICommandRunner commands, string dataDir, string localDataDir, + CancellationToken ct = default, Func? preserve = null) => + InitializeAsync((register, keep) => RunAsync(commands, register, null, null, false, ct, preserve: keep), + dataDir, localDataDir, ct, preserve); + + internal static async Task InitializeAsync(Func> runCleanup, + string dataDir, string localDataDir, CancellationToken ct = default, Func? preserve = null) { - if (!OperatingSystem.IsWindows()) return true; try { - var directory = await RunAsync(commands, false, null, null, false, ct); + var directory = await runCleanup(false, false); if (directory is not null) await ReconcileAsync(directory, dataDir, localDataDir, ct); if (directory is not null && File.Exists(Path.Combine(directory, "owned.json"))) - await RunAsync(commands, true, null, null, false, ct); - if (ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distroName) && - DistroInstallPathPolicy.TryGetManagedInstallPath(localDataDir, distroName, out var path, out _) && - ManagedDistroOwnership.HasRegisteredDistroEvidence(dataDir, localDataDir, distroName, path, - new WindowsWslRegistrationInspector(), out _)) - { - // Adopt retained installations prospectively. Past uninstall receipts do not authorize removal. - await RunAsync(commands, true, distroName, path, false, ct); - } + await runCleanup(true, false); + // Legacy name/path markers cannot identify the registration that Companion created. + // Only fresh creation may bind a WSL registration for unattended uninstall cleanup. return true; } - catch (Exception error) when (preserve is not null && error is (IOException or UnauthorizedAccessException or InvalidOperationException)) + catch (Exception error) when (preserve is not null && + error is (IOException or UnauthorizedAccessException or InvalidOperationException)) { if (!preserve(error.Message)) return false; - var directory = await RunAsync(commands, false, null, null, false, ct, preserve: true); + var directory = await runCleanup(false, true); if (directory is not null) await ReconcileAsync(directory, dataDir, localDataDir, ct); return true; } @@ -79,6 +78,10 @@ public static Task RegisterAsync(ICommandRunner commands, string? distroName = n TimeSpan.FromSeconds(register ? 30 : 180), ct: ct); if (registration.TimedOut || registration.ExitCode != 0) { + if (registration.Stderr.Contains("OPENCLAW_CLEANUP_RECEIPT_INVALID", StringComparison.Ordinal)) + throw new InvalidDataException( + $"The Gateway cleanup receipt '{Path.Combine(directory, "cleanup-result.json")}' is invalid. " + + "Restore a valid receipt from a backup, then reopen Companion. Cleanup remains pending."); var diagnostic = SetupLogger.Sanitize(registration.Stderr); throw new InvalidOperationException(register ? "Automatic Gateway cleanup could not be registered. " + diagnostic[..Math.Min(diagnostic.Length, 2048)] @@ -88,6 +91,8 @@ public static Task RegisterAsync(ICommandRunner commands, string? distroName = n } private sealed record Removal(string? DistroName, string? InstallPath, string? NativeFamily); + private sealed record LegacySetupState(string? DistroName, string? GatewayUrl); + private sealed record LegacySettings(string? GatewayUrl, bool? UseSshTunnel, string? PreferredGatewayId); private static async Task ReconcileAsync(string directory, string dataDir, string localDataDir, CancellationToken ct) { @@ -99,37 +104,45 @@ private static async Task ReconcileAsync(string directory, string dataDir, strin try { lease = File.Open(Path.Combine(directory, "cleanup.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); break; } catch (IOException) when (attempt < 100) { await Task.Delay(100, ct); } } - using (lease) + using var cleanupLease = lease; + try { if (!File.Exists(receipt)) return; var removals = JsonSerializer.Deserialize(File.ReadAllText(receipt)) ?? throw new InvalidDataException("The Gateway cleanup receipt is invalid."); + if (removals.Any(removal => removal is null)) + throw new InvalidDataException("The Gateway cleanup receipt contains an invalid removal."); var registry = new GatewayRegistry(dataDir); registry.Load(); var removed = registry.GetAll().Where(record => record.IsLocal && record.SshTunnel is null && removals.Any( removal => removal.DistroName is { Length: > 0 } distro && string.Equals( GatewayRecordEditing.ResolveManagedDistroName(record), distro, StringComparison.OrdinalIgnoreCase) || removal.NativeFamily is { Length: > 0 } family && record.NativePackageFamilyName == family)).ToArray(); - foreach (var record in removed) - if (!Guid.TryParse(record.Id, out _) || !DeviceIdentity.BeginClearAllDeviceTokens(registry.GetIdentityDirectory(record.Id)).Success) - throw new IOException("The removed Gateway's credentials could not be retired."); var settingsPath = Path.Combine(dataDir, "settings.json"); var statePath = Path.Combine(localDataDir, "setup-state.json"); - JsonObject? state = null; - try { if (File.Exists(statePath)) state = JsonNode.Parse(File.ReadAllText(statePath)) as JsonObject; } + LegacySetupState? state = null; + try { if (File.Exists(statePath)) state = JsonSerializer.Deserialize(File.ReadAllText(statePath)); } catch (JsonException) { } // Invalid state cannot bind legacy credentials to a removed Gateway. var retiredState = removals.Any(removal => removal.DistroName is { Length: > 0 } distro && - string.Equals(state?["DistroName"]?.GetValue(), distro, StringComparison.OrdinalIgnoreCase) && + string.Equals(state?.DistroName, distro, StringComparison.OrdinalIgnoreCase) && ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distro)); using (PersistenceFileLease.Acquire(settingsPath)) { - if (File.Exists(settingsPath) && JsonNode.Parse(File.ReadAllText(settingsPath)) is JsonObject settings) + var settings = File.Exists(settingsPath) + ? JsonNode.Parse(File.ReadAllText(settingsPath)) as JsonObject + ?? throw new InvalidDataException("The saved Gateway settings must contain a JSON object.") + : null; + var legacySettings = settings?.Deserialize(); + foreach (var record in removed) + if (!Guid.TryParse(record.Id, out _) || !DeviceIdentity.BeginClearAllDeviceTokens(registry.GetIdentityDirectory(record.Id)).Success) + throw new IOException("The removed Gateway's credentials could not be retired."); + if (settings is not null) { - var legacyUrl = settings["GatewayUrl"]?.GetValue(); - if (settings["UseSshTunnel"]?.GetValue() != true && (removed.Any(record => record.Url == legacyUrl || + var legacyUrl = legacySettings?.GatewayUrl; + if (legacySettings?.UseSshTunnel != true && (removed.Any(record => record.Url == legacyUrl || GatewayRecordEditing.AreEquivalentLoopbackEndpoints(record.Url, legacyUrl) || string.IsNullOrWhiteSpace(legacyUrl) && record.Id == registry.ActiveGatewayId) || - retiredState && GatewayRecordEditing.AreEquivalentLoopbackEndpoints(legacyUrl, state?["GatewayUrl"]?.GetValue()))) + retiredState && GatewayRecordEditing.AreEquivalentLoopbackEndpoints(legacyUrl, state?.GatewayUrl))) { if (!DeviceIdentity.BeginClearAllDeviceTokens(dataDir).Success) throw new IOException("The removed Gateway's legacy credentials could not be retired."); @@ -137,7 +150,7 @@ private static async Task ReconcileAsync(string directory, string dataDir, strin settings.Remove("Token"); settings.Remove("BootstrapToken"); } - if (removed.Any(record => record.Id == settings["PreferredGatewayId"]?.GetValue())) settings["PreferredGatewayId"] = null; + if (removed.Any(record => record.Id == legacySettings?.PreferredGatewayId)) settings["PreferredGatewayId"] = null; AtomicFile.WriteAllText(settingsPath, settings.ToJsonString(new() { WriteIndented = true })); } } @@ -151,5 +164,11 @@ private static async Task ReconcileAsync(string directory, string dataDir, strin throw new IOException("The removed Gateway's setup ownership could not be retired."); File.Delete(receipt); } + catch (Exception error) when (error is JsonException or InvalidDataException) + { + throw new InvalidDataException( + $"The Gateway cleanup receipt '{receipt}' or saved settings '{Path.Combine(dataDir, "settings.json")}' are invalid. " + + "Cleanup remains pending. Restore valid files from a backup, then reopen Companion.", error); + } } } diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 index 8856e37ee2..1b97dd7e71 100644 --- a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 @@ -36,11 +36,32 @@ function Read-WslRegistrations { Get-ChildItem -LiteralPath $key -ErrorAction Stop | Get-ItemProperty -ErrorAction Stop } } +function Assert-ValidRemovalReceipt { + $resultPath = Join-Path $root 'cleanup-result.json' + if (-not (Test-Path -LiteralPath $resultPath)) { return } + $json = Get-Content -LiteralPath $resultPath -Raw + if ([string]::IsNullOrWhiteSpace($json) -or -not $json.TrimStart().StartsWith('[')) { + throw 'OPENCLAW_CLEANUP_RECEIPT_INVALID: The cleanup receipt must contain an array.' + } + try { $results = ConvertFrom-Json -InputObject $json -ErrorAction Stop } + catch [System.ArgumentException] { throw 'OPENCLAW_CLEANUP_RECEIPT_INVALID: Restore a valid cleanup-result.json before retrying.' } + foreach ($result in $results) { + if ($null -eq $result -or $result -isnot [pscustomobject]) { + throw 'OPENCLAW_CLEANUP_RECEIPT_INVALID: The cleanup receipt contains an invalid removal.' + } + foreach ($name in @('DistroName', 'InstallPath', 'NativeFamily')) { + if ($null -ne $result.$name -and $result.$name -isnot [string]) { + throw 'OPENCLAW_CLEANUP_RECEIPT_INVALID: A removal identity is not a string.' + } + } + } +} function Record-Removal([string]$distro, [string]$path, [string]$native) { $resultPath = Join-Path $root 'cleanup-result.json' $results = if (Test-Path $resultPath) { @(Get-Content $resultPath -Raw | ConvertFrom-Json) } else { @() } $results += [pscustomobject]@{ DistroName = $distro; InstallPath = $path; NativeFamily = $native } - ConvertTo-Json -InputObject @($results) | Set-Content $resultPath -Encoding UTF8 + ConvertTo-Json -InputObject @($results) | Set-Content "$resultPath.tmp" -Encoding UTF8 + Move-Item -LiteralPath "$resultPath.tmp" -Destination $resultPath -Force } function Test-PackageRemovedSince([string]$since) { $events = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-AppXDeploymentServer/Operational'; Id = 400; @@ -76,6 +97,7 @@ while (-not $lock) { } } try { + Assert-ValidRemovalReceipt $record = if (Test-Path $recordPath) { Get-Content $recordPath -Raw | ConvertFrom-Json } else { [pscustomobject]@{ DistroName = $null; InstallPath = $null; OwnNativePackage = $false; InstallationId = $null; RegisteredUtc = $null } } @@ -95,7 +117,9 @@ try { if (-not [IO.Path]::IsPathRooted($InstallPath)) { throw 'The managed Gateway install path must be absolute.' } $registrations = @(Read-WslRegistrations | Where-Object DistributionName -eq $DistroName) if ($registrations.Count -ne 1 -or (Normalize-Path $registrations[0].BasePath) -ne (Normalize-Path $InstallPath) -or - (-not $CreatedDistro -and $record.DistroName -and $record.RegistrationId -ne $registrations[0].PSChildName)) { + (-not $CreatedDistro -and (-not $record.DistroName -or $record.DistroName -ne $DistroName -or + -not $record.RegistrationId -or $record.RegistrationId -ne $registrations[0].PSChildName -or + (Normalize-Path $record.InstallPath) -ne (Normalize-Path $InstallPath)))) { throw 'Managed WSL registration could not be confirmed. Preserve existing Gateways or retry cleanup.' } $record.DistroName = $DistroName diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 6e9df3ab48..12d245f752 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -652,13 +652,13 @@ await _activationRouter.ForwardLaunchToPrimaryAsync(new LaunchActivationInput( if (!await OpenClaw.SetupEngine.GatewayPackageCleanup.InitializeAsync( new OpenClaw.SetupEngine.CommandRunner(cleanupLogger), AppIdentity.ResolveRoamingDataDirectory(), AppIdentity.ResolveSetupLocalDataDirectory(), - AppIdentity.SetupDistroName, preserve: error => NativeRestartAdmission.PromptPreserveGateways(error, AppIdentity.DisplayName))) + preserve: error => NativeRestartAdmission.PromptPreserveGateways(error, AppIdentity.DisplayName))) { Exit(); return; } } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException) + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException or InvalidDataException) { NativeRestartAdmission.Notify(error.Message, AppIdentity.DisplayName); Exit(); diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayPackageCleanupTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayPackageCleanupTests.cs new file mode 100644 index 0000000000..e9f5b3a667 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayPackageCleanupTests.cs @@ -0,0 +1,373 @@ +using System.Text.Json; +using OpenClaw.Connection; +using OpenClaw.Shared; +using OpenClaw.TestSupport; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class GatewayPackageCleanupTests +{ + [Fact] + public async Task Startup_PreservesUnboundLegacyDistroWithoutRegisteringCleanup() + { + using var fixture = new CleanupFixture(); + var path = Path.Combine(fixture.Local, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(path); + await ManagedDistroOwnership.WriteMarkerAsync(fixture.Local, "OpenClawGateway", path, CancellationToken.None); + var marker = File.ReadAllText(Path.Combine(fixture.Local, "setup-managed-distro.json")); + var canary = Path.Combine(path, "replacement-data.txt"); + File.WriteAllText(canary, "replacement installed before upgraded startup"); + + Assert.True(await fixture.InitializeAsync()); + + Assert.Equal([(false, false)], fixture.Calls); + Assert.False(File.Exists(fixture.OwnedPath)); + Assert.Equal(marker, File.ReadAllText(Path.Combine(fixture.Local, "setup-managed-distro.json"))); + Assert.Equal("replacement installed before upgraded startup", File.ReadAllText(canary)); + } + + [Fact] + public async Task Startup_RefreshesExistingCleanupWithoutRebindingDistro() + { + using var fixture = new CleanupFixture(); + File.WriteAllText(fixture.OwnedPath, """{"DistroName":"OpenClawGateway","RegistrationId":"original"}"""); + + Assert.True(await fixture.InitializeAsync()); + + Assert.Equal([(false, false), (true, false)], fixture.Calls); + Assert.Contains("original", File.ReadAllText(fixture.OwnedPath)); + } + + [Theory] + [InlineData("{")] + [InlineData("{}")] + [InlineData("null")] + [InlineData("[null]")] + public async Task InvalidReceipt_LeavesEvidenceAndSettingsIntactWithoutDisowning(string receipt) + { + using var fixture = new CleanupFixture(); + File.WriteAllText(fixture.OwnedPath, "{}"); + File.WriteAllText(fixture.ReceiptPath, receipt); + File.WriteAllText(fixture.SettingsPath, """{"Theme":"Dark"}"""); + var prompts = 0; + + var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(_ => + { + prompts++; + return true; + })); + + Assert.Contains("cleanup receipt", error.Message); + Assert.Equal(0, prompts); + Assert.Equal([(false, false)], fixture.Calls); + Assert.Equal(receipt, File.ReadAllText(fixture.ReceiptPath)); + Assert.Equal("{}", File.ReadAllText(fixture.OwnedPath)); + Assert.Equal("""{"Theme":"Dark"}""", File.ReadAllText(fixture.SettingsPath)); + } + + [Theory] + [InlineData("{")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("""{"GatewayUrl":5}""")] + [InlineData("""{"UseSshTunnel":"yes"}""")] + [InlineData("""{"PreferredGatewayId":5}""")] + public async Task InvalidSettings_DoNotRetireAuthenticationOrPendingEvidence(string settings) + { + using var fixture = new CleanupFixture(); + var registry = new GatewayRegistry(fixture.Data); + var managed = registry.AddOrUpdate(new GatewayRecord + { + Id = Guid.NewGuid().ToString(), + Url = "ws://127.0.0.1:18789", + IsLocal = true, + SetupManagedDistroName = "OpenClawGateway", + SharedGatewayToken = "test-auth-token" + }); + registry.Save(); + var identityDirectory = registry.GetIdentityDirectory(managed.Id); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + identity.StoreDeviceToken("test-auth-token"); + var identityPath = Path.Combine(identityDirectory, "device-key-ed25519.json"); + var originalIdentity = File.ReadAllText(identityPath); + var registryPath = Path.Combine(fixture.Data, "gateways.json"); + var originalRegistry = File.ReadAllText(registryPath); + File.WriteAllText(fixture.OwnedPath, "{}"); + File.WriteAllText(fixture.ReceiptPath, """[{"DistroName":"OpenClawGateway"}]"""); + File.WriteAllText(fixture.SettingsPath, settings); + + await Assert.ThrowsAsync(() => fixture.InitializeAsync(_ => + throw new InvalidOperationException("Invalid JSON must not offer disowning."))); + + Assert.Equal(originalRegistry, File.ReadAllText(registryPath)); + Assert.Equal(originalIdentity, File.ReadAllText(identityPath)); + Assert.Equal(settings, File.ReadAllText(fixture.SettingsPath)); + Assert.Equal("{}", File.ReadAllText(fixture.OwnedPath)); + Assert.True(File.Exists(fixture.ReceiptPath)); + Assert.Equal([(false, false)], fixture.Calls); + } + + [Fact] + public async Task ValidReceipt_RetiresOnlyRemovedTargetAndKeepsUnrelatedSettings() + { + using var fixture = new CleanupFixture(); + var registry = new GatewayRegistry(fixture.Data); + var managed = registry.AddOrUpdate(new GatewayRecord + { + Id = Guid.NewGuid().ToString(), + Url = "ws://127.0.0.1:18789", + IsLocal = true, + SetupManagedDistroName = "OpenClawGateway", + SharedGatewayToken = "test-auth-token" + }); + var remote = registry.AddOrUpdate(new GatewayRecord + { + Id = Guid.NewGuid().ToString(), + Url = "wss://gateway.example.test", + SharedGatewayToken = "test-auth-token" + }); + registry.Save(); + var identityDirectory = registry.GetIdentityDirectory(managed.Id); + var identity = new DeviceIdentity(identityDirectory); + identity.Initialize(); + identity.StoreDeviceToken("test-auth-token"); + File.WriteAllText(fixture.ReceiptPath, """[{"DistroName":"OpenClawGateway"}]"""); + File.WriteAllText(fixture.SettingsPath, + """{"GatewayUrl":"ws://127.0.0.1:18789","Token":"test-auth-token","Theme":"Dark","EnableMcpServer":true}"""); + + Assert.True(await fixture.InitializeAsync()); + + registry.Load(); + Assert.Equal(remote, Assert.Single(registry.GetAll())); + Assert.Null(DeviceIdentity.TryReadStoredDeviceToken(identityDirectory)); + using var settings = JsonDocument.Parse(File.ReadAllText(fixture.SettingsPath)); + Assert.Equal("", settings.RootElement.GetProperty("GatewayUrl").GetString()); + Assert.False(settings.RootElement.TryGetProperty("Token", out _)); + Assert.Equal("Dark", settings.RootElement.GetProperty("Theme").GetString()); + Assert.True(settings.RootElement.GetProperty("EnableMcpServer").GetBoolean()); + Assert.False(File.Exists(fixture.ReceiptPath)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OwnershipConflict_PreserveChoiceStillControlsRecovery(bool preserve) + { + using var fixture = new CleanupFixture(); + var calls = new List<(bool, bool)>(); + var prompts = 0; + Task Run(bool register, bool keep) + { + calls.Add((register, keep)); + if (!keep) throw new InvalidOperationException("Managed WSL ownership changed."); + File.WriteAllText(fixture.ReceiptPath, "[]"); + return Task.FromResult(fixture.Cleanup); + } + + var result = await GatewayPackageCleanup.InitializeAsync(Run, fixture.Data, fixture.Local, + preserve: message => + { + Assert.Contains("ownership changed", message); + prompts++; + return preserve; + }); + + Assert.Equal(preserve, result); + Assert.Equal(1, prompts); + Assert.Equal(preserve ? [(false, false), (false, true)] : [(false, false)], calls); + Assert.False(File.Exists(fixture.ReceiptPath)); + } + + [Fact] + public async Task InvalidReceiptAfterPreservation_RemainsPendingAndSurfacesRecoveryError() + { + using var fixture = new CleanupFixture(); + Task Run(bool register, bool keep) + { + if (!keep) throw new InvalidOperationException("Managed WSL ownership changed."); + File.WriteAllText(fixture.ReceiptPath, "{"); + return Task.FromResult(fixture.Cleanup); + } + + var error = await Assert.ThrowsAsync(() => + GatewayPackageCleanup.InitializeAsync(Run, fixture.Data, fixture.Local, preserve: _ => true)); + + Assert.IsType(error.InnerException); + Assert.Contains("reopen Companion", error.Message); + Assert.Equal("{", File.ReadAllText(fixture.ReceiptPath)); + } + + [Theory] + [InlineData("[", false)] + [InlineData("", false)] + [InlineData("null", false)] + [InlineData("{}", false)] + [InlineData("[null]", false)] + [InlineData("""[{"DistroName":5}]""", false)] + [InlineData("[]", true)] + [InlineData("""[{"DistroName":"existing"}]""", true)] + public async Task Worker_ValidatesReceiptsBeforeCleanupAndPreservesAppendIdentities(string receipt, bool valid) + { + Assert.True(OperatingSystem.IsWindows(), "Worker contract proof requires Windows PowerShell 5.1."); + using var temp = new TempDirectory(); + using var source = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( + "OpenClaw.SetupEngine.GatewayPackageCleanup.ps1"); + Assert.NotNull(source); + using (var reader = new StreamReader(source)) + File.WriteAllText(temp.Combine("worker.ps1"), await reader.ReadToEndAsync()); + var resultPath = temp.Combine("cleanup-result.json"); + File.WriteAllText(resultPath, receipt); + var harness = temp.Combine("receipt-test.ps1"); + File.WriteAllText(harness, """ + $ErrorActionPreference = 'Stop' + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot 'worker.ps1'), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'Worker parsing failed.' } + foreach ($name in @('Assert-ValidRemovalReceipt', 'Record-Removal')) { + $function = $ast.Find({ param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name + }, $true) + . ([scriptblock]::Create($function.Extent.Text)) + } + $root = $PSScriptRoot + try { Assert-ValidRemovalReceipt } catch { + if ($_.Exception.Message -notlike 'OPENCLAW_CLEANUP_RECEIPT_INVALID:*') { throw } + 'invalid' + exit 0 + } + Record-Removal 'first' 'C:\fixture\first' $null + Record-Removal 'second' 'C:\fixture\second' $null + Record-Removal $null $null 'fixture.native' + Assert-ValidRemovalReceipt + 'valid' + """); + using var logger = new SetupLogger(filePath: null); + var result = await new CommandRunner(logger).RunAsync( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-NonInteractive", "-File", harness], TimeSpan.FromSeconds(30)); + Assert.False(result.TimedOut); + Assert.True(result.ExitCode == 0, result.Stderr); + Assert.Equal(valid ? "valid" : "invalid", result.Stdout.Trim()); + if (!valid) + { + Assert.Equal(receipt, File.ReadAllText(resultPath)); + return; + } + using var removals = JsonDocument.Parse(File.ReadAllText(resultPath)); + var values = removals.RootElement.EnumerateArray().ToArray(); + Assert.Equal("first", values[^3].GetProperty("DistroName").GetString()); + Assert.Equal("second", values[^2].GetProperty("DistroName").GetString()); + Assert.Equal("fixture.native", values[^1].GetProperty("NativeFamily").GetString()); + Assert.Equal(receipt == "[]" ? 3 : 4, values.Length); + Assert.False(File.Exists(resultPath + ".tmp")); + } + + [Theory] + [InlineData(null, null, false, false)] + [InlineData("OpenClawGateway", null, false, false)] + [InlineData("OpenClawGateway", "old-registration", false, false)] + [InlineData("OtherGateway", "current-registration", false, false)] + [InlineData("OpenClawGateway", "current-registration", false, true)] + [InlineData(null, null, true, true)] + [InlineData("OpenClawGateway", "old-registration", true, true)] + public async Task Worker_BindsOnlyFreshCreationOrAnAlreadyBoundRegistration( + string? previousDistro, string? previousId, bool createdDistro, bool expectedAllowed) + { + Assert.True(OperatingSystem.IsWindows(), "Worker contract proof requires Windows PowerShell 5.1."); + using var temp = new TempDirectory(); + using var source = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( + "OpenClaw.SetupEngine.GatewayPackageCleanup.ps1"); + Assert.NotNull(source); + var workerPath = temp.Combine("worker.ps1"); + using (var reader = new StreamReader(source)) + File.WriteAllText(workerPath, await reader.ReadToEndAsync()); + var installPath = temp.Combine("replacement"); + File.WriteAllText(temp.Combine("fixture.json"), JsonSerializer.Serialize(new + { + Record = new { DistroName = previousDistro, RegistrationId = previousId, InstallPath = installPath }, + InstallPath = installPath, + CreatedDistro = createdDistro + })); + var harness = temp.Combine("guard-test.ps1"); + File.WriteAllText(harness, """ + $ErrorActionPreference = 'Stop' + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + (Join-Path $PSScriptRoot 'worker.ps1'), [ref]$tokens, [ref]$errors) + if ($errors.Count) { throw 'Worker parsing failed.' } + $normalize = $ast.Find({ param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Normalize-Path' + }, $true) + . ([scriptblock]::Create($normalize.Extent.Text)) + $guards = @($ast.FindAll({ param($n) + $n -is [System.Management.Automation.Language.IfStatementAst] -and + $n.Clauses[0].Item1.Extent.Text -eq '$DistroName' + }, $true)) + if ($guards.Count -ne 1) { throw 'Expected exactly one registration guard.' } + $body = $guards[0].Clauses[0].Item2.Extent.Text + $inputData = Get-Content (Join-Path $PSScriptRoot 'fixture.json') -Raw | ConvertFrom-Json + $record = $inputData.Record + $InstallPath = $inputData.InstallPath + $DistroName = 'OpenClawGateway' + $CreatedDistro = $inputData.CreatedDistro + function Read-WslRegistrations { + [pscustomobject]@{ + DistributionName = 'OpenClawGateway' + BasePath = $InstallPath + PSChildName = 'current-registration' + } + } + $allowed = $false + $failure = $null + try { + . ([scriptblock]::Create($body.Substring(1, $body.Length - 2))) + $allowed = $true + } catch { $failure = $_.Exception.Message } + [pscustomobject]@{ Allowed = $allowed; Record = $record; Failure = $failure } | ConvertTo-Json -Depth 4 + """); + using var logger = new SetupLogger(filePath: null); + var result = await new CommandRunner(logger).RunAsync( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-NonInteractive", "-File", harness], TimeSpan.FromSeconds(30)); + Assert.False(result.TimedOut); + Assert.True(result.ExitCode == 0, result.Stderr); + using var output = JsonDocument.Parse(result.Stdout); + Assert.Equal(expectedAllowed, output.RootElement.GetProperty("Allowed").GetBoolean()); + var expectedId = expectedAllowed ? "current-registration" : previousId; + Assert.Equal(expectedId, output.RootElement.GetProperty("Record").GetProperty("RegistrationId").GetString()); + if (!expectedAllowed) + Assert.Contains("registration could not be confirmed", output.RootElement.GetProperty("Failure").GetString()); + } + + private sealed class CleanupFixture : IDisposable + { + private readonly TempDirectory _temp = new(); + public string Data { get; } + public string Local { get; } + public string Cleanup { get; } + public string OwnedPath => Path.Combine(Cleanup, "owned.json"); + public string ReceiptPath => Path.Combine(Cleanup, "cleanup-result.json"); + public string SettingsPath => Path.Combine(Data, "settings.json"); + public List<(bool, bool)> Calls { get; } = []; + + public CleanupFixture() + { + Data = Directory.CreateDirectory(_temp.Combine("roaming")).FullName; + Local = Directory.CreateDirectory(_temp.Combine("local")).FullName; + Cleanup = Directory.CreateDirectory(_temp.Combine("cleanup")).FullName; + } + + public Task InitializeAsync(Func? preserve = null) => + GatewayPackageCleanup.InitializeAsync((register, keep) => + { + Calls.Add((register, keep)); + return Task.FromResult(Cleanup); + }, Data, Local, preserve: preserve); + + public void Dispose() => _temp.Dispose(); + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index c3d0d9358a..94b38e6998 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -5,6 +5,25 @@ namespace OpenClaw.Tray.Tests; public sealed class AppRefactorContractTests { + [Fact] + public void Startup_InvalidCleanupDataIsReportedBeforeSettingsLoad() + { + var source = ReadAppSources(); + var cleanup = source[source.IndexOf("GatewayPackageCleanup.InitializeAsync(", StringComparison.Ordinal)..]; + cleanup = cleanup[..cleanup.IndexOf("var appUserModelIdRegistration", StringComparison.Ordinal)]; + + // Retire when packaged startup admission has an executable, WinUI-free seam. + Assert.Contains("InvalidDataException", cleanup); + AssertInOrder(cleanup, "catch (Exception error)", "NativeRestartAdmission.Notify(error.Message", "Exit();", "return;"); + + var root = TestRepositoryPaths.GetRepositoryRoot(); + var owner = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "GatewayPackageCleanup.cs")); + var worker = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine", "GatewayPackageCleanup.ps1")); + Assert.Contains("RunAsync(commands, register, null, null, false, ct, preserve: keep)", owner); + Assert.Contains("""registration.Stderr.Contains("OPENCLAW_CLEANUP_RECEIPT_INVALID", StringComparison.Ordinal)""", owner); + AssertInOrder(worker, " Assert-ValidRemovalReceipt", " $record = if", " if ($Preserve)", "'--unregister '"); + } + [Fact] public void E2ESetupTeardown_UsesFixtureOwnedUninstallArguments() {