From 1a175488aef65778b726c52c2649040c93534b72 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:05:36 -0700 Subject: [PATCH 1/7] fix(setup): delete only generated uninstall children Signed-off-by: Sebastien Tardif --- installer.iss | 48 +++++++++++++++++-- .../InstallerIssAssertionTests.cs | 25 +++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/installer.iss b/installer.iss index 75d0e03cf..79834c889 100644 --- a/installer.iss +++ b/installer.iss @@ -677,15 +677,55 @@ begin Log('User continued uninstall after local gateway cleanup failed; generated state will be preserved.'); end; +procedure DeleteGeneratedChild(const ChildName: String); +var + ChildPath: String; +begin + ChildPath := AddBackslash(ExpandConstant('{app}')) + ChildName; + if DirExists(ChildPath) then + begin + if not DelTree(ChildPath, True, True, True) then + Log('Generated directory could not be deleted: ' + ChildName); + end + else if FileExists(ChildPath) then + begin + if not DeleteFile(ChildPath) then + Log('Generated file could not be deleted: ' + ChildName); + end; +end; + procedure DeleteGeneratedAppState; +var + AppDir: String; + FolderName: String; begin if not LocalGatewayCleanupSucceeded then Exit; - if DelTree(ExpandConstant('{app}'), True, True, True) then - Log('Deleted generated app state from {app}.') - else - Log('Generated app state in {app} could not be fully deleted; continuing uninstall.'); + AppDir := RemoveBackslashUnlessRoot(ExpandConstant('{app}')); + FolderName := ExtractFileName(AppDir); + if (CompareText(FolderName, 'OpenClawTray') <> 0) and + (CompareText(FolderName, 'OpenClawTray-Dev') <> 0) then + begin + Log('Refusing to delete generated app state because the install folder is not OpenClawTray or OpenClawTray-Dev.'); + Exit; + end; + + DeleteGeneratedChild('wsl'); + DeleteGeneratedChild('Logs'); + DeleteGeneratedChild('wsl-keepalive'); + DeleteGeneratedChild('WebView2'); + DeleteGeneratedChild('canvas'); + DeleteGeneratedChild('native-cli'); + DeleteGeneratedChild('setup-state.json'); + DeleteGeneratedChild('run.marker'); + DeleteGeneratedChild('exec-approvals.json'); + DeleteGeneratedChild('exec-policy.json'); + DeleteGeneratedChild('openclaw-tray.log'); + DeleteGeneratedChild('uninstall-gateway-result.json'); + DeleteGeneratedChild('uninstall-gateway-error.log'); + DeleteGeneratedChild('uninstall-gateway-wsl.log'); + Log('Deleted generated app state children from {app}.'); end; procedure RemoveAppAutoStart; diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index 973770d00..6aaa81c97 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -105,7 +105,30 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() Assert.Contains("procedure RemoveAppAutoStart;", iss); Assert.Matches(@" RemoveAppAutoStart;\r?\n EnsureLocalGatewayCleanupChoice;", iss); Assert.Contains("CurUninstallStep = usPostUninstall", iss); - Assert.Contains("DelTree(ExpandConstant('{app}'), True, True, True)", iss); + Assert.DoesNotContain("DelTree(ExpandConstant('{app}'), True, True, True)", iss); + Assert.Contains("Refusing to delete generated app state", iss); + Assert.Contains("CompareText(FolderName, 'OpenClawTray')", iss); + Assert.Contains("CompareText(FolderName, 'OpenClawTray-Dev')", iss); + foreach (var child in new[] + { + "wsl", + "Logs", + "wsl-keepalive", + "WebView2", + "canvas", + "native-cli", + "setup-state.json", + "run.marker", + "exec-approvals.json", + "exec-policy.json", + "openclaw-tray.log", + "uninstall-gateway-result.json", + "uninstall-gateway-error.log", + "uninstall-gateway-wsl.log", + }) + { + Assert.Contains($"DeleteGeneratedChild('{child}');", iss); + } Assert.DoesNotContain("Start-Sleep -Seconds 3", iss); Assert.DoesNotContain("--uninstall --confirm-destructive", iss); Assert.DoesNotContain("[UninstallDelete]", iss); From 84a4e56b18f1b3d9ad6cfc410031c8a14af0ef3f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:01:27 -0700 Subject: [PATCH 2/7] fix(setup): delete only the confirmed distro child on uninstall Recursive deletion of the wsl directory could remove a sibling distro VHD. Uninstall now deletes the configured child only and leaves an uncertain path in the log. Signed-off-by: Sebastien Tardif --- installer.iss | 47 ++++++-- scripts/Uninstall-LocalGateway.ps1 | 100 +++++++++++++++++- .../InstallerIssAssertionTests.cs | 87 ++++++++++++++- 3 files changed, 223 insertions(+), 11 deletions(-) diff --git a/installer.iss b/installer.iss index 79834c889..45b21e5eb 100644 --- a/installer.iss +++ b/installer.iss @@ -143,6 +143,7 @@ var LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; + LocalGatewayCleanupScriptPath: String; MigrationOperationHandle: THandle; MigrationOperationLocked: Boolean; MigrationOperationUnavailable: Boolean; @@ -590,6 +591,8 @@ begin Exit; end; + LocalGatewayCleanupScriptPath := TempScriptPath; + Params := '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) + @@ -694,24 +697,56 @@ begin end; end; +procedure DeleteConfirmedDistroChild; +var + ResultCode: Integer; + Started: Boolean; + Params: String; +begin + if (LocalGatewayCleanupScriptPath = '') or (not FileExists(LocalGatewayCleanupScriptPath)) then + begin + Log('Ownership uncertain: local gateway cleanup script is unavailable; leaving WSL distro children in place.'); + Exit; + end; + + Params := + '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(LocalGatewayCleanupScriptPath) + + ' -RemoveConfirmedDistroChild' + + ' -AppRoot ' + AddQuotes(ExpandConstant('{tmp}')) + + ' -DataDirectoryName ' + AddQuotes('{#MyInstallDir}') + + ' -DistroName ' + AddQuotes('{#MyDistroName}'); + + Log('Deleting only the confirmed {#MyDistroName} child under the generated-data root.'); + Started := + Exec( + ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), + Params, + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode); + if (not Started) or (ResultCode <> 0) then + Log('Confirmed distro child cleanup did not finish; leaving uncertain WSL children in place. Exit code: ' + IntToStr(ResultCode) + '.'); +end; + procedure DeleteGeneratedAppState; var AppDir: String; - FolderName: String; + GeneratedRoot: String; begin if not LocalGatewayCleanupSucceeded then Exit; + DeleteConfirmedDistroChild; + AppDir := RemoveBackslashUnlessRoot(ExpandConstant('{app}')); - FolderName := ExtractFileName(AppDir); - if (CompareText(FolderName, 'OpenClawTray') <> 0) and - (CompareText(FolderName, 'OpenClawTray-Dev') <> 0) then + GeneratedRoot := RemoveBackslashUnlessRoot(ExpandConstant('{localappdata}\{#MyInstallDir}')); + if CompareText(AppDir, GeneratedRoot) <> 0 then begin - Log('Refusing to delete generated app state because the install folder is not OpenClawTray or OpenClawTray-Dev.'); + Log('Ownership uncertain: {app} is not the generated-data root; leaving generated children in place.'); Exit; end; - DeleteGeneratedChild('wsl'); DeleteGeneratedChild('Logs'); DeleteGeneratedChild('wsl-keepalive'); DeleteGeneratedChild('WebView2'); diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index e87a0239e..d74bd6656 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -21,7 +21,8 @@ param( # Deliberately longer than the checker's own watchdog so the inner bound # fires first and we do not orphan a child that is about to answer. [int]$MigrationCheckTimeoutSeconds = 120, - [int]$WslTimeoutSeconds = 120 + [int]$WslTimeoutSeconds = 120, + [switch]$RemoveConfirmedDistroChild ) $ErrorActionPreference = 'Stop' @@ -748,6 +749,97 @@ function Enter-DestructivePhase { $script:MigrationAdmissionPhase = $false } +function Test-SameFullPath { + param( + [string]$Left, + [string]$Right + ) + + try { + $leftFull = [System.IO.Path]::GetFullPath($Left).TrimEnd('\') + $rightFull = [System.IO.Path]::GetFullPath($Right).TrimEnd('\') + return [string]::Equals($leftFull, $rightFull, [System.StringComparison]::OrdinalIgnoreCase) + } catch { + return $false + } +} + +function Remove-ConfirmedDistroChild { + $localDataDir = Resolve-LocalDataDir + if ([string]::IsNullOrWhiteSpace($localDataDir)) { + Write-GatewayLog 'Ownership uncertain: generated-data root could not be resolved; leaving WSL children in place.' + return + } + + try { + $generatedRoot = [System.IO.Path]::GetFullPath($localDataDir).TrimEnd('\') + } catch { + Write-GatewayLog "Ownership uncertain: generated-data root '$localDataDir' is not a usable path; leaving WSL children in place." + return + } + + $rootName = [System.IO.Path]::GetFileName($generatedRoot) + if (-not [string]::Equals($rootName, $DataDirectoryName, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-GatewayLog "Ownership uncertain: generated-data root '$generatedRoot' is not '$DataDirectoryName'; leaving WSL children in place." + return + } + + if (-not [string]::IsNullOrWhiteSpace($AppRoot)) { + try { + $appRootFull = [System.IO.Path]::GetFullPath($AppRoot).TrimEnd('\') + $appRootName = [System.IO.Path]::GetFileName($appRootFull) + if ([string]::Equals($appRootName, $DataDirectoryName, [System.StringComparison]::OrdinalIgnoreCase) -and + -not (Test-SameFullPath $appRootFull $generatedRoot)) { + Write-GatewayLog "Ownership uncertain: '$appRootFull' matches the data-directory basename but is not the generated-data root '$generatedRoot'; leaving it in place." + } + } catch { + Write-GatewayLog "Ownership uncertain: AppRoot '$AppRoot' is not a usable path; leaving it in place." + } + } + + $wslRoot = Join-Path $generatedRoot 'wsl' + if (-not (Test-Path -LiteralPath $wslRoot -PathType Container)) { + Write-GatewayLog "No wsl directory under generated-data root '$generatedRoot'." + return + } + + $wslItem = Get-Item -LiteralPath $wslRoot -Force -ErrorAction Stop + if (($wslItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + Write-GatewayLog "Ownership uncertain: '$wslRoot' is a reparse point; leaving it in place." + return + } + + try { + $confirmed = [System.IO.Path]::GetFullPath((Join-Path $wslRoot $DistroName)).TrimEnd('\') + } catch { + Write-GatewayLog "Ownership uncertain: configured distro path under '$wslRoot' is not usable; leaving WSL children in place." + return + } + + $confirmedParent = [System.IO.Path]::GetDirectoryName($confirmed) + if (-not (Test-SameFullPath $confirmedParent $wslRoot)) { + Write-GatewayLog "Ownership uncertain: '$confirmed' is not an immediate child of '$wslRoot'; leaving WSL children in place." + return + } + + foreach ($child in @(Get-ChildItem -LiteralPath $wslRoot -Force)) { + $isReparse = ($child.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + $isConfirmed = + $child.PSIsContainer -and + -not $isReparse -and + [string]::Equals($child.Name, $DistroName, [System.StringComparison]::OrdinalIgnoreCase) -and + (Test-SameFullPath $child.FullName $confirmed) + + if ($isConfirmed) { + Remove-Item -LiteralPath $child.FullName -Recurse -Force -ErrorAction Stop + Write-GatewayLog "Deleted confirmed distro child '$($child.FullName)'." + continue + } + + Write-GatewayLog "Ownership uncertain; leaving leftover '$($child.FullName)'." + } +} + function Remove-GatewayDirectory { Enter-DestructivePhase $gatewayDirectory = Join-Path $AppRoot "wsl\$DistroName" @@ -848,6 +940,12 @@ try { } } Ensure-AppRoot + if ($RemoveConfirmedDistroChild) { + Write-GatewayLog "Removing only the confirmed distro child '$DistroName' under the generated-data root." + Remove-ConfirmedDistroChild + exit 0 + } + Write-GatewayLog "Starting local gateway cleanup for $DistroName." $script:WslPath = Get-WslExePath diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index 6aaa81c97..b69e55a1b 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -106,12 +106,13 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() Assert.Matches(@" RemoveAppAutoStart;\r?\n EnsureLocalGatewayCleanupChoice;", iss); Assert.Contains("CurUninstallStep = usPostUninstall", iss); Assert.DoesNotContain("DelTree(ExpandConstant('{app}'), True, True, True)", iss); - Assert.Contains("Refusing to delete generated app state", iss); - Assert.Contains("CompareText(FolderName, 'OpenClawTray')", iss); - Assert.Contains("CompareText(FolderName, 'OpenClawTray-Dev')", iss); + Assert.Contains("Ownership uncertain: {app} is not the generated-data root", iss); + Assert.Contains("ExpandConstant('{localappdata}\\{#MyInstallDir}')", iss); + Assert.Contains("-RemoveConfirmedDistroChild", iss); + Assert.Contains("Deleting only the confirmed {#MyDistroName} child under the generated-data root.", iss); + Assert.DoesNotContain("DeleteGeneratedChild('wsl');", iss); foreach (var child in new[] { - "wsl", "Logs", "wsl-keepalive", "WebView2", @@ -164,6 +165,84 @@ public void UninstallLocalGatewayScript_DirectlyUnregistersWslDistro() Assert.DoesNotContain("--confirm-destructive", script); } + [Fact] + public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPaths() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var script = Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"); + var temp = Directory.CreateTempSubdirectory("openclaw-uninstall-distro-"); + try + { + var localAppData = Path.Combine(temp.FullName, "local"); + var generatedRoot = Path.Combine(localAppData, "OpenClawTray"); + var configured = Path.Combine(generatedRoot, "wsl", "OpenClawGateway"); + var siblingVhdx = Path.Combine(generatedRoot, "wsl", "SiblingDistro", "ext4.vhdx"); + var lookalike = Path.Combine(temp.FullName, "custom", "OpenClawTray"); + var uncertainVhdx = Path.Combine(lookalike, "wsl", "OpenClawGateway", "ext4.vhdx"); + Directory.CreateDirectory(configured); + Directory.CreateDirectory(Path.GetDirectoryName(siblingVhdx)!); + Directory.CreateDirectory(Path.GetDirectoryName(uncertainVhdx)!); + File.WriteAllText(Path.Combine(configured, "ext4.vhdx"), "configured"); + File.WriteAllText(siblingVhdx, "sibling"); + File.WriteAllText(uncertainVhdx, "uncertain"); + + var powershell = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe"); + var startInfo = new ProcessStartInfo(powershell) + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", script, + "-RemoveConfirmedDistroChild", + "-AppRoot", lookalike, + "-DataDirectoryName", "OpenClawTray", + "-DistroName", "OpenClawGateway", + }) + { + startInfo.ArgumentList.Add(argument); + } + + startInfo.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = localAppData; + startInfo.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"] = ""; + startInfo.Environment["OPENCLAW_TRAY_DATA_DIR"] = ""; + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + var result = $"{await standardOutput}{Environment.NewLine}{await standardError}"; + var logPath = Path.Combine(lookalike, "uninstall-gateway-wsl.log"); + var log = File.Exists(logPath) ? File.ReadAllText(logPath) : ""; + + Assert.True( + process.ExitCode == 0, + $"Confirmed distro cleanup failed with exit code {process.ExitCode}.{Environment.NewLine}{result}{Environment.NewLine}{log}"); + Assert.False(Directory.Exists(configured)); + Assert.True(File.Exists(siblingVhdx)); + Assert.True(File.Exists(uncertainVhdx)); + Assert.Contains(Path.GetDirectoryName(siblingVhdx)!, log, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Ownership uncertain", log, StringComparison.OrdinalIgnoreCase); + Assert.Contains(lookalike, log, StringComparison.OrdinalIgnoreCase); + } + finally + { + temp.Delete(recursive: true); + } + } + [Fact] public void Installer_RegistersOpenClawProtocol() { From 0c29646b5b2df4d29bc97b2c2728315f35c89026 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:17:05 +0200 Subject: [PATCH 3/7] fix(setup): guard primary uninstall directory cleanup Bind primary filesystem deletion to the generated local-data root, preserve uncertain custom roots with durable warnings, and reject redirected ancestors. Exercise the real PowerShell first-phase AST with modeled transport and owned filesystem fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa19a99c-5eea-4942-a104-a82d6fb6c12a --- docs/SETUP_ENGINE_REDESIGN.md | 9 + scripts/Uninstall-LocalGateway.ps1 | 20 +- .../InstallerIssAssertionTests.cs | 196 ++++++++++++++++++ 3 files changed, 221 insertions(+), 4 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index a41c9cd2c..589254ff4 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -296,6 +296,15 @@ absent or unregister succeeds. To replace such a legacy distro, uninstall it first, using `--uninstall --confirm-destructive` and the same distro name, then rerun setup with a supported new name. +The Inno uninstall helper also binds its primary filesystem cleanup to the exact +generated local-data root, not the user-selected install folder or its basename. +After WSL reports the configured distro absent or unregister succeeds, an +uncertain custom install folder is preserved with an `artifactWarnings` entry in +`uninstall-gateway-result.json` and a warning in `uninstall-gateway-wsl.log` under +that folder. Reparse points at the app root, WSL root, or configured child stop +primary deletion. These helper checks do not establish ownership of other WSL +children or replace the signed-installer and native-WSL proof gates. + ```json { "DistroName": "OpenClawGateway", diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index d74bd6656..98483b2dd 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -842,16 +842,28 @@ function Remove-ConfirmedDistroChild { function Remove-GatewayDirectory { Enter-DestructivePhase - $gatewayDirectory = Join-Path $AppRoot "wsl\$DistroName" + $generatedRoot = Resolve-LocalDataDir + if (-not (Test-SameFullPath $AppRoot $generatedRoot)) { + Add-CleanupWarning "Ownership uncertain: AppRoot '$AppRoot' is not the generated-data root '$generatedRoot'; skipping filesystem cleanup there." + return + } + + $wslRoot = Join-Path $AppRoot 'wsl' + $gatewayDirectory = [System.IO.Path]::GetFullPath((Join-Path $wslRoot $DistroName)).TrimEnd('\') + if (-not (Test-SameFullPath ([System.IO.Path]::GetDirectoryName($gatewayDirectory)) $wslRoot)) { + throw "Refusing to delete '$gatewayDirectory': it is not an immediate child of '$wslRoot'." + } if (-not (Test-Path -LiteralPath $gatewayDirectory)) { Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" return } - $gatewayItem = Get-Item -LiteralPath $gatewayDirectory -Force -ErrorAction Stop - if (($gatewayItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing to recursively delete reparse point '$gatewayDirectory'." + foreach ($path in @($AppRoot, $wslRoot, $gatewayDirectory)) { + $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to recursively delete reparse point '$path'." + } } $lastError = $null diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index b69e55a1b..3fa9442ee 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Text.Json; +using OpenClaw.TestSupport; namespace OpenClaw.Tray.Tests; @@ -243,6 +245,200 @@ public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPath } } + [Theory] + [InlineData("custom", "no-wsl", 0, false)] + [InlineData("custom", "absent", 0, false)] + [InlineData("custom", "unregistered", 0, false)] + [InlineData("custom", "not-found", 0, false)] + [InlineData("custom", "failed", 7, false)] + [InlineData("lookalike", "no-wsl", 0, false)] + [InlineData("lookalike", "absent", 0, false)] + [InlineData("lookalike", "unregistered", 0, false)] + [InlineData("lookalike", "not-found", 0, false)] + [InlineData("lookalike", "failed", 7, false)] + [InlineData("generated", "no-wsl", 0, true)] + [InlineData("generated", "absent", 0, true)] + [InlineData("generated", "unregistered", 0, true)] + [InlineData("generated", "not-found", 0, true)] + [InlineData("generated", "failed", 7, false)] + [InlineData("root-junction", "unregistered", 1, false)] + [InlineData("wsl-junction", "unregistered", 1, false)] + [InlineData("child-junction", "unregistered", 1, false)] + public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModeledTransport( + string layout, string scenario, int expectedExitCode, bool deleted) + { + using var temp = new TempDirectory("openclaw-uninstall-primary-"); + var localAppData = temp.Combine("local"); + var generatedRoot = Path.Combine(localAppData, "OpenClawTray"); + var appRoot = layout switch + { + "custom" => temp.Combine("custom"), + "lookalike" => temp.Combine("custom", "OpenClawTray"), + _ => generatedRoot, + }; + var configuredVhd = Path.Combine(appRoot, "wsl", "ChosenGateway", "ext4.vhdx"); + var generatedVhd = Path.Combine(generatedRoot, "wsl", "ChosenGateway", "ext4.vhdx"); + var siblingVhd = Path.Combine(appRoot, "wsl", "SiblingDistro", "ext4.vhdx"); + var defaultVhd = Path.Combine(appRoot, "wsl", "OpenClawGateway", "ext4.vhdx"); + var junctionTarget = temp.Combine("junction-target"); + var junctionVhd = layout switch + { + "root-junction" => Path.Combine(junctionTarget, "wsl", "ChosenGateway", "ext4.vhdx"), + "wsl-junction" => Path.Combine(junctionTarget, "ChosenGateway", "ext4.vhdx"), + _ => Path.Combine(junctionTarget, "ext4.vhdx"), + }; + foreach (var path in new[] { configuredVhd, generatedVhd, siblingVhd, defaultVhd, junctionVhd }.Distinct()) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "owned sentinel"); + } + + var harnessPath = temp.Combine("primary-phase.ps1"); + // Only the production first-phase AST and allowlisted functions run. Native WSL, + // Windows artifact cleanup, and delays are modeled; filesystem guards and logs are real. + File.WriteAllText(harnessPath, """ + param([string]$SourceScript, [string]$AppRoot, [string]$Scenario, [string]$Layout, [string]$JunctionTarget) + $ErrorActionPreference = 'Stop' + $DataDirectoryName = 'OpenClawTray' + $DistroName = 'ChosenGateway' + $RemoveConfirmedDistroChild = $false + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile($SourceScript, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw ($parseErrors | Out-String) } + foreach ($statement in $ast.EndBlock.Statements) { + if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) { break } + . ([scriptblock]::Create($statement.Extent.Text)) + } + foreach ($name in @( + 'Ensure-AppRoot', 'Write-GatewayLog', 'Add-CleanupWarning', 'Write-GatewayResult', + 'Resolve-LocalDataDir', 'Test-SameFullPath', 'Remove-GatewayDirectory', + 'Test-DistroListed', 'Test-DistroNotFound', 'Complete-GatewayCleanup' + )) { + $definition = @($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -eq $name + }) + if ($definition.Count -ne 1) { throw "Expected one production function: $name" } + . ([scriptblock]::Create($definition[0].Extent.Text)) + } + function Get-WslExePath { + if ($Scenario -eq 'no-wsl') { return $null } + return 'modeled-wsl' + } + function Invoke-Wsl { + param([string[]]$Arguments) + $call = $Arguments -join ' ' + Add-Content -LiteralPath (Join-Path $AppRoot 'modeled-transport.log') -Value $call + switch ($call) { + '--list --quiet' { + $output = if ($Scenario -eq 'absent') { 'SiblingDistro' } else { $DistroName } + return [pscustomobject]@{ ExitCode = 0; Output = $output } + } + '--terminate ChosenGateway' { return [pscustomobject]@{ ExitCode = 0; Output = '' } } + '--unregister ChosenGateway' { + if ($Scenario -eq 'failed') { return [pscustomobject]@{ ExitCode = 7; Output = 'Modeled unregister failure' } } + if ($Scenario -eq 'not-found') { return [pscustomobject]@{ ExitCode = 1; Output = 'WSL_E_DISTRO_NOT_FOUND' } } + return [pscustomobject]@{ ExitCode = 0; Output = '' } + } + default { throw "Unexpected modeled WSL call: $call" } + } + } + function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows artifact cleanup.' } + function Start-Sleep { param([int]$Seconds) } + if ($Layout -in @('root-junction', 'wsl-junction', 'child-junction')) { + $link = Join-Path $AppRoot 'wsl' + if ($Layout -eq 'root-junction') { $link = $AppRoot } + if ($Layout -eq 'child-junction') { $link = Join-Path $link $DistroName } + Move-Item -LiteralPath $link -Destination ($link + '-before-junction') -ErrorAction Stop + New-Item -ItemType Junction -Path $link -Target $JunctionTarget -ErrorAction Stop | Out-Null + } + $entrypoint = @($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.TryStatementAst] + }) + if ($entrypoint.Count -ne 1) { throw 'Expected one production first-phase entrypoint.' } + & ([scriptblock]::Create($entrypoint[0].Extent.Text)) + """); + + var root = TestRepositoryPaths.GetRepositoryRoot(); + var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), + "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + var startInfo = new ProcessStartInfo(powershell) + { + WorkingDirectory = root, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", harnessPath, + "-SourceScript", Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"), + "-AppRoot", appRoot, "-Scenario", scenario, "-Layout", layout, "-JunctionTarget", junctionTarget, + }) + { + startInfo.ArgumentList.Add(argument); + } + startInfo.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = localAppData; + startInfo.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"] = ""; + + using var process = Process.Start(startInfo); + Assert.NotNull(process); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + var output = $"{await stdout}{Environment.NewLine}{await stderr}"; + Assert.True(process.ExitCode == expectedExitCode, $"Exit {process.ExitCode}: {output}"); + Assert.Equal(!deleted, File.Exists(configuredVhd)); + Assert.Equal("owned sentinel", File.ReadAllText(junctionVhd)); + var preservedWslRoot = layout switch + { + "root-junction" => Path.Combine(appRoot + "-before-junction", "wsl"), + "wsl-junction" => Path.Combine(appRoot, "wsl-before-junction"), + _ => Path.Combine(appRoot, "wsl"), + }; + Assert.Equal("owned sentinel", File.ReadAllText(Path.Combine(preservedWslRoot, "SiblingDistro", "ext4.vhdx"))); + Assert.Equal("owned sentinel", File.ReadAllText(Path.Combine(preservedWslRoot, "OpenClawGateway", "ext4.vhdx"))); + using var result = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "uninstall-gateway-result.json"))); + Assert.Equal(expectedExitCode == 0, result.RootElement.GetProperty("succeeded").GetBoolean()); + var log = File.ReadAllText(Path.Combine(appRoot, "uninstall-gateway-wsl.log")); + if (layout is "custom" or "lookalike") + { + Assert.Equal("owned sentinel", File.ReadAllText(generatedVhd)); + if (expectedExitCode == 0) + { + Assert.Contains("Ownership uncertain", log); + Assert.Contains("Ownership uncertain", + result.RootElement.GetProperty("details").GetProperty("artifactWarnings")[0].GetString()!); + } + } + if (layout.EndsWith("-junction", StringComparison.Ordinal)) + { + Assert.Contains("Refusing to recursively delete reparse point", log); + } + var calls = File.Exists(Path.Combine(appRoot, "modeled-transport.log")) + ? File.ReadAllLines(Path.Combine(appRoot, "modeled-transport.log")) + : []; + Assert.Equal(scenario switch + { + "no-wsl" => [], + "absent" => ["--list --quiet"], + _ => new[] { "--list --quiet", "--terminate ChosenGateway", "--unregister ChosenGateway" }, + }, calls); + Assert.Equal(expectedExitCode == 0, log.Contains("Modeled Windows artifact cleanup.", StringComparison.Ordinal)); + } + [Fact] public void Installer_RegistersOpenClawProtocol() { From d49320285f81f879f936fbcfb1e282c88553e2ca Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:32:32 +0200 Subject: [PATCH 4/7] fix(setup): inspect uninstall ancestors before missing-child return Reject redirected app and WSL roots even when their target lacks the configured distro child. Preserve the missing-directory no-op and cover empty junction targets through the actual first-phase PowerShell path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa19a99c-5eea-4942-a104-a82d6fb6c12a --- scripts/Uninstall-LocalGateway.ps1 | 11 ++++++----- .../InstallerIssAssertionTests.cs | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index 98483b2dd..e33130ed6 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -854,18 +854,19 @@ function Remove-GatewayDirectory { throw "Refusing to delete '$gatewayDirectory': it is not an immediate child of '$wslRoot'." } - if (-not (Test-Path -LiteralPath $gatewayDirectory)) { - Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" - return - } - foreach ($path in @($AppRoot, $wslRoot, $gatewayDirectory)) { + if (-not (Test-Path -LiteralPath $path)) { continue } $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to recursively delete reparse point '$path'." } } + if (-not (Test-Path -LiteralPath $gatewayDirectory)) { + Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" + return + } + $lastError = $null for ($attempt = 1; $attempt -le 6; $attempt++) { try { diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index 3fa9442ee..6d9210b6c 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -262,7 +262,9 @@ public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPath [InlineData("generated", "not-found", 0, true)] [InlineData("generated", "failed", 7, false)] [InlineData("root-junction", "unregistered", 1, false)] + [InlineData("root-junction", "unregistered-empty", 1, false)] [InlineData("wsl-junction", "unregistered", 1, false)] + [InlineData("wsl-junction", "unregistered-empty", 1, false)] [InlineData("child-junction", "unregistered", 1, false)] public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModeledTransport( string layout, string scenario, int expectedExitCode, bool deleted) @@ -292,6 +294,12 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, "owned sentinel"); } + var targetHasChild = scenario != "unregistered-empty"; + if (!targetHasChild) + { + File.Delete(junctionVhd); + Directory.Delete(Path.GetDirectoryName(junctionVhd)!); + } var harnessPath = temp.Combine("primary-phase.ps1"); // Only the production first-phase AST and allowlisted functions run. Native WSL, @@ -400,8 +408,12 @@ function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows arti } var output = $"{await stdout}{Environment.NewLine}{await stderr}"; Assert.True(process.ExitCode == expectedExitCode, $"Exit {process.ExitCode}: {output}"); - Assert.Equal(!deleted, File.Exists(configuredVhd)); - Assert.Equal("owned sentinel", File.ReadAllText(junctionVhd)); + Assert.Equal(!deleted && targetHasChild, File.Exists(configuredVhd)); + Assert.Equal(targetHasChild, File.Exists(junctionVhd)); + if (targetHasChild) + { + Assert.Equal("owned sentinel", File.ReadAllText(junctionVhd)); + } var preservedWslRoot = layout switch { "root-junction" => Path.Combine(appRoot + "-before-junction", "wsl"), From ff6e81210969868af718f3fc3cd72c452a585a6e Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:06:49 -0700 Subject: [PATCH 5/7] fix(setup): preserve guarded uninstall child cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Uninstall-LocalGateway.ps1 | 50 ++++++++++--- .../InstallerIssAssertionTests.cs | 75 ++++++++++++++++++- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index e33130ed6..8da292d4c 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -764,6 +764,29 @@ function Test-SameFullPath { } } +function Get-ReparsePointInPath { + param([string]$Path) + + $current = [System.IO.Path]::GetFullPath($Path).TrimEnd('\') + while (-not [string]::IsNullOrEmpty($current)) { + try { + if (([System.IO.File]::GetAttributes($current) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + return $current + } + } catch [System.IO.FileNotFoundException] { + } catch [System.IO.DirectoryNotFoundException] { + } + + $parent = [System.IO.Path]::GetDirectoryName($current) + if ([string]::IsNullOrEmpty($parent) -or (Test-SameFullPath $parent $current)) { + break + } + $current = $parent + } + + return $null +} + function Remove-ConfirmedDistroChild { $localDataDir = Resolve-LocalDataDir if ([string]::IsNullOrWhiteSpace($localDataDir)) { @@ -784,6 +807,12 @@ function Remove-ConfirmedDistroChild { return } + $redirectedPath = Get-ReparsePointInPath -Path $generatedRoot + if ($redirectedPath) { + Write-GatewayLog "Ownership uncertain: generated-data path traverses reparse point '$redirectedPath'; leaving WSL children in place." + return + } + if (-not [string]::IsNullOrWhiteSpace($AppRoot)) { try { $appRootFull = [System.IO.Path]::GetFullPath($AppRoot).TrimEnd('\') @@ -855,10 +884,9 @@ function Remove-GatewayDirectory { } foreach ($path in @($AppRoot, $wslRoot, $gatewayDirectory)) { - if (-not (Test-Path -LiteralPath $path)) { continue } - $item = Get-Item -LiteralPath $path -Force -ErrorAction Stop - if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing to recursively delete reparse point '$path'." + $redirectedPath = Get-ReparsePointInPath -Path $path + if ($redirectedPath) { + throw "Refusing to recursively delete reparse point '$redirectedPath'." } } @@ -893,6 +921,14 @@ $migrationOperationLock = $null # (locating wsl.exe, listing distros) stays inside the admission phase. $script:MigrationAdmissionPhase = $true try { + if ($RemoveConfirmedDistroChild) { + Enter-DestructivePhase + Ensure-AppRoot + Write-GatewayLog "Removing only the confirmed distro child '$DistroName' under the generated-data root." + Remove-ConfirmedDistroChild + exit 0 + } + if ($DataDirectoryName -eq 'OpenClawTray') { $lockDirectory = Join-Path (Resolve-AppDataDir) 'store-migration' $lockPath = Join-Path $lockDirectory 'prepare.lock' @@ -953,12 +989,6 @@ try { } } Ensure-AppRoot - if ($RemoveConfirmedDistroChild) { - Write-GatewayLog "Removing only the confirmed distro child '$DistroName' under the generated-data root." - Remove-ConfirmedDistroChild - exit 0 - } - Write-GatewayLog "Starting local gateway cleanup for $DistroName." $script:WslPath = Get-WslExePath diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index 6d9210b6c..fdcae2879 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -238,6 +238,73 @@ public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPath Assert.Contains(Path.GetDirectoryName(siblingVhdx)!, log, StringComparison.OrdinalIgnoreCase); Assert.Contains("Ownership uncertain", log, StringComparison.OrdinalIgnoreCase); Assert.Contains(lookalike, log, StringComparison.OrdinalIgnoreCase); + + var redirectedLocalAppData = Path.Combine(temp.FullName, "redirected-local"); + var redirectedRoot = Path.Combine(redirectedLocalAppData, "OpenClawTray"); + var redirectedTarget = Path.Combine(temp.FullName, "redirected-target"); + var redirectedVhdx = Path.Combine(redirectedTarget, "wsl", "OpenClawGateway", "ext4.vhdx"); + Directory.CreateDirectory(Path.GetDirectoryName(redirectedVhdx)!); + Directory.CreateDirectory(redirectedLocalAppData); + File.WriteAllText(redirectedVhdx, "redirected"); + + var junctionScript = Path.Combine(temp.FullName, "create-junction.ps1"); + File.WriteAllText(junctionScript, """ + param([string]$Link, [string]$Target) + New-Item -ItemType Junction -Path $Link -Target $Target -ErrorAction Stop | Out-Null + """); + var junctionStartInfo = new ProcessStartInfo(powershell) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", junctionScript, + "-Link", redirectedRoot, "-Target", redirectedTarget, + }) + { + junctionStartInfo.ArgumentList.Add(argument); + } + + using (var junctionProcess = Process.Start(junctionStartInfo)) + { + Assert.NotNull(junctionProcess); + var junctionOutput = junctionProcess.StandardOutput.ReadToEndAsync(); + var junctionError = junctionProcess.StandardError.ReadToEndAsync(); + await junctionProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True( + junctionProcess.ExitCode == 0, + $"Junction setup failed with exit code {junctionProcess.ExitCode}.{Environment.NewLine}{await junctionOutput}{Environment.NewLine}{await junctionError}"); + } + + startInfo.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = redirectedLocalAppData; + using (var redirectedProcess = Process.Start(startInfo)) + { + Assert.NotNull(redirectedProcess); + var redirectedOutput = redirectedProcess.StandardOutput.ReadToEndAsync(); + var redirectedError = redirectedProcess.StandardError.ReadToEndAsync(); + await redirectedProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True( + redirectedProcess.ExitCode == 0, + $"Redirected cleanup failed with exit code {redirectedProcess.ExitCode}.{Environment.NewLine}{await redirectedOutput}{Environment.NewLine}{await redirectedError}"); + } + + try + { + Assert.True(File.Exists(redirectedVhdx)); + log = File.ReadAllText(logPath); + Assert.Contains("generated-data path traverses reparse point", log, StringComparison.OrdinalIgnoreCase); + Assert.Contains(redirectedRoot, log, StringComparison.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(redirectedRoot)) + { + Directory.Delete(redirectedRoot); + } + } } finally { @@ -271,7 +338,7 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele { using var temp = new TempDirectory("openclaw-uninstall-primary-"); var localAppData = temp.Combine("local"); - var generatedRoot = Path.Combine(localAppData, "OpenClawTray"); + var generatedRoot = Path.Combine(localAppData, "OpenClawTray-Dev"); var appRoot = layout switch { "custom" => temp.Combine("custom"), @@ -307,7 +374,7 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele File.WriteAllText(harnessPath, """ param([string]$SourceScript, [string]$AppRoot, [string]$Scenario, [string]$Layout, [string]$JunctionTarget) $ErrorActionPreference = 'Stop' - $DataDirectoryName = 'OpenClawTray' + $DataDirectoryName = 'OpenClawTray-Dev' $DistroName = 'ChosenGateway' $RemoveConfirmedDistroChild = $false $tokens = $null @@ -320,8 +387,8 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele } foreach ($name in @( 'Ensure-AppRoot', 'Write-GatewayLog', 'Add-CleanupWarning', 'Write-GatewayResult', - 'Resolve-LocalDataDir', 'Test-SameFullPath', 'Remove-GatewayDirectory', - 'Test-DistroListed', 'Test-DistroNotFound', 'Complete-GatewayCleanup' + 'Resolve-LocalDataDir', 'Test-SameFullPath', 'Get-ReparsePointInPath', 'Remove-GatewayDirectory', + 'Test-DistroListed', 'Test-DistroNotFound', 'Enter-DestructivePhase', 'Complete-GatewayCleanup' )) { $definition = @($ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $_.Name -eq $name From fc5cb38e04169ff7127c34014afc0abd45031e33 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:22:21 -0700 Subject: [PATCH 6/7] test(setup): preserve migration uninstall contracts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InnoMigrationContractTests.cs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs index 1cfb8f47c..1e429293a 100644 --- a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -473,11 +473,18 @@ private static void AssertPreservationGuards(string installer) @"LocalGatewayCleanupSucceeded := True;\s+Log\('[^']*'\);\s+Exit;\s+end;", installer); Assert.Single(Regex.Matches(installer, @"LocalGatewayCleanupSucceeded\s*:=\s*True;")); - Assert.Matches( - @"procedure DeleteGeneratedAppState;\s+begin\s+" + - @"if not LocalGatewayCleanupSucceeded then\s+Exit;\s+" + - @"if DelTree\(ExpandConstant\('\{app\}'\), True, True, True\) then", - installer); + var deleteStart = installer.IndexOf("procedure DeleteGeneratedAppState;", StringComparison.Ordinal); + var deleteEnd = installer.IndexOf("procedure RemoveAppAutoStart;", deleteStart, StringComparison.Ordinal); + Assert.True(deleteStart >= 0 && deleteEnd > deleteStart); + var deleteGeneratedState = installer[deleteStart..deleteEnd]; + Assert.Contains("if not LocalGatewayCleanupSucceeded then", deleteGeneratedState); + Assert.True(deleteGeneratedState.IndexOf("DeleteConfirmedDistroChild;", StringComparison.Ordinal) < + deleteGeneratedState.IndexOf("AppDir := RemoveBackslashUnlessRoot", StringComparison.Ordinal)); + Assert.True(deleteGeneratedState.IndexOf("AppDir := RemoveBackslashUnlessRoot", StringComparison.Ordinal) < + deleteGeneratedState.IndexOf("if CompareText(AppDir, GeneratedRoot) <> 0 then", StringComparison.Ordinal)); + Assert.True(deleteGeneratedState.IndexOf("if CompareText(AppDir, GeneratedRoot) <> 0 then", StringComparison.Ordinal) < + deleteGeneratedState.IndexOf("DeleteGeneratedChild('Logs');", StringComparison.Ordinal)); + Assert.DoesNotContain("DelTree(ExpandConstant('{app}'), True, True, True)", deleteGeneratedState); } [Fact] @@ -570,8 +577,9 @@ public void CleanupScript_KeepsCheckerDiagnostics() public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() { var script = Read("scripts", "Uninstall-LocalGateway.ps1").ReplaceLineEndings("\n"); - // Read-only discovery stays inside the admission phase; only the two - // genuinely destructive steps leave it. A deleted flag flip must fail here. + // Read-only discovery stays inside the admission phase. The primary cleanup's + // directory removal and unregister plus the post-cleanup child-only mode are + // the three destructive entry points. A deleted flag flip must fail here. Assert.Matches( @"function Enter-DestructivePhase \{\s+#[^\n]*\n\s+\$script:MigrationAdmissionPhase = \$false\s+\}", script); @@ -581,7 +589,7 @@ public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() Assert.Matches( @"Enter-DestructivePhase\s+\$unregisterResult = Invoke-Wsl -Arguments @\('--unregister'", script); - Assert.Equal(2, Regex.Matches(script, @"^\s*Enter-DestructivePhase\s*$", + Assert.Equal(3, Regex.Matches(script, @"^\s*Enter-DestructivePhase\s*$", RegexOptions.Multiline).Count); Assert.Matches( @"\$failureExitCode = if \(\$script:MigrationAdmissionPhase\) \{ 2 \} else \{ 1 \}", @@ -590,9 +598,16 @@ public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() // Locating wsl.exe and listing distros destroy nothing, so they must not // sit past the admission boundary in the main flow. var main = script[script.LastIndexOf("\ntry {", StringComparison.Ordinal)..]; + Assert.Matches( + @"if \(\$RemoveConfirmedDistroChild\) \{\s+" + + @"Enter-DestructivePhase[\s\S]*?Remove-ConfirmedDistroChild\s+exit 0\s+\}", + main); + var primaryStart = main.IndexOf("if ($DataDirectoryName -eq 'OpenClawTray')", StringComparison.Ordinal); + Assert.True(primaryStart >= 0); + var primaryMain = main[primaryStart..]; Assert.DoesNotMatch( @"Enter-DestructivePhase[\s\S]*?\$script:WslPath = Get-WslExePath", - main); + primaryMain); } [Fact] From dd386abec2fc98ba1e5cc10277ec503b215e68d6 Mon Sep 17 00:00:00 2001 From: Karen Lai <7976322+karkarl@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:03:29 -0700 Subject: [PATCH 7/7] fix(setup): keep uninstall cleanup in one admitted path Retire matching distro ownership markers, preserve redirected and uncertain data, and cover generated-root cleanup with isolated regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b33eb2e2-2f67-45ed-bd90-e8777586f21c --- docs/SETUP_ENGINE_REDESIGN.md | 28 ++- installer.iss | 80 +++---- scripts/Uninstall-LocalGateway.ps1 | 147 +++++------- .../InnoMigrationContractTests.cs | 16 +- .../InstallerIssAssertionTests.cs | 209 +++++------------- 5 files changed, 169 insertions(+), 311 deletions(-) diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 589254ff4..9d6ab741d 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -296,14 +296,28 @@ absent or unregister succeeds. To replace such a legacy distro, uninstall it first, using `--uninstall --confirm-destructive` and the same distro name, then rerun setup with a supported new name. -The Inno uninstall helper also binds its primary filesystem cleanup to the exact +The Inno uninstall helper deletes the configured distro child under the exact generated local-data root, not the user-selected install folder or its basename. -After WSL reports the configured distro absent or unregister succeeds, an -uncertain custom install folder is preserved with an `artifactWarnings` entry in -`uninstall-gateway-result.json` and a warning in `uninstall-gateway-wsl.log` under -that folder. Reparse points at the app root, WSL root, or configured child stop -primary deletion. These helper checks do not establish ownership of other WSL -children or replace the signed-installer and native-WSL proof gates. +This runs in the same migration-admitted phase, after WSL reports the configured +distro absent or unregister succeeds. There is no separate destructive +post-uninstall script mode. If WSL is unavailable, distro directories and their +ownership markers remain because registration state cannot be confirmed. +An uncertain custom install folder is preserved with +an `artifactWarnings` entry in `uninstall-gateway-result.json` and a warning in +`uninstall-gateway-wsl.log` under that folder. Reparse points anywhere above, +at, or inside the configured generated child stop recursive deletion. + +After successful distro cleanup, the helper removes only an ordinary ownership +marker matching that distro and install path. Unreadable or mismatched markers +remain with a warning. Inno then removes allowlisted generated files and caches +only when the install folder is +the generated-data root. Wrong-type or redirected children remain. External +model caches, unknown files, sibling WSL children, chat metadata for preserved +gateways, and `windows-node-context.json` (which can track workspace changes in +other distros) are not blanket-deleted. `LocalAI` also remains: it can contain +the only backup of the user's original `.wslconfig`, and this Inno path does not +restore that configuration. Local AI teardown remains a separate operation. +These checks do not replace signed-installer or native-WSL proof. ```json { diff --git a/installer.iss b/installer.iss index 45b21e5eb..974b211a5 100644 --- a/installer.iss +++ b/installer.iss @@ -143,7 +143,6 @@ var LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; - LocalGatewayCleanupScriptPath: String; MigrationOperationHandle: THandle; MigrationOperationLocked: Boolean; MigrationOperationUnavailable: Boolean; @@ -591,8 +590,6 @@ begin Exit; end; - LocalGatewayCleanupScriptPath := TempScriptPath; - Params := '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) + @@ -680,53 +677,28 @@ begin Log('User continued uninstall after local gateway cleanup failed; generated state will be preserved.'); end; -procedure DeleteGeneratedChild(const ChildName: String); +procedure DeleteGeneratedChild(const ChildName: String; const IsDirectory: Boolean); var ChildPath: String; begin ChildPath := AddBackslash(ExpandConstant('{app}')) + ChildName; - if DirExists(ChildPath) then + if not MigrationPathIsOrdinary(ChildPath) then + begin + Log('Ownership uncertain: generated child path is redirected or unreadable; leaving it in place: ' + ChildName); + Exit; + end; + if IsDirectory and DirExists(ChildPath) then begin if not DelTree(ChildPath, True, True, True) then Log('Generated directory could not be deleted: ' + ChildName); end - else if FileExists(ChildPath) then + else if (not IsDirectory) and FileExists(ChildPath) then begin if not DeleteFile(ChildPath) then Log('Generated file could not be deleted: ' + ChildName); - end; -end; - -procedure DeleteConfirmedDistroChild; -var - ResultCode: Integer; - Started: Boolean; - Params: String; -begin - if (LocalGatewayCleanupScriptPath = '') or (not FileExists(LocalGatewayCleanupScriptPath)) then - begin - Log('Ownership uncertain: local gateway cleanup script is unavailable; leaving WSL distro children in place.'); - Exit; - end; - - Params := - '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(LocalGatewayCleanupScriptPath) + - ' -RemoveConfirmedDistroChild' + - ' -AppRoot ' + AddQuotes(ExpandConstant('{tmp}')) + - ' -DataDirectoryName ' + AddQuotes('{#MyInstallDir}') + - ' -DistroName ' + AddQuotes('{#MyDistroName}'); - - Log('Deleting only the confirmed {#MyDistroName} child under the generated-data root.'); - Started := - Exec( - ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), - Params, - '', - SW_HIDE, - ewWaitUntilTerminated, - ResultCode); - if (not Started) or (ResultCode <> 0) then - Log('Confirmed distro child cleanup did not finish; leaving uncertain WSL children in place. Exit code: ' + IntToStr(ResultCode) + '.'); + end + else if DirExists(ChildPath) or FileExists(ChildPath) then + Log('Ownership uncertain: generated child has the wrong file type; leaving it in place: ' + ChildName); end; procedure DeleteGeneratedAppState; @@ -737,8 +709,6 @@ begin if not LocalGatewayCleanupSucceeded then Exit; - DeleteConfirmedDistroChild; - AppDir := RemoveBackslashUnlessRoot(ExpandConstant('{app}')); GeneratedRoot := RemoveBackslashUnlessRoot(ExpandConstant('{localappdata}\{#MyInstallDir}')); if CompareText(AppDir, GeneratedRoot) <> 0 then @@ -747,20 +717,20 @@ begin Exit; end; - DeleteGeneratedChild('Logs'); - DeleteGeneratedChild('wsl-keepalive'); - DeleteGeneratedChild('WebView2'); - DeleteGeneratedChild('canvas'); - DeleteGeneratedChild('native-cli'); - DeleteGeneratedChild('setup-state.json'); - DeleteGeneratedChild('run.marker'); - DeleteGeneratedChild('exec-approvals.json'); - DeleteGeneratedChild('exec-policy.json'); - DeleteGeneratedChild('openclaw-tray.log'); - DeleteGeneratedChild('uninstall-gateway-result.json'); - DeleteGeneratedChild('uninstall-gateway-error.log'); - DeleteGeneratedChild('uninstall-gateway-wsl.log'); - Log('Deleted generated app state children from {app}.'); + DeleteGeneratedChild('Logs', True); + DeleteGeneratedChild('wsl-keepalive', True); + DeleteGeneratedChild('WebView2', True); + DeleteGeneratedChild('canvas', True); + DeleteGeneratedChild('native-cli', True); + DeleteGeneratedChild('setup-state.json', False); + DeleteGeneratedChild('run.marker', False); + DeleteGeneratedChild('exec-approvals.json', False); + DeleteGeneratedChild('exec-policy.json', False); + DeleteGeneratedChild('openclaw-tray.log', False); + DeleteGeneratedChild('uninstall-gateway-result.json', False); + DeleteGeneratedChild('uninstall-gateway-error.log', False); + DeleteGeneratedChild('uninstall-gateway-wsl.log', False); + Log('Finished generated app state cleanup; uncertain or unavailable children were preserved.'); end; procedure RemoveAppAutoStart; diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index 8da292d4c..1eae115f3 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -21,8 +21,7 @@ param( # Deliberately longer than the checker's own watchdog so the inner bound # fires first and we do not orphan a child that is about to answer. [int]$MigrationCheckTimeoutSeconds = 120, - [int]$WslTimeoutSeconds = 120, - [switch]$RemoveConfirmedDistroChild + [int]$WslTimeoutSeconds = 120 ) $ErrorActionPreference = 'Stop' @@ -649,6 +648,7 @@ function Resolve-AppDataDir { function Complete-GatewayCleanup { param([string]$Message) + Enter-DestructivePhase Remove-WindowsGatewayArtifacts Write-GatewayResult ` -Succeeded $true ` @@ -787,120 +787,98 @@ function Get-ReparsePointInPath { return $null } -function Remove-ConfirmedDistroChild { - $localDataDir = Resolve-LocalDataDir - if ([string]::IsNullOrWhiteSpace($localDataDir)) { - Write-GatewayLog 'Ownership uncertain: generated-data root could not be resolved; leaving WSL children in place.' - return - } - - try { - $generatedRoot = [System.IO.Path]::GetFullPath($localDataDir).TrimEnd('\') - } catch { - Write-GatewayLog "Ownership uncertain: generated-data root '$localDataDir' is not a usable path; leaving WSL children in place." - return - } - - $rootName = [System.IO.Path]::GetFileName($generatedRoot) - if (-not [string]::Equals($rootName, $DataDirectoryName, [System.StringComparison]::OrdinalIgnoreCase)) { - Write-GatewayLog "Ownership uncertain: generated-data root '$generatedRoot' is not '$DataDirectoryName'; leaving WSL children in place." - return - } - - $redirectedPath = Get-ReparsePointInPath -Path $generatedRoot - if ($redirectedPath) { - Write-GatewayLog "Ownership uncertain: generated-data path traverses reparse point '$redirectedPath'; leaving WSL children in place." - return - } +function Get-ReparsePointInTree { + param([string]$Path) - if (-not [string]::IsNullOrWhiteSpace($AppRoot)) { - try { - $appRootFull = [System.IO.Path]::GetFullPath($AppRoot).TrimEnd('\') - $appRootName = [System.IO.Path]::GetFileName($appRootFull) - if ([string]::Equals($appRootName, $DataDirectoryName, [System.StringComparison]::OrdinalIgnoreCase) -and - -not (Test-SameFullPath $appRootFull $generatedRoot)) { - Write-GatewayLog "Ownership uncertain: '$appRootFull' matches the data-directory basename but is not the generated-data root '$generatedRoot'; leaving it in place." + $redirected = Get-ReparsePointInPath -Path $Path + if ($redirected) { return $redirected } + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $null } + + # Preserve redirected subtrees, including the links themselves, on every + # supported PowerShell version. Never follow links during inspection. + $pending = New-Object System.Collections.Stack + $pending.Push($Path) + while ($pending.Count -gt 0) { + foreach ($child in [System.IO.Directory]::EnumerateFileSystemEntries($pending.Pop())) { + $attributes = [System.IO.File]::GetAttributes($child) + if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + return $child + } + if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + $pending.Push($child) } - } catch { - Write-GatewayLog "Ownership uncertain: AppRoot '$AppRoot' is not a usable path; leaving it in place." } } + return $null +} - $wslRoot = Join-Path $generatedRoot 'wsl' - if (-not (Test-Path -LiteralPath $wslRoot -PathType Container)) { - Write-GatewayLog "No wsl directory under generated-data root '$generatedRoot'." - return - } - - $wslItem = Get-Item -LiteralPath $wslRoot -Force -ErrorAction Stop - if (($wslItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - Write-GatewayLog "Ownership uncertain: '$wslRoot' is a reparse point; leaving it in place." - return - } +function Remove-ConfirmedDistroMarker { + param([string]$GeneratedRoot, [string]$GatewayDirectory) - try { - $confirmed = [System.IO.Path]::GetFullPath((Join-Path $wslRoot $DistroName)).TrimEnd('\') - } catch { - Write-GatewayLog "Ownership uncertain: configured distro path under '$wslRoot' is not usable; leaving WSL children in place." + $markerPath = Join-Path $GeneratedRoot 'setup-managed-distro.json' + if (Get-ReparsePointInPath -Path $markerPath) { + Add-CleanupWarning "Ownership uncertain: managed distro marker path is redirected; leaving '$markerPath' in place." return } - - $confirmedParent = [System.IO.Path]::GetDirectoryName($confirmed) - if (-not (Test-SameFullPath $confirmedParent $wslRoot)) { - Write-GatewayLog "Ownership uncertain: '$confirmed' is not an immediate child of '$wslRoot'; leaving WSL children in place." + if (-not (Test-Path -LiteralPath $markerPath)) { return } + $marker = Read-JsonFile $markerPath + if ($null -eq $marker) { return } + if ((Get-JsonPropertyValue $marker 'schemaVersion') -ne 1 -or + -not [string]::Equals([string](Get-JsonPropertyValue $marker 'distroName'), $DistroName, + [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-SameFullPath ([string](Get-JsonPropertyValue $marker 'installPath')) $GatewayDirectory)) { + Add-CleanupWarning "Ownership uncertain: managed distro marker does not match '$DistroName' at '$GatewayDirectory'; leaving it in place." return } - - foreach ($child in @(Get-ChildItem -LiteralPath $wslRoot -Force)) { - $isReparse = ($child.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 - $isConfirmed = - $child.PSIsContainer -and - -not $isReparse -and - [string]::Equals($child.Name, $DistroName, [System.StringComparison]::OrdinalIgnoreCase) -and - (Test-SameFullPath $child.FullName $confirmed) - - if ($isConfirmed) { - Remove-Item -LiteralPath $child.FullName -Recurse -Force -ErrorAction Stop - Write-GatewayLog "Deleted confirmed distro child '$($child.FullName)'." - continue - } - - Write-GatewayLog "Ownership uncertain; leaving leftover '$($child.FullName)'." - } + Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop + Write-GatewayLog "Removed ownership marker for '$DistroName'." } function Remove-GatewayDirectory { Enter-DestructivePhase $generatedRoot = Resolve-LocalDataDir + if ([string]::IsNullOrWhiteSpace($generatedRoot) -or + -not [string]::Equals([System.IO.Path]::GetFileName( + [System.IO.Path]::GetFullPath($generatedRoot).TrimEnd('\')), + $DataDirectoryName, [System.StringComparison]::OrdinalIgnoreCase)) { + Add-CleanupWarning "Ownership uncertain: generated-data root '$generatedRoot' does not match '$DataDirectoryName'; leaving WSL children in place." + return + } if (-not (Test-SameFullPath $AppRoot $generatedRoot)) { Add-CleanupWarning "Ownership uncertain: AppRoot '$AppRoot' is not the generated-data root '$generatedRoot'; skipping filesystem cleanup there." - return } - $wslRoot = Join-Path $AppRoot 'wsl' + $wslRoot = Join-Path $generatedRoot 'wsl' $gatewayDirectory = [System.IO.Path]::GetFullPath((Join-Path $wslRoot $DistroName)).TrimEnd('\') if (-not (Test-SameFullPath ([System.IO.Path]::GetDirectoryName($gatewayDirectory)) $wslRoot)) { throw "Refusing to delete '$gatewayDirectory': it is not an immediate child of '$wslRoot'." } - foreach ($path in @($AppRoot, $wslRoot, $gatewayDirectory)) { - $redirectedPath = Get-ReparsePointInPath -Path $path - if ($redirectedPath) { - throw "Refusing to recursively delete reparse point '$redirectedPath'." - } + $redirectedPath = Get-ReparsePointInTree -Path $gatewayDirectory + if ($redirectedPath) { + throw "Refusing to recursively delete reparse point '$redirectedPath'." } if (-not (Test-Path -LiteralPath $gatewayDirectory)) { Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" + Remove-ConfirmedDistroMarker -GeneratedRoot $generatedRoot -GatewayDirectory $gatewayDirectory return } + if (-not (Test-Path -LiteralPath $gatewayDirectory -PathType Container)) { + throw "Refusing to delete '$gatewayDirectory': the configured distro child is not a directory." + } $lastError = $null for ($attempt = 1; $attempt -le 6; $attempt++) { try { + $redirectedPath = Get-ReparsePointInTree -Path $gatewayDirectory + if ($redirectedPath) { + throw "Refusing to recursively delete reparse point '$redirectedPath'." + } Remove-Item -LiteralPath $gatewayDirectory -Recurse -Force -ErrorAction Stop if (-not (Test-Path -LiteralPath $gatewayDirectory)) { Write-GatewayLog "Removed gateway directory: $gatewayDirectory" + Remove-ConfirmedDistroMarker -GeneratedRoot $generatedRoot -GatewayDirectory $gatewayDirectory return } } catch { @@ -921,14 +899,6 @@ $migrationOperationLock = $null # (locating wsl.exe, listing distros) stays inside the admission phase. $script:MigrationAdmissionPhase = $true try { - if ($RemoveConfirmedDistroChild) { - Enter-DestructivePhase - Ensure-AppRoot - Write-GatewayLog "Removing only the confirmed distro child '$DistroName' under the generated-data root." - Remove-ConfirmedDistroChild - exit 0 - } - if ($DataDirectoryName -eq 'OpenClawTray') { $lockDirectory = Join-Path (Resolve-AppDataDir) 'store-migration' $lockPath = Join-Path $lockDirectory 'prepare.lock' @@ -993,8 +963,7 @@ try { $script:WslPath = Get-WslExePath if (-not $script:WslPath) { - Write-GatewayLog 'wsl.exe was not found; removing stale gateway directory if present.' - Remove-GatewayDirectory + Add-CleanupWarning 'wsl.exe was not found; registration state could not be confirmed, so WSL directories and ownership markers were preserved.' Complete-GatewayCleanup -Message 'wsl.exe was not found; no registered WSL gateway can be removed.' } diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs index 1e429293a..8ba47772b 100644 --- a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -478,12 +478,11 @@ private static void AssertPreservationGuards(string installer) Assert.True(deleteStart >= 0 && deleteEnd > deleteStart); var deleteGeneratedState = installer[deleteStart..deleteEnd]; Assert.Contains("if not LocalGatewayCleanupSucceeded then", deleteGeneratedState); - Assert.True(deleteGeneratedState.IndexOf("DeleteConfirmedDistroChild;", StringComparison.Ordinal) < - deleteGeneratedState.IndexOf("AppDir := RemoveBackslashUnlessRoot", StringComparison.Ordinal)); + Assert.DoesNotContain("DeleteConfirmedDistroChild", installer); Assert.True(deleteGeneratedState.IndexOf("AppDir := RemoveBackslashUnlessRoot", StringComparison.Ordinal) < deleteGeneratedState.IndexOf("if CompareText(AppDir, GeneratedRoot) <> 0 then", StringComparison.Ordinal)); Assert.True(deleteGeneratedState.IndexOf("if CompareText(AppDir, GeneratedRoot) <> 0 then", StringComparison.Ordinal) < - deleteGeneratedState.IndexOf("DeleteGeneratedChild('Logs');", StringComparison.Ordinal)); + deleteGeneratedState.IndexOf("DeleteGeneratedChild('Logs', True);", StringComparison.Ordinal)); Assert.DoesNotContain("DelTree(ExpandConstant('{app}'), True, True, True)", deleteGeneratedState); } @@ -578,8 +577,8 @@ public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() { var script = Read("scripts", "Uninstall-LocalGateway.ps1").ReplaceLineEndings("\n"); // Read-only discovery stays inside the admission phase. The primary cleanup's - // directory removal and unregister plus the post-cleanup child-only mode are - // the three destructive entry points. A deleted flag flip must fail here. + // directory removal, unregister and Windows artifact cleanup are the + // three destructive entry points. A deleted flag flip must fail here. Assert.Matches( @"function Enter-DestructivePhase \{\s+#[^\n]*\n\s+\$script:MigrationAdmissionPhase = \$false\s+\}", script); @@ -598,10 +597,11 @@ public void CleanupScript_ReportsPreDestructiveFailuresAsUncertain() // Locating wsl.exe and listing distros destroy nothing, so they must not // sit past the admission boundary in the main flow. var main = script[script.LastIndexOf("\ntry {", StringComparison.Ordinal)..]; + Assert.DoesNotContain("RemoveConfirmedDistroChild", main); Assert.Matches( - @"if \(\$RemoveConfirmedDistroChild\) \{\s+" + - @"Enter-DestructivePhase[\s\S]*?Remove-ConfirmedDistroChild\s+exit 0\s+\}", - main); + @"function Complete-GatewayCleanup \{\s+param\(\[string\]\$Message\)\s+" + + @"Enter-DestructivePhase\s+Remove-WindowsGatewayArtifacts", + script); var primaryStart = main.IndexOf("if ($DataDirectoryName -eq 'OpenClawTray')", StringComparison.Ordinal); Assert.True(primaryStart >= 0); var primaryMain = main[primaryStart..]; diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index fdcae2879..0e44a3f9a 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -110,9 +110,11 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() Assert.DoesNotContain("DelTree(ExpandConstant('{app}'), True, True, True)", iss); Assert.Contains("Ownership uncertain: {app} is not the generated-data root", iss); Assert.Contains("ExpandConstant('{localappdata}\\{#MyInstallDir}')", iss); - Assert.Contains("-RemoveConfirmedDistroChild", iss); - Assert.Contains("Deleting only the confirmed {#MyDistroName} child under the generated-data root.", iss); - Assert.DoesNotContain("DeleteGeneratedChild('wsl');", iss); + Assert.DoesNotContain("-RemoveConfirmedDistroChild", iss); + Assert.DoesNotContain("DeleteGeneratedChild('wsl'", iss); + Assert.Contains("if not MigrationPathIsOrdinary(ChildPath) then", iss); + Assert.Contains("if IsDirectory and DirExists(ChildPath) then", iss); + Assert.Contains("else if (not IsDirectory) and FileExists(ChildPath) then", iss); foreach (var child in new[] { "Logs", @@ -120,6 +122,12 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() "WebView2", "canvas", "native-cli", + }) + { + Assert.Contains($"DeleteGeneratedChild('{child}', True);", iss); + } + foreach (var child in new[] + { "setup-state.json", "run.marker", "exec-approvals.json", @@ -130,7 +138,15 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() "uninstall-gateway-wsl.log", }) { - Assert.Contains($"DeleteGeneratedChild('{child}');", iss); + Assert.Contains($"DeleteGeneratedChild('{child}', False);", iss); + } + foreach (var preserved in new[] + { + "LocalAI", "LocalAICache", "windows-node-context.json", "setup-managed-distro.json", + "last-chat-state.json", "aborted-messages.json", "tool-metadata.json", "attachment-metadata.json", + }) + { + Assert.DoesNotContain($"DeleteGeneratedChild('{preserved}'", iss); } Assert.DoesNotContain("Start-Sleep -Seconds 3", iss); Assert.DoesNotContain("--uninstall --confirm-destructive", iss); @@ -168,148 +184,14 @@ public void UninstallLocalGatewayScript_DirectlyUnregistersWslDistro() } [Fact] - public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPaths() + public void Uninstall_HasOnlyOneMigrationAndWslAdmittedDeletionPath() { - var root = TestRepositoryPaths.GetRepositoryRoot(); - var script = Path.Combine(root, "scripts", "Uninstall-LocalGateway.ps1"); - var temp = Directory.CreateTempSubdirectory("openclaw-uninstall-distro-"); - try - { - var localAppData = Path.Combine(temp.FullName, "local"); - var generatedRoot = Path.Combine(localAppData, "OpenClawTray"); - var configured = Path.Combine(generatedRoot, "wsl", "OpenClawGateway"); - var siblingVhdx = Path.Combine(generatedRoot, "wsl", "SiblingDistro", "ext4.vhdx"); - var lookalike = Path.Combine(temp.FullName, "custom", "OpenClawTray"); - var uncertainVhdx = Path.Combine(lookalike, "wsl", "OpenClawGateway", "ext4.vhdx"); - Directory.CreateDirectory(configured); - Directory.CreateDirectory(Path.GetDirectoryName(siblingVhdx)!); - Directory.CreateDirectory(Path.GetDirectoryName(uncertainVhdx)!); - File.WriteAllText(Path.Combine(configured, "ext4.vhdx"), "configured"); - File.WriteAllText(siblingVhdx, "sibling"); - File.WriteAllText(uncertainVhdx, "uncertain"); - - var powershell = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Windows), - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe"); - var startInfo = new ProcessStartInfo(powershell) - { - WorkingDirectory = root, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - foreach (var argument in new[] - { - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", script, - "-RemoveConfirmedDistroChild", - "-AppRoot", lookalike, - "-DataDirectoryName", "OpenClawTray", - "-DistroName", "OpenClawGateway", - }) - { - startInfo.ArgumentList.Add(argument); - } - - startInfo.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = localAppData; - startInfo.Environment["OPENCLAW_TRAY_LOCAL_DATA_DIR"] = ""; - startInfo.Environment["OPENCLAW_TRAY_DATA_DIR"] = ""; - - using var process = Process.Start(startInfo); - Assert.NotNull(process); - var standardOutput = process.StandardOutput.ReadToEndAsync(); - var standardError = process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); - var result = $"{await standardOutput}{Environment.NewLine}{await standardError}"; - var logPath = Path.Combine(lookalike, "uninstall-gateway-wsl.log"); - var log = File.Exists(logPath) ? File.ReadAllText(logPath) : ""; - - Assert.True( - process.ExitCode == 0, - $"Confirmed distro cleanup failed with exit code {process.ExitCode}.{Environment.NewLine}{result}{Environment.NewLine}{log}"); - Assert.False(Directory.Exists(configured)); - Assert.True(File.Exists(siblingVhdx)); - Assert.True(File.Exists(uncertainVhdx)); - Assert.Contains(Path.GetDirectoryName(siblingVhdx)!, log, StringComparison.OrdinalIgnoreCase); - Assert.Contains("Ownership uncertain", log, StringComparison.OrdinalIgnoreCase); - Assert.Contains(lookalike, log, StringComparison.OrdinalIgnoreCase); - - var redirectedLocalAppData = Path.Combine(temp.FullName, "redirected-local"); - var redirectedRoot = Path.Combine(redirectedLocalAppData, "OpenClawTray"); - var redirectedTarget = Path.Combine(temp.FullName, "redirected-target"); - var redirectedVhdx = Path.Combine(redirectedTarget, "wsl", "OpenClawGateway", "ext4.vhdx"); - Directory.CreateDirectory(Path.GetDirectoryName(redirectedVhdx)!); - Directory.CreateDirectory(redirectedLocalAppData); - File.WriteAllText(redirectedVhdx, "redirected"); - - var junctionScript = Path.Combine(temp.FullName, "create-junction.ps1"); - File.WriteAllText(junctionScript, """ - param([string]$Link, [string]$Target) - New-Item -ItemType Junction -Path $Link -Target $Target -ErrorAction Stop | Out-Null - """); - var junctionStartInfo = new ProcessStartInfo(powershell) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - }; - foreach (var argument in new[] - { - "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", junctionScript, - "-Link", redirectedRoot, "-Target", redirectedTarget, - }) - { - junctionStartInfo.ArgumentList.Add(argument); - } - - using (var junctionProcess = Process.Start(junctionStartInfo)) - { - Assert.NotNull(junctionProcess); - var junctionOutput = junctionProcess.StandardOutput.ReadToEndAsync(); - var junctionError = junctionProcess.StandardError.ReadToEndAsync(); - await junctionProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True( - junctionProcess.ExitCode == 0, - $"Junction setup failed with exit code {junctionProcess.ExitCode}.{Environment.NewLine}{await junctionOutput}{Environment.NewLine}{await junctionError}"); - } - - startInfo.Environment["OPENCLAW_TRAY_LOCALAPPDATA_DIR"] = redirectedLocalAppData; - using (var redirectedProcess = Process.Start(startInfo)) - { - Assert.NotNull(redirectedProcess); - var redirectedOutput = redirectedProcess.StandardOutput.ReadToEndAsync(); - var redirectedError = redirectedProcess.StandardError.ReadToEndAsync(); - await redirectedProcess.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); - Assert.True( - redirectedProcess.ExitCode == 0, - $"Redirected cleanup failed with exit code {redirectedProcess.ExitCode}.{Environment.NewLine}{await redirectedOutput}{Environment.NewLine}{await redirectedError}"); - } - - try - { - Assert.True(File.Exists(redirectedVhdx)); - log = File.ReadAllText(logPath); - Assert.Contains("generated-data path traverses reparse point", log, StringComparison.OrdinalIgnoreCase); - Assert.Contains(redirectedRoot, log, StringComparison.OrdinalIgnoreCase); - } - finally - { - if (Directory.Exists(redirectedRoot)) - { - Directory.Delete(redirectedRoot); - } - } - } - finally - { - temp.Delete(recursive: true); - } + var script = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), "scripts", "Uninstall-LocalGateway.ps1")); + Assert.DoesNotContain("RemoveConfirmedDistroChild", script); + Assert.DoesNotContain("Remove-ConfirmedDistroChild", script); + Assert.Contains("$wslRoot = Join-Path $generatedRoot 'wsl'", script); + Assert.Contains("Get-ReparsePointInTree -Path $gatewayDirectory", script); } [Theory] @@ -323,7 +205,7 @@ public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPath [InlineData("lookalike", "unregistered", 0, false)] [InlineData("lookalike", "not-found", 0, false)] [InlineData("lookalike", "failed", 7, false)] - [InlineData("generated", "no-wsl", 0, true)] + [InlineData("generated", "no-wsl", 0, false)] [InlineData("generated", "absent", 0, true)] [InlineData("generated", "unregistered", 0, true)] [InlineData("generated", "not-found", 0, true)] @@ -333,6 +215,11 @@ public async Task Uninstall_DeletesOnlyConfirmedDistroChildAndKeepsUncertainPath [InlineData("wsl-junction", "unregistered", 1, false)] [InlineData("wsl-junction", "unregistered-empty", 1, false)] [InlineData("child-junction", "unregistered", 1, false)] + [InlineData("nested-junction", "unregistered", 1, false)] + [InlineData("nested-junction", "absent", 1, false)] + [InlineData("ancestor-junction", "unregistered", 1, false)] + [InlineData("generated", "unregistered-other-marker", 0, true)] + [InlineData("generated", "unregistered-invalid-marker", 0, true)] public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModeledTransport( string layout, string scenario, int expectedExitCode, bool deleted) { @@ -352,6 +239,7 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele var junctionTarget = temp.Combine("junction-target"); var junctionVhd = layout switch { + "ancestor-junction" => Path.Combine(junctionTarget, "OpenClawTray-Dev", "wsl", "ChosenGateway", "ext4.vhdx"), "root-junction" => Path.Combine(junctionTarget, "wsl", "ChosenGateway", "ext4.vhdx"), "wsl-junction" => Path.Combine(junctionTarget, "ChosenGateway", "ext4.vhdx"), _ => Path.Combine(junctionTarget, "ext4.vhdx"), @@ -361,6 +249,14 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, "owned sentinel"); } + var markerPath = Path.Combine(generatedRoot, "setup-managed-distro.json"); + var preserveMarker = scenario is "unregistered-other-marker" or "unregistered-invalid-marker"; + File.WriteAllText(markerPath, scenario == "unregistered-invalid-marker" ? "invalid JSON" : JsonSerializer.Serialize(new + { + schemaVersion = 1, + distroName = preserveMarker ? "SiblingDistro" : "ChosenGateway", + installPath = Path.GetDirectoryName(generatedVhd), + })); var targetHasChild = scenario != "unregistered-empty"; if (!targetHasChild) { @@ -376,7 +272,6 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele $ErrorActionPreference = 'Stop' $DataDirectoryName = 'OpenClawTray-Dev' $DistroName = 'ChosenGateway' - $RemoveConfirmedDistroChild = $false $tokens = $null $parseErrors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($SourceScript, [ref]$tokens, [ref]$parseErrors) @@ -387,7 +282,8 @@ public async Task Uninstall_PrimaryPhase_BindsDeletionToGeneratedRoot_WithModele } foreach ($name in @( 'Ensure-AppRoot', 'Write-GatewayLog', 'Add-CleanupWarning', 'Write-GatewayResult', - 'Resolve-LocalDataDir', 'Test-SameFullPath', 'Get-ReparsePointInPath', 'Remove-GatewayDirectory', + 'Resolve-LocalDataDir', 'Test-SameFullPath', 'Get-ReparsePointInPath', 'Get-ReparsePointInTree', 'Remove-GatewayDirectory', + 'Read-JsonFile', 'Get-JsonPropertyValue', 'Remove-ConfirmedDistroMarker', 'Test-DistroListed', 'Test-DistroNotFound', 'Enter-DestructivePhase', 'Complete-GatewayCleanup' )) { $definition = @($ast.EndBlock.Statements | Where-Object { @@ -420,11 +316,15 @@ function Invoke-Wsl { } function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows artifact cleanup.' } function Start-Sleep { param([int]$Seconds) } - if ($Layout -in @('root-junction', 'wsl-junction', 'child-junction')) { + if ($Layout.EndsWith('-junction')) { $link = Join-Path $AppRoot 'wsl' + if ($Layout -eq 'ancestor-junction') { $link = Split-Path -Parent $AppRoot } if ($Layout -eq 'root-junction') { $link = $AppRoot } if ($Layout -eq 'child-junction') { $link = Join-Path $link $DistroName } - Move-Item -LiteralPath $link -Destination ($link + '-before-junction') -ErrorAction Stop + if ($Layout -eq 'nested-junction') { $link = Join-Path $link "$DistroName\nested" } + if (Test-Path -LiteralPath $link) { + Move-Item -LiteralPath $link -Destination ($link + '-before-junction') -ErrorAction Stop + } New-Item -ItemType Junction -Path $link -Target $JunctionTarget -ErrorAction Stop | Out-Null } $entrypoint = @($ast.EndBlock.Statements | Where-Object { @@ -475,6 +375,10 @@ function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows arti } var output = $"{await stdout}{Environment.NewLine}{await stderr}"; Assert.True(process.ExitCode == expectedExitCode, $"Exit {process.ExitCode}: {output}"); + if (!layout.EndsWith("-junction", StringComparison.Ordinal)) + { + Assert.Equal(expectedExitCode != 0 || preserveMarker || scenario == "no-wsl", File.Exists(markerPath)); + } Assert.Equal(!deleted && targetHasChild, File.Exists(configuredVhd)); Assert.Equal(targetHasChild, File.Exists(junctionVhd)); if (targetHasChild) @@ -483,6 +387,7 @@ function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows arti } var preservedWslRoot = layout switch { + "ancestor-junction" => Path.Combine(localAppData + "-before-junction", "OpenClawTray-Dev", "wsl"), "root-junction" => Path.Combine(appRoot + "-before-junction", "wsl"), "wsl-junction" => Path.Combine(appRoot, "wsl-before-junction"), _ => Path.Combine(appRoot, "wsl"), @@ -494,8 +399,8 @@ function Remove-WindowsGatewayArtifacts { Write-GatewayLog 'Modeled Windows arti var log = File.ReadAllText(Path.Combine(appRoot, "uninstall-gateway-wsl.log")); if (layout is "custom" or "lookalike") { - Assert.Equal("owned sentinel", File.ReadAllText(generatedVhd)); - if (expectedExitCode == 0) + Assert.Equal(expectedExitCode != 0 || scenario == "no-wsl", File.Exists(generatedVhd)); + if (expectedExitCode == 0 && scenario != "no-wsl") { Assert.Contains("Ownership uncertain", log); Assert.Contains("Ownership uncertain",