From bb3d34b9f2a076339db159f30336b2832b19af5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 01:32:18 +0000 Subject: [PATCH] fix(uninstall): clean up empty wsl\ parent dir after VHD removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After Step 5a removes the distro-specific VHD directory (e.g. %LOCALAPPDATA%\OpenClawTray\wsl\OpenClawGateway\), the parent wsl\ directory was left as an empty orphan, which also prevented the %LOCALAPPDATA%\OpenClawTray\ directory from being removed by the MSIX uninstaller. Changes: - Add Step 5b in LocalGatewayUninstall.cs: after VHD parent dir cleanup, delete the wsl\ parent directory if it is empty. Non-empty wsl\ dirs (e.g. other distros still present) are preserved safely. - Add WslParentDirAbsent postcondition; include it in AllRequiredPostconditionsMet and AppendPostconditionErrors. - Update validate-wsl-gateway-uninstall.ps1: add wsl_parent_dir path constant, include wsl_parent_dir_absent in postconditions and required-checks list, and capture it in the pre-state snapshot. - Add 3 unit tests (WslParentDirCleanup_*) covering empty→deleted, non-empty→skipped, and already-absent→skipped scenarios. Closes #467 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/validate-wsl-gateway-uninstall.ps1 | 6 +- .../LocalGatewayUninstall.cs | 42 +++++++++- .../LocalGatewayUninstallTests.cs | 78 +++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 index 2eaab1c89..fc94a6a32 100644 --- a/scripts/validate-wsl-gateway-uninstall.ps1 +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -233,6 +233,7 @@ $settingsPath = Join-Path $appData "OpenClawTray\settings.json" $logsDir = Join-Path $localAppData "OpenClawTray\Logs" $execPolicyPath = Join-Path $localAppData "OpenClawTray\exec-policy.json" $vhdDirPath = Join-Path $localAppData "OpenClawTray\wsl\$DistroName" +$wslParentDirPath = Join-Path $localAppData "OpenClawTray\wsl" $autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" $autoStartAppName = "OpenClawTray" @@ -374,6 +375,7 @@ function Get-StateSnapshot { settings_exists = (Test-Path -LiteralPath $settingsPath) exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) + wsl_parent_dir_exists = (Test-Path -LiteralPath $wslParentDirPath) } processes_openclaw = @() } @@ -477,6 +479,7 @@ function Get-Postconditions { mcp_token_preserved = $mcpTokenPreserved keepalives_absent = $keepalivesAbsent vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath)) + wsl_parent_dir_absent = (-not (Test-Path -LiteralPath $wslParentDirPath)) } } @@ -488,7 +491,8 @@ function Get-Verdict { # Required postconditions (device_key_file_preserved and mcp_token_preserved are advisory). $required = @('wsl_distro_absent', 'autostart_cleared', 'setup_state_absent', - 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent') + 'device_token_cleared', 'keepalives_absent', 'vhd_dir_absent', + 'wsl_parent_dir_absent') $failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true }) $errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count } diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs index 8261562aa..33bd02a6a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs @@ -50,6 +50,9 @@ public sealed record LocalGatewayUninstallPostconditions /// VHD parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\<DistroName>. public bool VhdDirAbsent { get; init; } + /// WSL parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\ + public bool WslParentDirAbsent { get; init; } + /// No gateway records matching local predicate remain in gateways.json. public bool LocalGatewayRecordsAbsent { get; init; } @@ -420,6 +423,35 @@ await RunStepAsync("VHD parent dir cleanup", options, ct, () => return Task.CompletedTask; }); + // ------------------------------------------------------------------ + // Step 5b — WSL parent-dir cleanup (idempotent) + // After the distro-specific VHD dir is removed, clean up the empty + // wsl\ parent directory so the installer leaves no orphaned folders. + // ------------------------------------------------------------------ + await RunStepAsync("WSL parent dir cleanup", options, ct, () => + { + var wslDir = Path.Combine(_localDataPath, "wsl"); + if (!Directory.Exists(wslDir)) + { + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped, + "Directory absent."); + return Task.CompletedTask; + } + + if (!Directory.EnumerateFileSystemEntries(wslDir).Any()) + { + Directory.Delete(wslDir); + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Executed, + "Deleted empty wsl\\ parent directory."); + } + else + { + RecordStep("WSL parent dir cleanup", UninstallStepStatus.Skipped, + "Directory not empty; preserved."); + } + return Task.CompletedTask; + }); + // ------------------------------------------------------------------ // Step 6 — Reset autostart // CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting registry. @@ -783,6 +815,10 @@ private async Task ComputePostconditionsAsy bool vhdDirAbsent = !Directory.Exists( Path.Combine(_localDataPath, "wsl", options.DistroName)); + // WSL parent dir absent? + bool wslParentDirAbsent = !Directory.Exists( + Path.Combine(_localDataPath, "wsl")); + // Local gateway records absent? Reload from disk — fresh instance, not mutated in-memory. bool localRecordsAbsent; try @@ -806,6 +842,7 @@ private async Task ComputePostconditionsAsy McpTokenPreserved = mcpTokenPreserved, KeepalivesAbsent = keepalivesAbsent, VhdDirAbsent = vhdDirAbsent, + WslParentDirAbsent = wslParentDirAbsent, LocalGatewayRecordsAbsent = localRecordsAbsent, LocalGatewayIdentityDirsAbsent = localIdentityDirsAbsent }; @@ -819,7 +856,8 @@ private static bool AllRequiredPostconditionsMet(LocalGatewayUninstallPostcondit && p.LocalGatewayRecordsAbsent && p.LocalGatewayIdentityDirsAbsent && p.KeepalivesAbsent - && p.VhdDirAbsent; + && p.VhdDirAbsent + && p.WslParentDirAbsent; private static bool IsLocalGatewayRecordForUninstall(GatewayRecord record) { @@ -855,6 +893,8 @@ private void AppendPostconditionErrors(LocalGatewayUninstallPostconditions p) _errors.Add("Postcondition failed: keepalive process still running."); if (!p.VhdDirAbsent) _errors.Add("Postcondition failed: VHD directory still present."); + if (!p.WslParentDirAbsent) + _errors.Add("Postcondition failed: wsl\\ parent directory still present (may contain unexpected files)."); } private LocalGatewayUninstallResult BuildResult( diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs index b46937468..f2b07fc15 100644 --- a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs @@ -1435,4 +1435,82 @@ public async Task DryRun_SuccessTrue_PostconditionsSkipped() Assert.True(result.Success); Assert.Empty(result.Errors); } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir removed when empty after VHD cleanup + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_EmptyAfterVhdCleanup_ExecutedAndDeleted() + { + using var env = new UninstallTestEnv(); + var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(vhdDir); + File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake vhd"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Assert.False(Directory.Exists(wslDir)); + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Executed, step.Status); + Assert.True(result.Postconditions.WslParentDirAbsent); + } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir preserved when non-empty + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_NonEmpty_Skipped() + { + using var env = new UninstallTestEnv(); + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Directory.CreateDirectory(wslDir); + // Put an unrelated file in wsl\ to make it non-empty after VHD dir is gone + File.WriteAllText(Path.Combine(wslDir, "other-distro-marker.txt"), "preserved"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + Assert.True(Directory.Exists(wslDir), "wsl\\ dir should be preserved when non-empty"); + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Skipped, step.Status); + Assert.False(result.Postconditions.WslParentDirAbsent); + } + + // ----------------------------------------------------------------------- + // Test: WslParentDirCleanup — wsl\ dir already absent → Skipped (idempotent) + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task WslParentDirCleanup_AlreadyAbsent_Skipped() + { + using var env = new UninstallTestEnv(); + var wslDir = Path.Combine(env.LocalDataDir, "wsl"); + Assert.False(Directory.Exists(wslDir)); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + var step = result.Steps.FirstOrDefault(s => s.Name == "WSL parent dir cleanup"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Skipped, step.Status); + Assert.True(result.Postconditions.WslParentDirAbsent); + } }