Skip to content
Draft
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
9 changes: 9 additions & 0 deletions docs/SETUP_ENGINE_REDESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 79 additions & 4 deletions installer.iss
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ var
LocalGatewayCleanupChoiceInitialized: Boolean;
LocalGatewayCleanupRequested: Boolean;
LocalGatewayCleanupSucceeded: Boolean;
LocalGatewayCleanupScriptPath: String;
MigrationOperationHandle: THandle;
MigrationOperationLocked: Boolean;
MigrationOperationUnavailable: Boolean;
Expand Down Expand Up @@ -499,6 +500,8 @@ begin
Exit;
end;

LocalGatewayCleanupScriptPath := TempScriptPath;

Params :=
'-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) +
' -AppRoot ' + AddQuotes(ExpandConstant('{app}')) +
Expand Down Expand Up @@ -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;
Expand Down
155 changes: 148 additions & 7 deletions scripts/Uninstall-LocalGateway.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -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'
Expand Down
33 changes: 24 additions & 9 deletions tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
Expand All @@ -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 \}",
Expand All @@ -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]
Expand Down
Loading
Loading