Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion scripts/validate-wsl-gateway-uninstall.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = @()
}
Expand Down Expand Up @@ -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))
}
}

Expand All @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public sealed record LocalGatewayUninstallPostconditions
/// <summary>VHD parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\&lt;DistroName&gt;.</summary>
public bool VhdDirAbsent { get; init; }

/// <summary>WSL parent directory absent: %LOCALAPPDATA%\OpenClawTray\wsl\</summary>
public bool WslParentDirAbsent { get; init; }

/// <summary>No gateway records matching local predicate remain in gateways.json.</summary>
public bool LocalGatewayRecordsAbsent { get; init; }

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -783,6 +815,10 @@ private async Task<LocalGatewayUninstallPostconditions> 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
Expand All @@ -806,6 +842,7 @@ private async Task<LocalGatewayUninstallPostconditions> ComputePostconditionsAsy
McpTokenPreserved = mcpTokenPreserved,
KeepalivesAbsent = keepalivesAbsent,
VhdDirAbsent = vhdDirAbsent,
WslParentDirAbsent = wslParentDirAbsent,
LocalGatewayRecordsAbsent = localRecordsAbsent,
LocalGatewayIdentityDirsAbsent = localIdentityDirsAbsent
};
Expand All @@ -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)
{
Expand Down Expand Up @@ -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(
Expand Down
78 changes: 78 additions & 0 deletions tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}