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/installer.iss b/installer.iss index 506720732..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}')) + @@ -300,15 +303,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 c18062d10..7545f1e11 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,19 +617,123 @@ 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" + $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)) { + 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 } - $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 { @@ -650,6 +755,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 e70d9747b..e5a1d7472 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,290 @@ 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); + } + } + + [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"); + 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' + $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 && 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() {