diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ac7c94ec7b..41e9d46092 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,7 +121,7 @@ new attempt. This boundary does not change release signing or Windows policy. | 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 | @@ -227,6 +227,7 @@ new attempt. This boundary does not change release signing or Windows policy. | 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 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 | @@ -283,7 +284,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 @@ -342,9 +343,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 a9be3bf040..8a0db8bb09 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..1fec84ab81 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,31 @@ 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. 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`. - **Roaming app data** under `%APPDATA%\OpenClawTray\` (device key, settings, @@ -30,7 +53,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/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 35101e04a6..4e4728fe37 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..befd4881e6 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs @@ -0,0 +1,174 @@ +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 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) + { + try + { + 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 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)) + { + if (!preserve(error.Message)) return false; + var directory = await runCleanup(false, 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) + { + 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)] + : "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 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) + { + 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 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(); + var settingsPath = Path.Combine(dataDir, "settings.json"); + var statePath = Path.Combine(localDataDir, "setup-state.json"); + 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, distro, StringComparison.OrdinalIgnoreCase) && + ManagedDistroOwnership.HasPathBoundMarkerEvidence(localDataDir, distro)); + using (PersistenceFileLease.Acquire(settingsPath)) + { + 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 = 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))) + { + 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 == legacySettings?.PreferredGatewayId)) 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); + } + 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 new file mode 100644 index 0000000000..1b97dd7e71 --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 @@ -0,0 +1,224 @@ +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 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.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; + 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 { + 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 } + } + 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 (-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 + $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 f61e268832..04888dfba8 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -637,6 +637,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(), + preserve: error => NativeRestartAdmission.PromptPreserveGateways(error, AppIdentity.DisplayName))) + { + Exit(); + return; + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidOperationException or InvalidDataException) + { + 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 dcaa160f41..cb8d4baf23 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -5000,10 +5000,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. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 954a703d98..b04ebd3734 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -4995,10 +4995,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. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 1ed1ecbb7b..8a198833a4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -4996,10 +4996,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. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 23c4b0f8e2..29d2ee8302 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -4974,10 +4974,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. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 84a956bc4d..4147bd822b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -4995,10 +4995,10 @@ 打开引导 - 检测到 MSIX 安装 + 网关清理 - 通过 Windows 设置 → 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 + 在 Windows 设置中卸载 OpenClaw 会自动移除托管网关。使用下方的“移除本地网关”可移除网关并保留应用。 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 69ce2e72dd..7a816a2288 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -4995,10 +4995,10 @@ 開啟引導 - 偵測到 MSIX 安裝 + 閘道清理 - 透過 Windows 設定 → 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 + 在 Windows 設定中解除安裝 OpenClaw 會自動移除受管理的閘道。使用下方的「移除本機閘道」可移除閘道並保留應用程式。 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。 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.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/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() { diff --git a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs index 97ef5ce585..5a51d7ffac 100644 --- a/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/NativeGatewaySetupUxContractTests.cs @@ -171,7 +171,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);