From d7364a1f2333b1cbc7ec7d477737ccdef6524d19 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:05:36 -0700 Subject: [PATCH 1/4] 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 506720732..5c66aa10b 100644 --- a/installer.iss +++ b/installer.iss @@ -300,15 +300,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 e70d9747b..8fb946dfe 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -87,7 +87,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 889bf11938f824f001e88a5e43f6215918f385da Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:01:27 -0700 Subject: [PATCH 2/4] 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 5c66aa10b..dff9ab9c1 100644 --- a/installer.iss +++ b/installer.iss @@ -132,6 +132,7 @@ var LocalGatewayCleanupChoiceInitialized: Boolean; LocalGatewayCleanupRequested: Boolean; LocalGatewayCleanupSucceeded: Boolean; + LocalGatewayCleanupScriptPath: String; #if vcRedist != "" procedure InstallVCRuntime; @@ -235,6 +236,8 @@ begin Exit; end; + LocalGatewayCleanupScriptPath := TempScriptPath; + Params := '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) + @@ -317,24 +320,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 c18062d10..43c5259ca 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -15,7 +15,8 @@ param( [string]$DataDirectoryName = 'OpenClawTray', [string]$AutoStartName = 'OpenClawTray', [string]$StartupTaskName = 'OpenClaw Companion', - [string]$DistroName = 'OpenClawGateway' + [string]$DistroName = 'OpenClawGateway', + [switch]$RemoveConfirmedDistroChild ) $ErrorActionPreference = 'Stop' @@ -616,6 +617,97 @@ function Test-DistroListed { return $distros -contains $DistroName } +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 { $gatewayDirectory = Join-Path $AppRoot "wsl\$DistroName" @@ -650,6 +742,12 @@ function Remove-GatewayDirectory { 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 8fb946dfe..a1aa7c630 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -88,12 +88,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", @@ -146,6 +147,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 8af2ba28206045eeef2da7a6624ac1bf8e1a9662 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:17:05 +0200 Subject: [PATCH 3/4] 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 11b055d0c..cd870c26a 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -79,6 +79,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 43c5259ca..e70ff932c 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -709,16 +709,28 @@ function Remove-ConfirmedDistroChild { } function Remove-GatewayDirectory { - $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 a1aa7c630..3222b2a4e 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; @@ -225,6 +227,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 910784c599ae3215e637eb88ed7a99c155015816 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Thu, 24 Sep 2026 02:32:32 +0200 Subject: [PATCH 4/4] 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 e70ff932c..7545f1e11 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -721,18 +721,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 3222b2a4e..e5a1d7472 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -244,7 +244,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) @@ -274,6 +276,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, @@ -382,8 +390,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"),