diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 26ed96f5a..47499b7e6 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -98,6 +98,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/installer.iss b/installer.iss index 1312af3b9..d0f469a6f 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; @@ -499,6 +500,8 @@ begin Exit; end; + LocalGatewayCleanupScriptPath := TempScriptPath; + Params := '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) + @@ -586,15 +589,87 @@ 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 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; + GeneratedRoot: 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.'); + DeleteConfirmedDistroChild; + + AppDir := RemoveBackslashUnlessRoot(ExpandConstant('{app}')); + GeneratedRoot := RemoveBackslashUnlessRoot(ExpandConstant('{localappdata}\{#MyInstallDir}')); + if CompareText(AppDir, GeneratedRoot) <> 0 then + begin + Log('Ownership uncertain: {app} is not the generated-data root; leaving generated children in place.'); + 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}.'); end; procedure RemoveAppAutoStart; diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index 97168f471..6730abf34 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' @@ -692,20 +693,152 @@ 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 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)) { + 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 + } + + 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" + $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'." + } + + foreach ($path in @($AppRoot, $wslRoot, $gatewayDirectory)) { + $redirectedPath = Get-ReparsePointInPath -Path $path + if ($redirectedPath) { + throw "Refusing to recursively delete reparse point '$redirectedPath'." + } + } 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'." - } - $lastError = $null for ($attempt = 1; $attempt -le 6; $attempt++) { try { @@ -732,6 +865,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' diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs index 276db0492..8f4fe6039 100644 --- a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -353,11 +353,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] @@ -412,8 +419,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); @@ -423,7 +431,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 \}", @@ -432,9 +440,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] diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index e70d9747b..eda7c57a7 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; @@ -87,7 +89,31 @@ 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("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[] + { + "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); @@ -123,6 +149,357 @@ 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); + + 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); + } + } + + [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("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) + { + using var temp = new TempDirectory("openclaw-uninstall-primary-"); + var localAppData = temp.Combine("local"); + var generatedRoot = Path.Combine(localAppData, "OpenClawTray-Dev"); + 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 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, + // 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-Dev' + $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', '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 + }) + 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 && 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"), + "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() {