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);
+ }
}