diff --git a/.gitignore b/.gitignore index 1f0c7d423..4cc76c6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -352,3 +352,8 @@ visual-test-output/ # Local squad agent state .squad/ + +# MSIX and packaging test output +msix-validation-evidence/ +packaging-test-output/ +uninstall-validation-output/ diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md new file mode 100644 index 000000000..5a50ce336 --- /dev/null +++ b/docs/uninstall-msix.md @@ -0,0 +1,75 @@ +# Uninstalling OpenClaw Tray — MSIX Package + +> **Date:** 2026-05-07 +> **Branch:** feat/wsl-gateway-uninstall + +--- + +## MSIX Cannot Auto-Clean WSL State + +**Feasibility verdict:** `runFullTrust` MSIX packages do **not** support a +`CustomInstall` / `CustomUninstall` extension that runs an arbitrary EXE +at uninstall time. The supported extension points +(`windows.startupTask`, `windows.appExecutionAlias`, `com.extension`, +`windows.protocol`, etc.) do not include an uninstall hook. + +Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray +→ Uninstall** will silently leave behind: + +- **WSL distro** — `OpenClawGateway` remains in `wsl --list`. +- **Roaming app data** under `%APPDATA%\OpenClawTray\` (device key, settings, + mcp-token). +- **Local app data** under `%LOCALAPPDATA%\OpenClawTray\` (setup state, logs, + VHD parent directory). + +> **Note:** If the tray was installed with MSIX and the data landed in the +> package-virtualized path (`%LOCALAPPDATA%\Packages\OpenClaw.Tray_\...`) +> instead of real `%APPDATA%`, those directories are removed automatically by +> MSIX on uninstall. Bostick's commit 7 validation test (Path A vs Path B) +> confirms which layout applies. + +--- + +## Recommended: Run "Remove Local Gateway" Before Uninstalling MSIX + +1. Open the tray icon. +2. Navigate to **Settings → Local Gateway**. +3. Click **"Remove Local Gateway"** (Mattingly's warning banner in commit 4 + surfaces this step for MSIX users). +4. Wait for the engine to complete — it stops keepalive processes, unregisters + the WSL distro, nulls the device token, removes autostart, and cleans up app + data. +5. Uninstall the MSIX package via **Settings → Apps**. + +--- + +## Manual Recovery (After MSIX Removed Without In-Tray Cleanup) + +If the MSIX was already removed and the WSL distro / app data remains: + +```powershell +# 1. Unregister the distro (removes .vhdx from wsl's internal store) +wsl --unregister OpenClawGateway + +# 2. Remove VHD parent directory (wsl --unregister may leave the folder) +Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" ` + -Recurse -Force -ErrorAction SilentlyContinue + +# 3. Remove autostart registry entry +Remove-ItemProperty ` + -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` + -Name "OpenClawTray" -ErrorAction SilentlyContinue + +# 4. Remove local app data (setup state, logs) +Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue + +# 5. Remove roaming app data (settings, device key — only if you want full purge) +# NOTE: mcp-token.txt is intentionally preserved here; delete manually if needed. +Remove-Item "$env:APPDATA\OpenClawTray\setup-state.json" -Force -ErrorAction SilentlyContinue +``` + +Or use the validation script if it is available separately: + +```powershell +.\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive +``` diff --git a/docs/uninstall-portable.md b/docs/uninstall-portable.md new file mode 100644 index 000000000..027b7e164 --- /dev/null +++ b/docs/uninstall-portable.md @@ -0,0 +1,71 @@ +# Uninstalling OpenClaw Tray — Portable ZIP + +> **Date:** 2026-05-07 +> **Branch:** feat/wsl-gateway-uninstall + +Portable (ZIP) installations have **no automatic uninstall hook**. +Simply deleting the folder leaves the WSL distro, app data, and autostart +entry behind. Follow one of the two paths below for a clean removal. + +--- + +## Recommended: In-Tray Removal (Requires the Tray Running) + +1. Open the tray icon. +2. Navigate to **Settings → Local Gateway**. +3. Click **"Remove Local Gateway"**. +4. The engine stops keepalive processes, unregisters the WSL distro, nulls + the device token, removes autostart, and cleans up app data. +5. After the operation completes, delete the portable folder. + +--- + +## CLI: Headless Removal (No Tray UI Required) + +Run from the portable folder: + +```powershell +# Destructive — removes the local WSL gateway cleanly, then print result to stdout +.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive + +# With JSON output for programmatic consumption (tokens redacted in output): +.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive --json-output .\uninstall-result.json + +# Dry-run — records what would happen without any destruction: +.\OpenClaw.Tray.WinUI.exe --uninstall --dry-run +``` + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| 0 | Success — all steps completed, postconditions satisfied | +| 1 | Partial failure — one or more steps failed (see JSON output or stderr) | +| 2 | Bad arguments — `--confirm-destructive` or `--dry-run` missing | + +After the CLI command exits 0, delete the portable folder. + +--- + +## WARNING: Deleting the Folder Without Running Uninstall + +Deleting the portable folder **without** running the uninstall first leaves: + +- **WSL distro orphaned** — `OpenClawGateway` remains in `wsl --list`. + Manual cleanup: `wsl --unregister OpenClawGateway` + +- **App data** remains under: + - `%APPDATA%\OpenClawTray\` — device key, settings, mcp-token + - `%LOCALAPPDATA%\OpenClawTray\` — setup state, logs, exec policy, VHD parent dir + +- **Autostart entry** may remain in + `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` + +Manual WSL + registry cleanup: + +```powershell +wsl --unregister OpenClawGateway +Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` + -Name "OpenClawTray" -ErrorAction SilentlyContinue +Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" -Recurse -Force -ErrorAction SilentlyContinue +``` diff --git a/installer.iss b/installer.iss index 82d65c673..661cd691f 100644 --- a/installer.iss +++ b/installer.iss @@ -27,6 +27,11 @@ WizardStyle=modern PrivilegesRequired=lowest SetupIconFile=src\OpenClaw.Tray.WinUI\Assets\openclaw.ico UninstallDisplayIcon={app}\{#MyAppExeName} +; Round 2 (Scott #5): block install/uninstall while the tray is running. +; Mutex name matches App.xaml.cs (`new Mutex(true, "OpenClawTray", …)`). +; Tray and Inno run in the same user session, so the bare name resolves +; against Local\OpenClawTray — no Global\ prefix needed. +AppMutex=OpenClawTray #if MyAppArch == "arm64" ArchitecturesInstallIn64BitMode=arm64 ArchitecturesAllowed=arm64 @@ -51,8 +56,12 @@ Name: "cmdpalette"; Description: "Install PowerToys Command Palette extension"; [Files] ; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; Command Palette extension (all files from build output) -Source: "{#publish}\cmdpal\*"; DestDir: "{app}\CommandPalette"; Flags: ignoreversion recursesubdirs; Tasks: cmdpalette +; Command Palette extension (all files from build output). +; skipifsourcedoesntexist: prevents ISCC compile error when the cmdpal publish +; dir is absent (e.g. developer builds that skip the cmdpalette task). +Source: "{#publish}\cmdpal\*"; DestDir: "{app}\CommandPalette"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist; Tasks: cmdpalette +; WSL gateway uninstall helper — invoked by [UninstallRun] to drive clean removal +Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" @@ -66,5 +75,16 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Add-AppxPackage -Register '{app}\CommandPalette\AppxManifest.xml' -ForceApplicationShutdown"""; Flags: runhidden; Tasks: cmdpalette [UninstallRun] +; ORDERING NOTE: Inno Setup runs [UninstallRun] entries BEFORE deleting {app} +; directory contents. This guarantees OpenClawTray.exe is still present when +; the script executes. See Inno docs: "[UninstallRun] section". +; Fallback: if OpenClawTray.exe is missing for any reason, Uninstall-LocalGateway.ps1 +; logs the error to {app}\uninstall-gateway-error.log and exits 0 so Inno continues. +; *** DO NOT COMMENT OUT OR REMOVE THE Flags LINE BELOW *** +; waituntilterminated is non-negotiable: without it Inno races ahead and deletes +; {app} while the PowerShell hook (and the CLI engine it invokes) is still running, +; leaving 279+ application files behind after unins000.exe reports exit 0. +; runhidden suppresses the console window that would otherwise flash briefly. +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\Uninstall-LocalGateway.ps1"""; Flags: shellexec waituntilterminated runhidden; StatusMsg: "Removing local WSL gateway..." ; Unregister Command Palette extension on uninstall Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Get-AppxPackage -Name '*OpenClaw*' | Remove-AppxPackage"""; Flags: runhidden diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 new file mode 100644 index 000000000..cde043c95 --- /dev/null +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -0,0 +1,79 @@ +<# +.SYNOPSIS + Inno Setup [UninstallRun] helper — removes the local WSL gateway via the + OpenClaw tray CLI flag. + +.DESCRIPTION + INNO ORDERING CONTRACT + ---------------------- + Per Inno Setup documentation, [UninstallRun] entries execute BEFORE the + {app} directory is deleted. OpenClawTray.exe is therefore guaranteed to + be present when this script runs. + + WHAT THIS SCRIPT DOES + --------------------- + 1. Locates OpenClawTray.exe in the same directory as this script ({app}). + 2. Invokes: OpenClawTray.exe --uninstall --confirm-destructive --json-output + 3. Logs success or failure to {app}\uninstall-gateway-result.json. + 4. If the EXE is missing (e.g., partial install), logs the error and exits 0 + so the Inno uninstaller continues. The user may need to clean up manually + (see docs\uninstall-portable.md for manual steps). + + FALLBACK + -------- + Exit 0 in all error cases so Inno does not abort the uninstall if gateway + cleanup fails. The result JSON captures the failure for post-mortem. + +.NOTES + Date: 2026-05-07 + Author: Aaron (Backend / Infrastructure Engineer) + Branch: feat/wsl-gateway-uninstall + Commit: 5 of 7 + + Token / key material is NEVER written to the result log; the engine + and CLI layer both redact sensitive fields before serializing. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$scriptDir = $PSScriptRoot +$exePath = Join-Path $scriptDir 'OpenClaw.Tray.WinUI.exe' +$resultPath = Join-Path $scriptDir 'uninstall-gateway-result.json' +$errorPath = Join-Path $scriptDir 'uninstall-gateway-error.log' + +# --------------------------------------------------------------------------- +# EXE presence check — fallback if somehow missing +# --------------------------------------------------------------------------- +if (-not (Test-Path -LiteralPath $exePath)) { + $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1: " + + "OpenClawTray.exe not found at '$exePath'. " + + "WSL gateway cleanup skipped. Manual cleanup may be required." + try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + Write-Warning $msg + exit 0 +} + +# --------------------------------------------------------------------------- +# Invoke CLI uninstall +# --------------------------------------------------------------------------- +$exitCode = 0 +try { + & $exePath --uninstall --confirm-destructive --json-output $resultPath + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + + if ($exitCode -eq 0) { + Write-Host "OpenClaw local WSL gateway removed successfully." -ForegroundColor Green + } else { + Write-Warning "OpenClaw gateway uninstall exited $exitCode; see '$resultPath' for details." + } +} catch { + $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1 error: $($_.Exception.Message)" + try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} + Write-Warning $msg +} + +# Always exit 0 so Inno does not abort the broader uninstall. +exit 0 diff --git a/scripts/_uninstall-helpers.ps1 b/scripts/_uninstall-helpers.ps1 new file mode 100644 index 000000000..64c95963c --- /dev/null +++ b/scripts/_uninstall-helpers.ps1 @@ -0,0 +1,151 @@ +# _uninstall-helpers.ps1 +# +# Shared helper functions for OpenClaw uninstall and cleanup scripts. +# Dot-source this file at the top of any script that needs these utilities: +# +# . "$PSScriptRoot\_uninstall-helpers.ps1" +# +# Note: Add-Step requires a $script:steps array to be initialised in the +# calling script before use (e.g. $script:steps = @()). + +# --------------------------------------------------------------------------- +# Distro-name guard +# Exact-match guard. Mirrors C# LocalGatewayUninstall.AllowedDistroName. +# Round 2 (Scott #4): prefix matching was dead allowance that let test +# distros like "OpenClawGateway-test" pass param validation and then strand +# at the final unregister guard (which is exact-match). Exact-everywhere. +# --------------------------------------------------------------------------- + +function Test-IsOpenClawOwnedDistroName { + param([string]$Name) + + return $Name -eq "OpenClawGateway" +} + +# --------------------------------------------------------------------------- +# WSL command runner +# --------------------------------------------------------------------------- + +function Invoke-WslCommand { + <# + .SYNOPSIS + Runs a bash command inside WSL and returns stdout, stderr, and exit code. + .PARAMETER Command + The bash command string to execute via `wsl bash -c`. + .PARAMETER DistroName + Optional WSL distribution name. Omit to use the default distribution. + .OUTPUTS + A hashtable with keys: Stdout, Stderr, ExitCode. + #> + param( + [Parameter(Mandatory)] + [string]$Command, + [string]$DistroName + ) + + $wslArgs = if ($DistroName) { + @("-d", $DistroName, "bash", "-c", $Command) + } else { + @("bash", "-c", $Command) + } + + $stdoutLines = [System.Collections.Generic.List[string]]::new() + $stderrLines = [System.Collections.Generic.List[string]]::new() + + & wsl @wslArgs 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + $stderrLines.Add($_.ToString()) + } else { + $stdoutLines.Add($_) + } + } + + return @{ + Stdout = $stdoutLines -join "`n" + Stderr = $stderrLines -join "`n" + ExitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + } +} + +# --------------------------------------------------------------------------- +# Process termination +# --------------------------------------------------------------------------- + +function Stop-OpenClawProcessByPid { + <# + .SYNOPSIS + Terminates a process by PID, suppressing "not found" errors. + .PARAMETER ProcessId + PID of the process to terminate. + .PARAMETER Force + If specified, uses -Force on Stop-Process. + #> + param( + [Parameter(Mandatory)] + [int]$ProcessId, + [switch]$Force + ) + + try { + if ($Force) { + Stop-Process -Id $ProcessId -Force -ErrorAction Stop + } else { + Stop-Process -Id $ProcessId -ErrorAction Stop + } + } catch [Microsoft.PowerShell.Commands.ProcessCommandException] { + # Process already exited — not an error. + } +} + +# --------------------------------------------------------------------------- +# Dry-run gate +# --------------------------------------------------------------------------- + +function Assert-DryRunGate { + <# + .SYNOPSIS + Throws if the caller is in dry-run mode. Intended to guard any + statement that mutates persistent state (filesystem, processes, WSL). + .PARAMETER DryRun + Boolean dry-run flag from the calling script. + .PARAMETER OperationDescription + Human-readable description of the blocked operation (used in error message). + #> + param( + [Parameter(Mandatory)] + [bool]$DryRun, + [string]$OperationDescription = "destructive operation" + ) + + if ($DryRun) { + throw "Dry-run mode is active; $OperationDescription was not executed." + } +} + +# --------------------------------------------------------------------------- +# Step logging +# --------------------------------------------------------------------------- + +function Add-Step { + <# + .SYNOPSIS + Appends a structured step entry to `$script:steps` in the calling script. + .NOTES + The calling script must declare `$script:steps = @()` before dot-sourcing + this file or before first calling Add-Step. + #> + param( + [string]$Name, + [string]$Status, + [string]$Message, + [hashtable]$Data = @{} + ) + + $script:steps += [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } +} diff --git a/scripts/reset-openclaw-wsl-validation-state.ps1 b/scripts/reset-openclaw-wsl-validation-state.ps1 index 04bf9fdc0..9543c314f 100644 --- a/scripts/reset-openclaw-wsl-validation-state.ps1 +++ b/scripts/reset-openclaw-wsl-validation-state.ps1 @@ -30,6 +30,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +. "$PSScriptRoot\_uninstall-helpers.ps1" + # Production-locked WSL distro name (Phase 3 constant). This script will # refuse to act on any other distro, even via -DistroName overrides # (which are intentionally absent). diff --git a/scripts/validate-msix-storage-paths.ps1 b/scripts/validate-msix-storage-paths.ps1 new file mode 100644 index 000000000..4a2d62924 --- /dev/null +++ b/scripts/validate-msix-storage-paths.ps1 @@ -0,0 +1,1272 @@ +<# +.SYNOPSIS + Empirically determines whether OpenClawTray MSIX writes user data to real + %APPDATA%/%LOCALAPPDATA% paths (Path A) or MSIX-virtualized package storage + (Path B). The verdict drives the MSIX uninstall strategy. + +.DESCRIPTION + OpenClawTray declares the runFullTrust restricted capability, which typically + bypasses MSIX filesystem virtualization and writes to the real roaming/local + app-data folders. This cannot be assumed — it must be verified empirically + before the MSIX uninstall surface is considered complete. + + VERDICT SEMANTICS + ----------------- + PathA-OrphanRisk + Files land in real %APPDATA%\OpenClawTray\ and/or %LOCALAPPDATA%\OpenClawTray\. + Remove-AppxPackage does NOT clean these up. The "Remove Local Gateway" in-tray + button is the canonical pre-uninstall cleanup path. A pre-removal warning banner + (PackageHelper.IsPackaged() && setup-state.json exists) MUST ship in commit 5. + + PathB-CleanRemove + Files land only inside %LOCALAPPDATA%\Packages\\. + Remove-AppxPackage deletes them automatically. WSL distro registration still + requires explicit cleanup (not handled by MSIX removal). In-tray warning banner + is optional / informational only. + + Inconclusive + The probe could not produce a definitive answer (no probe files found in either + location, app launch failure, timeout, etc.). Investigation is required before + MSIX uninstall claims can be made. + + ## Notes for Aaron + ================== + Read verdict.json → field "verdict" to branch your commit-5 decisions: + + If "PathA-OrphanRisk": + - Keep the "Remove Local Gateway" in-tray button as the canonical cleanup path. + - MUST add in-app pre-uninstall warning banner gated on: + PackageHelper.IsPackaged() && File.Exists(setupStatePath) + so users are warned before removing the MSIX package. + - The Inno uninstaller script (Uninstall-LocalGateway.ps1) targets real paths + unconditionally — no change needed there. + - Recovery: scripts/validate-wsl-gateway-uninstall.ps1 -Scenario Full + -ConfirmDestructiveClean is still relevant for orphaned state. + + If "PathB-CleanRemove": + - Remove-AppxPackage handles file-based artifact cleanup automatically. + - MSIX section of the plan is limited to WSL distro cleanup only (steps 2-5 of + canonical sequence: stop service, terminate, unregister distro, remove VHD dir). + - In-app warning banner is optional. You may still want it for WSL distro orphan + risk (distro registration is NOT cleaned by Remove-AppxPackage in either path). + - document in Artifact Catalog that MSIX path is path B. + + If "Inconclusive": + - Block commit 5 MSIX claims. Either re-run on a clean VM or defer MSIX + validation to a tracked TODO. Do NOT ship "MSIX removal is sufficient" + language without a pass verdict. + + OPEN QUESTIONS FOR AARON (pre-commit-5) + ======================================== + Q1: If -AutoSetup detection is infeasible on a given test machine (no interactive + session, sandboxed runner), do you want to defer MSIX validation to a manual + TODO tracked in the PR, or require a VM pre-condition before commit 5 merges? + Default assumption: commit 5 is gated on a non-Inconclusive verdict. + + Q2: Should the in-app warning banner check PackageHelper.IsPackaged() at runtime, + or check for APPX identity via Environment.GetEnvironmentVariable("LOCALAPPDATA")? + The former is more robust. Confirm the PackageHelper API is available in the + Settings page code-behind at the time commit 5 lands. + + ⚠️ SECURITY NOTE: Do NOT run this script against a live, user-paired tray instance. + Filesystem snapshots captured in -EvidenceDir would include settings.json + which may contain gateway token fields. Run only on a clean test machine or + dedicated validation VM. + + CI ARTIFACT + ----------- + The MSIX is produced by the build-msix CI job. Download artifact: + gh run download --name openclaw-msix-win-x64 --dir ./msix-drop/ + Then pass the .msix file path to -MsixPath. + +.PARAMETER MsixPath + Absolute path to the OpenClawTray MSIX file (e.g. OpenClawTray_1.2.3.0_x64.msix) + as produced by the build-msix CI job. Required unless -SkipInstall is set. + +.PARAMETER CertPath + Optional path to a .cer or .pfx sideload certificate. If provided, the cert is + imported to Cert:\LocalMachine\TrustedPeople before Add-AppxPackage is called. + +.PARAMETER EvidenceDir + Directory where ALL captured artifacts land. Defaults to + .\msix-validation-evidence\\ + +.PARAMETER SkipInstall + Assume the MSIX is already installed on this machine. Skip Add-AppxPackage and + jump directly to the probe + uninstall phases. + +.PARAMETER AutoSetup + Write a small probe settings.json and run.marker-bait directly to the expected real + app-data paths, then launch the installed tray briefly (30 s), and infer storage + routing from which files change / appear. This avoids the manual UI walk-through. + DEFAULT: enabled (the script defaults to -AutoSetup). + +.PARAMETER SkipAutoSetup + Disable the -AutoSetup logic. The script emits a MANUAL-STEP-REQUIRED.txt and + exits with code 3, asking the operator to walk through Setup-Locally in the tray + UI, then re-run with -SkipInstall to continue from phase 5. + +.PARAMETER WhatIf + Print all planned actions without executing any destructive operations. + +.PARAMETER Help + Print usage and exit. + +.EXAMPLE + # Typical run (auto-probe mode): + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -CertPath C:\drop\OpenClawTray.cer ` + -EvidenceDir C:\msix-evidence\run1 + +.EXAMPLE + # Manual UI walk-through mode: + # Step 1 — install and emit manual instructions: + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -SkipAutoSetup ` + -EvidenceDir C:\msix-evidence\run1 + # (exit code 3 — operator walks through Setup-Locally) + + # Step 2 — probe + teardown (app already installed): + .\validate-msix-storage-paths.ps1 ` + -SkipInstall ` + -EvidenceDir C:\msix-evidence\run1 + +.EXAMPLE + # WhatIf dry-run: + .\validate-msix-storage-paths.ps1 ` + -MsixPath C:\drop\OpenClawTray_1.2.3.0_x64.msix ` + -WhatIf + + EVIDENCE LAYOUT + --------------- + / + pre-appdata.txt — recursive dir listing of %APPDATA%\OpenClawTray\ pre-install + pre-localappdata.txt — same for %LOCALAPPDATA%\OpenClawTray\ + pre-packages.txt — %LOCALAPPDATA%\Packages\*OpenClaw* pre-install + pre-appx.json — Get-AppxPackage *OpenClaw* pre-install (JSON) + install-stdout.txt — Add-AppxPackage output + install-stderr.txt — Add-AppxPackage errors + package-info.json — PackageFamilyName, InstallLocation, PackageFullName + post-appdata.txt — same dirs post-install/probe + post-localappdata.txt + post-packages.txt + post-appx.json + post-uninstall-appdata.txt — same dirs after Remove-AppxPackage + post-uninstall-localappdata.txt + post-uninstall-packages.txt + verdict.json — final verdict with removal_orphans + summary.json — script run metadata, all steps + MANUAL-STEP-REQUIRED.txt — emitted only when -SkipAutoSetup is used + + EXIT CODES + ---------- + 0 Script passed: non-Inconclusive verdict, all evidence files present, no errors. + 1 Script failed: Inconclusive verdict, incomplete evidence, or unhandled errors. + 2 Pre-flight blocked: OpenClaw* process running, or other blocking condition. + 3 Manual step required: -SkipAutoSetup was used; operator must walk UI flow. + +.NOTES + Date: 2026-05-07 + Author: Bostick (Tester/FIDO) — drafted pre-commit-7 for commit-7 verification. + DO NOT RUN against a paired tray instance. Snapshots capture settings.json content. +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [string]$MsixPath, + [string]$CertPath, + [string]$EvidenceDir, + [switch]$SkipInstall, + [switch]$AutoSetup, # default ON — see logic below + [switch]$SkipAutoSetup, # force manual path + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- +if ($Help) { + Get-Help -Full $PSCommandPath + exit 0 +} + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +$SCRIPT_VERSION = "1.0.0" +$SCRIPT_DATE = "2026-05-07" +$PACKAGE_NAME_GLOB = "*OpenClaw.Tray*" +# Publisher hash in PackageFamilyName is derived from the manifest Publisher DN. +# We resolve it at runtime from Get-AppxPackage after install. +$PROBE_SESSION_ID = [System.Guid]::NewGuid().ToString("N") +$PROBE_MARKER_NAME = ".msix-storage-probe-$PROBE_SESSION_ID" +$RUN_MARKER_NAME = "run.marker" +$SETTINGS_FILE_NAME = "settings.json" + +# --------------------------------------------------------------------------- +# Default -AutoSetup ON unless -SkipAutoSetup explicitly set +# --------------------------------------------------------------------------- +$effectiveAutoSetup = -not $SkipAutoSetup.IsPresent + +# --------------------------------------------------------------------------- +# Evidence directory default +# --------------------------------------------------------------------------- +$runStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss") +if ([string]::IsNullOrWhiteSpace($EvidenceDir)) { + $EvidenceDir = Join-Path (Get-Location) "msix-validation-evidence\$runStamp" +} + +# --------------------------------------------------------------------------- +# Script-level state +# --------------------------------------------------------------------------- +$script:summary = [ordered]@{ + script = "validate-msix-storage-paths" + version = $SCRIPT_VERSION + date = $SCRIPT_DATE + startedAt = (Get-Date).ToString("o") + finishedAt = $null + status = "Running" + probeSessionId = $PROBE_SESSION_ID + autoSetup = $effectiveAutoSetup + evidenceDir = $EvidenceDir + msixPath = $MsixPath + packageFamilyName = $null + packageFullName = $null + installLocation = $null + verdict = $null + steps = @() + error = $null +} + +# Script-level slot for engine result (populated by Invoke-CliEngineUninstall) +$script:engineResult = $null + +# Exit-code sentinels +$EXIT_PASS = 0 +$EXIT_FAIL = 1 +$EXIT_PREFLIGHT_BLOCK = 2 +$EXIT_MANUAL_REQUIRED = 3 + +# --------------------------------------------------------------------------- +# Logging helpers (mirror validate-wsl-gateway.ps1 patterns) +# --------------------------------------------------------------------------- +function Add-Step { + param( + [string]$Name, + [string]$Status, # Completed | Failed | Skipped | Warning + [string]$Message, + [hashtable]$Data = @{} + ) + $entry = [ordered]@{ + name = $Name + status = $Status + message = $Message + data = $Data + timestamp = (Get-Date).ToString("o") + } + $script:summary.steps += $entry + + $ts = (Get-Date).ToString("HH:mm:ss") + $color = switch ($Status) { + "Completed" { "Cyan" } + "Failed" { "Red" } + "Skipped" { "DarkGray" } + "Warning" { "Yellow" } + default { "White" } + } + Write-Host "[$ts] [$Status] $Name — $Message" -ForegroundColor $color +} + +function Write-StepInfo { + param([string]$Message) + $ts = (Get-Date).ToString("HH:mm:ss") + Write-Host "[$ts] $Message" -ForegroundColor DarkCyan +} + +function Write-Fatal { + param([string]$Message) + $ts = (Get-Date).ToString("HH:mm:ss") + Write-Host "[$ts] [FATAL] $Message" -ForegroundColor Red +} + +# --------------------------------------------------------------------------- +# Evidence directory helpers +# --------------------------------------------------------------------------- +function Ensure-EvidenceDir { + if (-not (Test-Path -LiteralPath $EvidenceDir)) { + New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null + } +} + +function Get-EvidencePath { + param([string]$FileName) + Ensure-EvidenceDir + return Join-Path $EvidenceDir $FileName +} + +# --------------------------------------------------------------------------- +# Snapshot helpers +# --------------------------------------------------------------------------- +function Capture-DirListing { + param( + [string]$Path, + [string]$OutFile + ) + $dest = Get-EvidencePath $OutFile + if (Test-Path -LiteralPath $Path) { + try { + $items = Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | + Select-Object FullName, Length, LastWriteTimeUtc, Attributes | + Sort-Object FullName + $lines = @("# Captured: $(Get-Date -Format 'o') Path: $Path") + foreach ($i in $items) { + $lines += "$($i.LastWriteTimeUtc.ToString('o')) $($i.Attributes.ToString().PadRight(12)) $($i.Length.ToString().PadLeft(12)) $($i.FullName)" + } + $lines | Set-Content -LiteralPath $dest -Encoding UTF8 + return $items.Count + } + catch { + "# ERROR capturing $Path : $_" | Set-Content -LiteralPath $dest -Encoding UTF8 + return -1 + } + } + else { + "# Path does not exist: $Path (captured: $(Get-Date -Format 'o'))" | + Set-Content -LiteralPath $dest -Encoding UTF8 + return 0 + } +} + +function Capture-PackagesListing { + param([string]$OutFile) + $dest = Get-EvidencePath $OutFile + $pkgDir = Join-Path $env:LOCALAPPDATA "Packages" + if (Test-Path -LiteralPath $pkgDir) { + try { + $items = Get-ChildItem -LiteralPath $pkgDir -Directory -Filter "*OpenClaw*" -Force -ErrorAction SilentlyContinue | + Select-Object FullName, CreationTimeUtc, LastWriteTimeUtc + $lines = @("# Captured: $(Get-Date -Format 'o') Packages filter: *OpenClaw*") + foreach ($i in $items) { + $lines += "created=$($i.CreationTimeUtc.ToString('o')) modified=$($i.LastWriteTimeUtc.ToString('o')) $($i.FullName)" + # Recurse one level to show sub-dirs (LocalCache, LocalState, Settings, etc.) + $subs = Get-ChildItem -LiteralPath $i.FullName -Directory -Force -ErrorAction SilentlyContinue + foreach ($s in $subs) { + $lines += " + $($s.Name)" + } + } + $lines | Set-Content -LiteralPath $dest -Encoding UTF8 + return $items.Count + } + catch { + "# ERROR: $_" | Set-Content -LiteralPath $dest -Encoding UTF8 + return -1 + } + } + else { + "# %LOCALAPPDATA%\Packages does not exist" | Set-Content -LiteralPath $dest -Encoding UTF8 + return 0 + } +} + +function Capture-AppxPackage { + param([string]$OutFile) + $dest = Get-EvidencePath $OutFile + try { + $pkgs = Get-AppxPackage -Name "*OpenClaw*" -ErrorAction SilentlyContinue + if ($null -eq $pkgs) { $pkgs = @() } + $pkgs | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $dest -Encoding UTF8 + } + catch { + @{ error = $_.ToString() } | ConvertTo-Json | Set-Content -LiteralPath $dest -Encoding UTF8 + } +} + +function Capture-AllSnapshots { + param([string]$Prefix) + # Prefix is "pre" | "post" | "post-uninstall" + $appDataDir = Join-Path $env:APPDATA "OpenClawTray" + $localAppDataDir = Join-Path $env:LOCALAPPDATA "OpenClawTray" + + $countA = Capture-DirListing -Path $appDataDir -OutFile "$Prefix-appdata.txt" + $countL = Capture-DirListing -Path $localAppDataDir -OutFile "$Prefix-localappdata.txt" + $countP = Capture-PackagesListing -OutFile "$Prefix-packages.txt" + Capture-AppxPackage -OutFile "$Prefix-appx.json" + + Add-Step "snapshot-$Prefix" "Completed" "Captured filesystem snapshots ($Prefix)." @{ + appDataItemCount = $countA + localAppDataItemCount = $countL + openClawPackageCount = $countP + } +} + +# --------------------------------------------------------------------------- +# Diff helper — returns list of new/changed paths in target vs baseline +# --------------------------------------------------------------------------- +function Get-NewPaths { + param( + [string]$BaselineFile, + [string]$NewFile + ) + if (-not (Test-Path -LiteralPath $BaselineFile)) { return @() } + if (-not (Test-Path -LiteralPath $NewFile)) { return @() } + + $baseline = Get-Content -LiteralPath $BaselineFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $current = Get-Content -LiteralPath $NewFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + + $baseSet = [System.Collections.Generic.HashSet[string]]::new($baseline, [System.StringComparer]::OrdinalIgnoreCase) + $new = $current | Where-Object { -not $baseSet.Contains($_) } + return @($new) +} + +# --------------------------------------------------------------------------- +# Write summary files +# --------------------------------------------------------------------------- +function Write-Summary { + Ensure-EvidenceDir + $script:summary.finishedAt = (Get-Date).ToString("o") + $summaryPath = Get-EvidencePath "summary.json" + $script:summary | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 +} + +# --------------------------------------------------------------------------- +# Invoke a process, capture stdout/stderr, record step +# --------------------------------------------------------------------------- +function Invoke-CapturedProcess { + param( + [string]$StepName, + [string]$FilePath, + [string[]]$ArgumentList = @(), + [string]$WorkingDirectory = (Get-Location).Path, + [switch]$IgnoreExitCode + ) + Ensure-EvidenceDir + $safeName = $StepName -replace '[^a-zA-Z0-9_.-]', '-' + $stdoutFile = Get-EvidencePath "$safeName.stdout.txt" + $stderrFile = Get-EvidencePath "$safeName.stderr.txt" + + Push-Location $WorkingDirectory + try { + & $FilePath @ArgumentList > $stdoutFile 2> $stderrFile + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { [int]$global:LASTEXITCODE } + } + finally { + Pop-Location + } + + Add-Step $StepName "Completed" "Exit code $exitCode." @{ + file = $FilePath + arguments = ($ArgumentList -join " ") + exitCode = $exitCode + stdout = $stdoutFile + stderr = $stderrFile + } + + if ($exitCode -ne 0 -and -not $IgnoreExitCode) { + throw "$StepName failed with exit code $exitCode. See $stderrFile" + } + return $exitCode +} + +# --------------------------------------------------------------------------- +# PHASE 1 — PREFLIGHT +# --------------------------------------------------------------------------- +function Invoke-Preflight { + Write-Host "" + Write-Host "═══ PHASE 1: PREFLIGHT ═══" -ForegroundColor Magenta + + # 1a. Interactive session check + if ($PSVersionTable.PSVersion.Major -ge 5) { + $sessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId + if ($sessionId -eq 0) { + Write-Fatal "This script must run in an interactive Windows session (session 0 detected — likely a non-interactive service context). MSIX installation requires a user session." + return $EXIT_PREFLIGHT_BLOCK + } + } + Add-Step "preflight-session-check" "Completed" "Interactive session confirmed (SessionId=$([System.Diagnostics.Process]::GetCurrentProcess().SessionId))." + + # 1b. Refuse if any OpenClaw* process is running + $running = Get-Process -Name "OpenClaw*" -ErrorAction SilentlyContinue + if ($running) { + $pids = ($running | ForEach-Object { $_.Id }) -join ", " + Write-Fatal "OpenClaw* process(es) are running (PIDs: $pids). Stop them first:" + foreach ($p in $running) { + Write-Fatal " Stop-Process -Id $($p.Id) # $($p.ProcessName)" + } + Add-Step "preflight-process-check" "Failed" "OpenClaw processes running: PIDs $pids" @{ pids = $pids } + return $EXIT_PREFLIGHT_BLOCK + } + Add-Step "preflight-process-check" "Completed" "No OpenClaw* processes running." + + # 1c. Validate -MsixPath (unless -SkipInstall) + if (-not $SkipInstall) { + if ([string]::IsNullOrWhiteSpace($MsixPath)) { + Write-Fatal "-MsixPath is required unless -SkipInstall is set." + Add-Step "preflight-msix-path" "Failed" "-MsixPath not provided." + return $EXIT_PREFLIGHT_BLOCK + } + if (-not (Test-Path -LiteralPath $MsixPath)) { + Write-Fatal "-MsixPath does not exist: $MsixPath" + Add-Step "preflight-msix-path" "Failed" "File not found: $MsixPath" + return $EXIT_PREFLIGHT_BLOCK + } + Add-Step "preflight-msix-path" "Completed" "MSIX file found: $MsixPath" + } + else { + Add-Step "preflight-msix-path" "Skipped" "-SkipInstall set; skipping MSIX path validation." + } + + # 1d. Check for pre-existing OpenClawTray package + $existing = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + if ($existing -and -not $SkipInstall) { + Add-Step "preflight-existing-package" "Warning" "Pre-existing OpenClaw.Tray package found: $($existing.PackageFullName). Will overwrite via Add-AppxPackage." @{ + existingFullName = $existing.PackageFullName + } + } + elseif ($SkipInstall -and -not $existing) { + Write-Fatal "-SkipInstall set but no OpenClaw.Tray package is installed. Nothing to probe." + Add-Step "preflight-existing-package" "Failed" "No installed package found but -SkipInstall was set." + return $EXIT_PREFLIGHT_BLOCK + } + else { + Add-Step "preflight-existing-package" "Completed" "No pre-existing OpenClaw.Tray package found (clean state)." + } + + # 1e. WhatIf gate + if ($WhatIfPreference) { + Write-Host "" + Write-Host "WhatIf mode — planned actions:" -ForegroundColor Yellow + Write-Host " 1. Snapshot pre-install state" + Write-Host " 2. Add-AppxPackage '$MsixPath'" + if (-not [string]::IsNullOrWhiteSpace($CertPath)) { + Write-Host " 2a. Import-Certificate '$CertPath' -CertStoreLocation Cert:\LocalMachine\TrustedPeople" + } + Write-Host " 3. Write probe files to real %APPDATA%\OpenClawTray\ + %LOCALAPPDATA%\OpenClawTray\" + Write-Host " 4. Launch installed tray briefly (max 30 s)" + Write-Host " 5. Kill tray by PID" + Write-Host " 6. Snapshot post-probe state" + Write-Host " 7. Compute diff + verdict" + Write-Host " 8. Remove-AppxPackage" + Write-Host " 9. Snapshot post-uninstall state" + Write-Host " Evidence dir: $EvidenceDir" + Add-Step "whatif-output" "Completed" "WhatIf planned actions printed; no destructive operations performed." + return $EXIT_PASS + } + + return $null # $null means "continue" +} + +# --------------------------------------------------------------------------- +# PHASE 2 — SNAPSHOT PRE-INSTALL STATE +# --------------------------------------------------------------------------- +function Invoke-PreInstallSnapshot { + Write-Host "" + Write-Host "═══ PHASE 2: PRE-INSTALL SNAPSHOT ═══" -ForegroundColor Magenta + Capture-AllSnapshots -Prefix "pre" +} + +# --------------------------------------------------------------------------- +# PHASE 3 — INSTALL +# --------------------------------------------------------------------------- +function Invoke-Install { + Write-Host "" + Write-Host "═══ PHASE 3: INSTALL ═══" -ForegroundColor Magenta + + if ($SkipInstall) { + Add-Step "install-msix" "Skipped" "-SkipInstall set; assuming MSIX already installed." + } + else { + # 3a. Import cert if provided + if (-not [string]::IsNullOrWhiteSpace($CertPath)) { + Write-StepInfo "Importing sideload certificate: $CertPath" + try { + Import-Certificate -FilePath $CertPath -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" | Out-Null + Add-Step "import-certificate" "Completed" "Certificate imported to Cert:\LocalMachine\TrustedPeople." + } + catch { + Add-Step "import-certificate" "Failed" "Certificate import failed: $_" + throw + } + } + + # 3b. Add-AppxPackage + Write-StepInfo "Installing: $MsixPath" + $stdoutFile = Get-EvidencePath "install.stdout.txt" + $stderrFile = Get-EvidencePath "install.stderr.txt" + try { + Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown 2>&1 | Tee-Object -FilePath $stdoutFile + Add-Step "install-msix" "Completed" "Add-AppxPackage succeeded." @{ + msixPath = $MsixPath + stdout = $stdoutFile + } + } + catch { + $_ | Out-File -FilePath $stderrFile -Encoding UTF8 + Add-Step "install-msix" "Failed" "Add-AppxPackage threw: $_" @{ + msixPath = $MsixPath + stderr = $stderrFile + } + throw + } + } + + # 3c. Resolve PackageFamilyName and InstallLocation + $pkg = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + if (-not $pkg) { + throw "Package OpenClaw.Tray not found after install step. Cannot continue." + } + + $script:summary.packageFamilyName = $pkg.PackageFamilyName + $script:summary.packageFullName = $pkg.PackageFullName + $script:summary.installLocation = $pkg.InstallLocation + + $pkgInfo = [ordered]@{ + packageFamilyName = $pkg.PackageFamilyName + packageFullName = $pkg.PackageFullName + installLocation = $pkg.InstallLocation + version = $pkg.Version + architecture = $pkg.Architecture + publisherId = $pkg.PublisherId + capturedAt = (Get-Date).ToString("o") + } + $pkgInfoPath = Get-EvidencePath "package-info.json" + $pkgInfo | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $pkgInfoPath -Encoding UTF8 + + Add-Step "resolve-package-info" "Completed" "Package resolved: $($pkg.PackageFamilyName)" @{ + packageFamilyName = $pkg.PackageFamilyName + installLocation = $pkg.InstallLocation + pkgInfoFile = $pkgInfoPath + } + + return $pkg +} + +# --------------------------------------------------------------------------- +# PHASE 4 — TRIGGER / PROBE +# --------------------------------------------------------------------------- +function Invoke-ProbeSetup { + param([object]$Pkg) + + Write-Host "" + Write-Host "═══ PHASE 4: PROBE SETUP ═══" -ForegroundColor Magenta + + if (-not $effectiveAutoSetup) { + # Manual path: emit instructions and exit 3 + $manualPath = Get-EvidencePath "MANUAL-STEP-REQUIRED.txt" + $instructions = @( + "MANUAL STEP REQUIRED — $(Get-Date -Format 'o')", + "", + "The script was run with -SkipAutoSetup. You must manually walk through the", + "OpenClaw Setup-Locally flow in the tray UI, then re-run the script to capture", + "the post-setup snapshot.", + "", + "STEPS:", + " 1. Launch the installed tray from Start / App list:", + " explorer.exe shell:AppsFolder\$($Pkg.PackageFamilyName)!App", + " 2. Follow the Setup-Locally wizard in the tray UI until it completes.", + " 3. Close the tray app (right-click tray icon → Exit, or Stop-Process -Id ).", + " 4. Re-run this script with -SkipInstall and the SAME -EvidenceDir:", + " .\validate-msix-storage-paths.ps1 -SkipInstall -EvidenceDir '$EvidenceDir'", + "", + "Evidence directory: $EvidenceDir", + "PackageFamilyName: $($Pkg.PackageFamilyName)" + ) + $instructions | Set-Content -LiteralPath $manualPath -Encoding UTF8 + Add-Step "probe-setup" "Skipped" "-SkipAutoSetup: manual UI walk-through required. See MANUAL-STEP-REQUIRED.txt." + Write-Host "" + Write-Host "Manual step required. Instructions written to:" -ForegroundColor Yellow + Write-Host " $manualPath" -ForegroundColor Yellow + return $EXIT_MANUAL_REQUIRED + } + + # -AutoSetup path: + # Write probe marker files to the REAL %APPDATA%\OpenClawTray\ and %LOCALAPPDATA%\OpenClawTray\ + # paths with a unique session ID. Then launch the installed tray briefly. + # The presence of a run.marker or modified settings.json at real paths tells us the app + # uses real APPDATA (Path A). If those paths stay untouched but files appear under + # %LOCALAPPDATA%\Packages\\, that confirms Path B. + + $realAppData = Join-Path $env:APPDATA "OpenClawTray" + $realLocalAppData = Join-Path $env:LOCALAPPDATA "OpenClawTray" + + # Ensure dirs exist so we can write probe files + foreach ($dir in @($realAppData, $realLocalAppData)) { + if (-not (Test-Path -LiteralPath $dir)) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + } + } + + # Write probe markers at real paths (unique per session) + $probeAppData = Join-Path $realAppData $PROBE_MARKER_NAME + $probeLocalAppData = Join-Path $realLocalAppData $PROBE_MARKER_NAME + $probeContent = @{ probeSessionId = $PROBE_SESSION_ID; createdAt = (Get-Date).ToString("o") } | ConvertTo-Json + Set-Content -LiteralPath $probeAppData -Value $probeContent -Encoding UTF8 + Set-Content -LiteralPath $probeLocalAppData -Value $probeContent -Encoding UTF8 + + Add-Step "probe-write-markers" "Completed" "Probe marker files written to real APPDATA paths." @{ + appDataProbe = $probeAppData + localAppDataProbe = $probeLocalAppData + probeSessionId = $PROBE_SESSION_ID + } + + # Launch the MSIX-installed tray using the shell:AppsFolder activation URI + # (the canonical way to launch a packaged app without a Start tile shortcut) + Write-StepInfo "Launching installed tray via shell:AppsFolder..." + $pfn = $Pkg.PackageFamilyName + $appUserModelId = "$pfn!App" + $trayProcess = $null + + try { + Start-Process "explorer.exe" -ArgumentList "shell:AppsFolder\$appUserModelId" + Add-Step "probe-launch-tray" "Completed" "explorer.exe shell:AppsFolder launched." @{ appUserModelId = $appUserModelId } + } + catch { + Add-Step "probe-launch-tray" "Warning" "Launch attempt threw: $_. Proceeding with probe anyway." @{ error = $_.ToString() } + } + + # Wait up to 30 s for a OpenClaw* process to appear + Write-StepInfo "Waiting up to 30 s for tray process to start..." + $deadline = (Get-Date).AddSeconds(30) + $trayProcess = $null + while ((Get-Date) -lt $deadline) { + $found = Get-Process -Name "OpenClaw*" -ErrorAction SilentlyContinue + if ($found) { + $trayProcess = $found | Select-Object -First 1 + break + } + Start-Sleep -Milliseconds 500 + } + + if ($trayProcess) { + Write-StepInfo "Tray process started: PID $($trayProcess.Id) ($($trayProcess.ProcessName)). Waiting 10 s for initialization..." + Start-Sleep -Seconds 10 + Add-Step "probe-tray-running" "Completed" "Tray process detected." @{ + pid = $trayProcess.Id + processName = $trayProcess.ProcessName + } + + # Kill tray by PID (never by name — policy requirement) + try { + Stop-Process -Id $trayProcess.Id -Force -ErrorAction SilentlyContinue + Add-Step "probe-tray-stop" "Completed" "Tray process stopped." @{ pid = $trayProcess.Id } + } + catch { + Add-Step "probe-tray-stop" "Warning" "Stop-Process threw: $_. May have already exited." @{ error = $_.ToString() } + } + Start-Sleep -Seconds 2 # let file handles close + } + else { + Add-Step "probe-tray-running" "Warning" "Tray process did not appear within 30 s. Probe results may be Inconclusive." + } + + # Clean up our own probe markers (they served their purpose; we don't want them + # contaminating the diff as "app-written" files) + foreach ($f in @($probeAppData, $probeLocalAppData)) { + if (Test-Path -LiteralPath $f) { + Remove-Item -LiteralPath $f -Force -ErrorAction SilentlyContinue + } + } + + return $null # continue +} + +# --------------------------------------------------------------------------- +# PHASE 4a — CLI ENGINE UNINSTALL +# Invoke OpenClawTray.exe --uninstall CLI to: +# 1. Drive the engine's own cleanup (WSL distro, settings, etc.) +# 2. Capture engine postconditions for cross_check_consistent in verdict.json +# +# Runs AFTER the probe has triggered file writes so the filesystem diff is +# captured before engine cleanup happens (Phase 5 snapshots the post-probe +# state AFTER this phase so the diff reflects what the tray itself wrote, +# not the engine cleanup). +# +# NOTE: The EXE is taken from the MSIX install location so it is the same +# binary that was probed. If the EXE is not found, the phase is skipped and +# cross_check_consistent will be false. +# --------------------------------------------------------------------------- +function Invoke-CliEngineUninstall { + param([object]$Pkg) + + Write-Host "" + Write-Host "═══ PHASE 4a: CLI ENGINE UNINSTALL ═══" -ForegroundColor Magenta + + # Locate the EXE inside the MSIX install location + $installLocation = if ($Pkg) { $Pkg.InstallLocation } else { $script:summary.installLocation } + $exePath = $null + if (-not [string]::IsNullOrEmpty($installLocation)) { + $candidate = Join-Path $installLocation "OpenClaw.Tray.WinUI.exe" + if (Test-Path -LiteralPath $candidate) { + $exePath = $candidate + } + } + + if ([string]::IsNullOrEmpty($exePath)) { + Add-Step "cli-engine-uninstall" "Skipped" "OpenClaw.Tray.WinUI.exe not found in install location '$installLocation'. cross_check_consistent will be false." @{ + installLocation = $installLocation + } + return [ordered]@{ + invoked = $false + exit_code = $null + success = $false + json_path = $null + postconditions = $null + skip_reason = "EXE not found at install location" + } + } + + Write-StepInfo "Engine EXE: $exePath" + + Ensure-EvidenceDir + $jsonOutputPath = Get-EvidencePath "engine-uninstall-result.json" + $stdoutPath = Get-EvidencePath "cli-engine-uninstall.stdout.txt" + $stderrPath = Get-EvidencePath "cli-engine-uninstall.stderr.txt" + + $cliArgs = @('--uninstall', '--confirm-destructive', '--json-output', $jsonOutputPath) + Write-StepInfo "Invoking: $exePath $($cliArgs -join ' ')" + + $exitCode = $null + try { + & $exePath @cliArgs > $stdoutPath 2> $stderrPath + $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { [int]$global:LASTEXITCODE } + } + catch { + $exitCode = -1 + "Exception: $($_.ToString())" | Set-Content -LiteralPath $stderrPath -Encoding UTF8 + } + + # Parse engine JSON result + $engineJson = $null + $engineSuccess = $false + $enginePostconds = $null + if (Test-Path -LiteralPath $jsonOutputPath) { + try { + $raw = Get-Content -LiteralPath $jsonOutputPath -Raw -Encoding UTF8 + $engineJson = $raw | ConvertFrom-Json + $engineSuccess = [bool]$engineJson.success + # Build an ordered hashtable from the postconditions object + $pc = $engineJson.postconditions + if ($pc) { + $enginePostconds = [ordered]@{ + wsl_distro_absent = [bool]$pc.wsl_distro_absent + autostart_cleared = [bool]$pc.autostart_cleared + setup_state_absent = [bool]$pc.setup_state_absent + device_token_cleared = [bool]$pc.device_token_cleared + mcp_token_preserved = [bool]$pc.mcp_token_preserved + keepalives_absent = [bool]$pc.keepalives_absent + vhd_dir_absent = [bool]$pc.vhd_dir_absent + } + } + } + catch { + Write-StepInfo "Warning: could not parse engine JSON: $_" + } + } + + $stepStatus = if ($exitCode -eq 0) { "Completed" } else { "Warning" } + Add-Step "cli-engine-uninstall" $stepStatus "CLI engine exit code $exitCode. success=$engineSuccess." @{ + exePath = $exePath + exitCode = $exitCode + success = $engineSuccess + jsonPath = $jsonOutputPath + stdout = $stdoutPath + stderr = $stderrPath + } + + $result = [ordered]@{ + invoked = $true + exit_code = $exitCode + success = $engineSuccess + json_path = $jsonOutputPath + postconditions = $enginePostconds + skip_reason = $null + } + + # Store in script-level slot so Invoke-Teardown can reference it + $script:engineResult = $result + + return $result +} + +# --------------------------------------------------------------------------- +# PHASE 5 — POST-INSTALL SNAPSHOT +# --------------------------------------------------------------------------- +function Invoke-PostInstallSnapshot { + Write-Host "" + Write-Host "═══ PHASE 5: POST-INSTALL SNAPSHOT ═══" -ForegroundColor Magenta + Capture-AllSnapshots -Prefix "post" +} + +# --------------------------------------------------------------------------- +# PHASE 6 — DIFF & VERDICT +# --------------------------------------------------------------------------- +function Invoke-Verdict { + param( + [object]$Pkg, + [object]$EngineResult # result from Invoke-CliEngineUninstall; may be $null + ) + + Write-Host "" + Write-Host "═══ PHASE 6: DIFF & VERDICT ═══" -ForegroundColor Magenta + + $preAppData = Get-EvidencePath "pre-appdata.txt" + $preLocalAppData = Get-EvidencePath "pre-localappdata.txt" + $prePackages = Get-EvidencePath "pre-packages.txt" + $postAppData = Get-EvidencePath "post-appdata.txt" + $postLocalAppData = Get-EvidencePath "post-localappdata.txt" + $postPackages = Get-EvidencePath "post-packages.txt" + + $newInAppData = Get-NewPaths -BaselineFile $preAppData -NewFile $postAppData + $newInLocalAppData = Get-NewPaths -BaselineFile $preLocalAppData -NewFile $postLocalAppData + $newInPackages = Get-NewPaths -BaselineFile $prePackages -NewFile $postPackages + + $writesToRealAppData = $newInAppData.Count -gt 0 + $writesToRealLocalAppData = $newInLocalAppData.Count -gt 0 + + # "Virtualized" = package-local storage gained new sub-dirs/files + # We check if the PFN package dir gained a LocalState or LocalCache dir with content + $pfn = if ($Pkg) { $Pkg.PackageFamilyName } else { $script:summary.packageFamilyName } + $pkgDir = Join-Path $env:LOCALAPPDATA "Packages\$pfn" + $pkgLocalState = Join-Path $pkgDir "LocalState" + $pkgRoaming = Join-Path $pkgDir "RoamingState" + + $writesToVirtualized = $false + foreach ($candidate in @($pkgLocalState, $pkgRoaming)) { + if (Test-Path -LiteralPath $candidate) { + $items = Get-ChildItem -LiteralPath $candidate -Recurse -Force -ErrorAction SilentlyContinue + if ($items.Count -gt 0) { + $writesToVirtualized = $true + break + } + } + } + + # Determine verdict + $verdict = "Inconclusive" + $reasoning = "No new files detected in either real APPDATA paths or MSIX package LocalState. Possible causes: tray did not launch, first-run initialization skipped, or probe window too short." + + if ($writesToRealAppData -or $writesToRealLocalAppData) { + $verdict = "PathA-OrphanRisk" + $reasoning = "New files detected in real APPDATA and/or LOCALAPPDATA paths. runFullTrust is bypassing MSIX virtualization. Remove-AppxPackage will NOT clean these files. In-tray cleanup (Remove Local Gateway) and pre-removal warning banner are required." + } + elseif ($writesToVirtualized) { + $verdict = "PathB-CleanRemove" + $reasoning = "No new files in real APPDATA paths. New content detected under %LOCALAPPDATA%\Packages\$pfn\. MSIX filesystem virtualization is active. Remove-AppxPackage will clean file-based artifacts. WSL distro still requires explicit cleanup." + } + elseif ($newInPackages.Count -gt 0 -and -not $writesToRealAppData -and -not $writesToRealLocalAppData) { + $verdict = "PathB-CleanRemove" + $reasoning = "New package directories found under %LOCALAPPDATA%\Packages (filter *OpenClaw*) and no real APPDATA growth detected. Consistent with virtualized storage." + } + + $verdictData = [ordered]@{ + msix_writes_to_real_appdata = $writesToRealAppData + msix_writes_to_real_localappdata = $writesToRealLocalAppData + msix_writes_to_virtualized_storage = $writesToVirtualized + package_family_name = $pfn + install_location = if ($Pkg) { $Pkg.InstallLocation } else { $script:summary.installLocation } + evidence_dir = $EvidenceDir + verdict = $verdict + reasoning = $reasoning + new_real_appdata_files = @($newInAppData) + new_real_localappdata_files = @($newInLocalAppData) + removal_orphans = @() # populated in phase 7 + # ------------------------------------------------------------------ 7A fields + # Engine CLI postconditions from --uninstall --confirm-destructive --json-output. + # cross_check_consistent is updated in Phase 7 (Invoke-Teardown) once orphan + # data is available. Initial value is $false; Teardown sets final value. + engine_cli_invoked = $false + engine_cli_exit_code = $null + engine_postconditions = $null + cross_check_consistent = $false + # ------------------------------------------------------------------ + capturedAt = (Get-Date).ToString("o") + } + + # Embed engine result if we have one (may have been populated by Invoke-CliEngineUninstall) + if ($null -ne $EngineResult) { + $verdictData.engine_cli_invoked = [bool]$EngineResult.invoked + $verdictData.engine_cli_exit_code = $EngineResult.exit_code + $verdictData.engine_postconditions = $EngineResult.postconditions + } elseif ($null -ne $script:engineResult) { + $er = $script:engineResult + $verdictData.engine_cli_invoked = [bool]$er.invoked + $verdictData.engine_cli_exit_code = $er.exit_code + $verdictData.engine_postconditions = $er.postconditions + } + + $verdictPath = Get-EvidencePath "verdict.json" + $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + + $script:summary.verdict = $verdict + + # Color-coded console output + $verdictColor = switch ($verdict) { + "PathA-OrphanRisk" { "Red" } + "PathB-CleanRemove" { "Green" } + default { "Yellow" } + } + Write-Host "" + Write-Host "┌─────────────────────────────────────────────────────────────┐" -ForegroundColor $verdictColor + Write-Host "│ VERDICT: $verdict" -ForegroundColor $verdictColor + Write-Host "│ $reasoning" -ForegroundColor $verdictColor + Write-Host "│ Evidence: $verdictPath" -ForegroundColor $verdictColor + Write-Host "└─────────────────────────────────────────────────────────────┘" -ForegroundColor $verdictColor + Write-Host "" + + Add-Step "verdict" $( if ($verdict -eq "Inconclusive") { "Warning" } else { "Completed" } ) ` + "Verdict: $verdict" @{ + verdict = $verdict + writesToRealAppData = $writesToRealAppData + writesToRealLocal = $writesToRealLocalAppData + writesToVirtualized = $writesToVirtualized + newRealAppDataCount = $newInAppData.Count + newRealLocalCount = $newInLocalAppData.Count + verdictFile = $verdictPath + } + + return $verdictData +} + +# --------------------------------------------------------------------------- +# PHASE 7 — TEARDOWN +# --------------------------------------------------------------------------- +function Invoke-Teardown { + param( + [object]$Pkg, + [object]$VerdictData + ) + + Write-Host "" + Write-Host "═══ PHASE 7: TEARDOWN ═══" -ForegroundColor Magenta + + if (-not $Pkg) { + # Attempt to resolve by name in case we used -SkipInstall + $Pkg = Get-AppxPackage -Name "OpenClaw.Tray" -ErrorAction SilentlyContinue + } + + if (-not $Pkg) { + Add-Step "teardown-remove-appx" "Skipped" "No OpenClaw.Tray package found to remove." + } + else { + $pkgFullName = $Pkg.PackageFullName + Write-StepInfo "Removing package: $pkgFullName" + $removeStdout = Get-EvidencePath "remove-appx.stdout.txt" + $removeStderr = Get-EvidencePath "remove-appx.stderr.txt" + try { + Remove-AppxPackage -Package $pkgFullName 2>&1 | Tee-Object -FilePath $removeStdout + Add-Step "teardown-remove-appx" "Completed" "Remove-AppxPackage succeeded." @{ + packageFullName = $pkgFullName + stdout = $removeStdout + } + } + catch { + $_ | Out-File -FilePath $removeStderr -Encoding UTF8 + Add-Step "teardown-remove-appx" "Warning" "Remove-AppxPackage threw: $_. Proceeding with post-uninstall snapshot." @{ + error = $_.ToString() + stderr = $removeStderr + } + } + } + + # Post-uninstall snapshot + Capture-AllSnapshots -Prefix "post-uninstall" + + # Compute orphans: files that still exist after removal that existed at post-install time + $postAppData = Get-EvidencePath "post-appdata.txt" + $postLocalData = Get-EvidencePath "post-localappdata.txt" + $postUnAppData = Get-EvidencePath "post-uninstall-appdata.txt" + $postUnLocalData = Get-EvidencePath "post-uninstall-localappdata.txt" + + $orphansAppData = Get-NewPaths -BaselineFile $postUnAppData -NewFile $postAppData + $orphansLocal = Get-NewPaths -BaselineFile $postUnLocalData -NewFile $postLocalData + + # Items that survived removal (present in post-uninstall = NOT new relative to post = survivors) + # Re-interpret: survivors = items in post-uninstall that were also in post (gained during run) + # Simpler: items in post that are ALSO in post-uninstall = not cleaned up + function Get-SurvivedPaths { + param([string]$PostFile, [string]$PostUninstallFile) + if (-not (Test-Path -LiteralPath $PostFile)) { return @() } + if (-not (Test-Path -LiteralPath $PostUninstallFile)) { return @() } + $postItems = Get-Content -LiteralPath $PostFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $postUnItems = Get-Content -LiteralPath $PostUninstallFile | Where-Object { $_ -match '^\d{4}' } | ForEach-Object { ($_ -split '\s+', 5)[-1] } + $uninstallSet = [System.Collections.Generic.HashSet[string]]::new($postUnItems, [System.StringComparer]::OrdinalIgnoreCase) + return @($postItems | Where-Object { $uninstallSet.Contains($_) }) + } + + $survivedAppData = Get-SurvivedPaths -PostFile $postAppData -PostUninstallFile $postUnAppData + $survivedLocal = Get-SurvivedPaths -PostFile $postLocalData -PostUninstallFile $postUnLocalData + $allSurvivors = @($survivedAppData) + @($survivedLocal) + + # ----------------------------------------------------------------------- + # cross_check_consistent (7A requirement): + # "Do the engine postconditions match the empirical filesystem diff?" + # + # For PathA-OrphanRisk: consistent = engine was invoked AND succeeded + # AND engine.postconditions.wsl_distro_absent == true (engine cleaned or + # confirmed WSL distro was never registered) WHILE files in real APPDATA + # survived Remove-AppxPackage (confirming MSIX does not auto-clean them). + # This is the definitive PathA evidence package. + # + # For PathB-CleanRemove: consistent = engine was invoked AND succeeded + # AND no orphan files survived Remove-AppxPackage. + # + # Inconclusive: always false. + # ----------------------------------------------------------------------- + $crossCheckConsistent = $false + $er = $script:engineResult + if ($VerdictData -and $null -ne $er -and [bool]$er.invoked -and $er.exit_code -eq 0) { + $enginePc = $er.postconditions + $engineWslAbsent = if ($enginePc -and $null -ne $enginePc.wsl_distro_absent) { [bool]$enginePc.wsl_distro_absent } else { $false } + $currentVerdict = $VerdictData.verdict + + if ($currentVerdict -eq "PathA-OrphanRisk") { + # PathA: real-APPDATA writes confirmed AND engine cleaned WSL (or no distro was ever + # registered). Orphans surviving MSIX removal confirm the orphan-risk claim. + $crossCheckConsistent = $engineWslAbsent + } + elseif ($currentVerdict -eq "PathB-CleanRemove") { + # PathB: no orphan files after MSIX removal AND engine completed successfully. + $crossCheckConsistent = ($engineWslAbsent -and ($allSurvivors.Count -eq 0)) + } + # Inconclusive → stays false + } + + # Update verdict.json with removal_orphans AND cross_check_consistent + if ($VerdictData) { + $VerdictData.removal_orphans = $allSurvivors + $VerdictData.cross_check_consistent = $crossCheckConsistent + $verdictPath = Get-EvidencePath "verdict.json" + $VerdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + } + + if ($allSurvivors.Count -gt 0) { + Add-Step "teardown-orphan-check" "Warning" "$($allSurvivors.Count) file(s) survived Remove-AppxPackage (orphans)." @{ + orphans = $allSurvivors + } + Write-Host "⚠ $($allSurvivors.Count) orphan file(s) found after MSIX removal:" -ForegroundColor Yellow + foreach ($o in $allSurvivors) { Write-Host " $o" -ForegroundColor Yellow } + } + else { + Add-Step "teardown-orphan-check" "Completed" "No orphan files detected after Remove-AppxPackage." + } +} + +# --------------------------------------------------------------------------- +# PASS/FAIL evaluation +# --------------------------------------------------------------------------- +function Get-FinalExitCode { + param([object]$VerdictData) + + $hasAllEvidence = $true + foreach ($required in @( + "pre-appdata.txt", "pre-localappdata.txt", "pre-packages.txt", "pre-appx.json", + "post-appdata.txt", "post-localappdata.txt", "post-packages.txt", "post-appx.json", + "post-uninstall-appdata.txt", "post-uninstall-localappdata.txt", "post-uninstall-packages.txt", + "verdict.json", "package-info.json" + )) { + $p = Get-EvidencePath $required + if (-not (Test-Path -LiteralPath $p)) { + Write-Host " MISSING evidence file: $p" -ForegroundColor Red + $hasAllEvidence = $false + } + } + + if (-not $hasAllEvidence) { return $EXIT_FAIL } + + $verdict = if ($VerdictData) { $VerdictData.verdict } else { $script:summary.verdict } + if ($verdict -eq "Inconclusive") { return $EXIT_FAIL } + if ([string]::IsNullOrWhiteSpace($verdict)) { return $EXIT_FAIL } + + return $EXIT_PASS +} + +# --------------------------------------------------------------------------- +# MAIN +# --------------------------------------------------------------------------- +Ensure-EvidenceDir +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ validate-msix-storage-paths.ps1 v$SCRIPT_VERSION ($SCRIPT_DATE) ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host " Evidence dir : $EvidenceDir" +Write-Host " AutoSetup : $effectiveAutoSetup" +Write-Host " SkipInstall : $($SkipInstall.IsPresent)" +if ($MsixPath) { Write-Host " MsixPath : $MsixPath" } +Write-Host "" + +$exitCode = $EXIT_FAIL +$pkg = $null +$verdictData = $null + +try { + # Phase 1: Preflight + $preflightResult = Invoke-Preflight + if ($null -ne $preflightResult) { + # Non-null means a terminal early result (WhatIf, preflight block, etc.) + $exitCode = $preflightResult + } + else { + # Phase 2: Pre-install snapshot + Invoke-PreInstallSnapshot + + # Phase 3: Install + $pkg = Invoke-Install + + # Phase 4: Probe setup + $probeResult = Invoke-ProbeSetup -Pkg $pkg + if ($null -ne $probeResult) { + $exitCode = $probeResult # manual step required (exit 3) + } + else { + # Phase 5: Post-install snapshot + Invoke-PostInstallSnapshot + + # Phase 4a: CLI Engine Uninstall — invoke Aaron's --uninstall flag to: + # 1. Drive the engine's own gateway cleanup (WSL distro, settings, etc.) + # 2. Capture engine postconditions for cross_check_consistent in verdict.json + # Must run AFTER the probe snapshot so the post-probe state reflects what the + # tray itself wrote (not engine cleanup). + $engineResult = Invoke-CliEngineUninstall -Pkg $pkg + + # Phase 6: Verdict (includes engine data in verdict.json) + $verdictData = Invoke-Verdict -Pkg $pkg -EngineResult $engineResult + + # Phase 7: Teardown (Remove-AppxPackage, orphan check, final cross_check update) + Invoke-Teardown -Pkg $pkg -VerdictData $verdictData + + # Evaluate final exit code + $exitCode = Get-FinalExitCode -VerdictData $verdictData + } + } + + $script:summary.status = if ($exitCode -eq $EXIT_PASS) { "Passed" } elseif ($exitCode -eq $EXIT_MANUAL_REQUIRED) { "ManualStepRequired" } elseif ($exitCode -eq $EXIT_PREFLIGHT_BLOCK) { "PreflightBlocked" } else { "Failed" } +} +catch { + $script:summary.status = "Error" + $script:summary.error = $_.ToString() + Add-Step "unhandled-error" "Failed" $_.ToString() + Write-Fatal "Unhandled error: $_" + $exitCode = $EXIT_FAIL +} +finally { + Write-Summary + Write-Host "" + Write-Host "Summary written to: $(Get-EvidencePath 'summary.json')" -ForegroundColor Cyan + Write-Host "Evidence directory: $EvidenceDir" -ForegroundColor Cyan + $verdictDisplay = if ($verdictData) { $verdictData.verdict } elseif ($script:summary.verdict) { $script:summary.verdict } else { "N/A" } + Write-Host "Verdict : $verdictDisplay" -ForegroundColor $( + switch ($verdictDisplay) { + "PathA-OrphanRisk" { "Red" } + "PathB-CleanRemove" { "Green" } + default { "Yellow" } + } + ) + Write-Host "Exit code : $exitCode" -ForegroundColor $(if ($exitCode -eq 0) { "Green" } else { "Yellow" }) + Write-Host "" +} + +exit $exitCode diff --git a/scripts/validate-wsl-gateway-uninstall.ps1 b/scripts/validate-wsl-gateway-uninstall.ps1 new file mode 100644 index 000000000..2eaab1c89 --- /dev/null +++ b/scripts/validate-wsl-gateway-uninstall.ps1 @@ -0,0 +1,1095 @@ +<# +.SYNOPSIS + Empirical verification of LocalGatewayUninstall postconditions. + Mirror of validate-wsl-gateway.ps1 for the inverse (uninstall) direction. + +.DESCRIPTION + PURPOSE + ------- + This script duplicates the LocalGatewayUninstall step sequence in PowerShell + so Bostick can validate end-to-end without invoking the tray. The C# engine + remains the production code path; this script MUST stay aligned with it. + + Source of truth for steps: + src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs + + Shared helpers dot-sourced from: + scripts/_uninstall-helpers.ps1 + (Test-IsOpenClawOwnedDistroName, Invoke-WslCommand, Stop-OpenClawProcessByPid, + Assert-DryRunGate, Add-Step) + + MODES + ----- + PreflightOnly + Verifies the distro is registered, validates the distro-name guard, and + snapshots pre-state to pre-state.json. Non-destructive. If the distro + is absent, exits 0 immediately with verdict "AlreadyClean". + + Full + PreflightOnly snapshot -> execute 13-step uninstall sequence in + PowerShell -> post-state snapshot -> writes verdict.json. + Requires -ConfirmDestructive unless -DryRun is also set. + + PostconditionOnly + Skips snapshot and destruction. Evaluates current filesystem / + registry / WSL state against the uninstall-completed expectations. + Use after an in-tray uninstall or a manual cleanup. + + OUTPUT LAYOUT + ------------- + / + pre-state.json state snapshot before uninstall + post-state.json state snapshot after uninstall (Full only) + steps.json ordered step audit trail (Full only) + verdict.json final pass/fail verdict + postconditions + summary.md human-readable summary + + EXIT CODES + ---------- + 0 PASS All postconditions match expected uninstall-complete state. + 1 FAIL One or more postconditions do not match. + 2 BLOCKED Preflight blocked (wrong distro name, -ConfirmDestructive missing). + 3 ERROR Full mode failed mid-execution (unhandled exception). + +.PARAMETER Mode + Required. One of: PreflightOnly | Full | PostconditionOnly. + +.PARAMETER ConfirmDestructive + Required for Full mode unless -DryRun is also set. Safety gate. + +.PARAMETER DistroName + WSL distro name to target. Default: OpenClawGateway. + Must be exactly "OpenClawGateway" (no prefix variants). + +.PARAMETER PreserveLogs + When $true (default), gateway logs are not deleted in Full mode. + Mirrors LocalGatewayUninstallOptions.PreserveLogs. + +.PARAMETER PreserveExecPolicy + When $true (default), exec-policy.json is not deleted in Full mode. + Mirrors LocalGatewayUninstallOptions.PreserveExecPolicy. + +.PARAMETER OutputDir + Directory for output artifacts. + Default: .\uninstall-validation-output\\ + +.PARAMETER DryRun + When set with Full mode, executes step logic with no destructive mutations. + Each step records status "DryRun". Verdict is "DryRunComplete". + +.PARAMETER Help + Prints usage and exits 0. + +.EXAMPLE + # Non-destructive preflight + pre-state snapshot: + .\validate-wsl-gateway-uninstall.ps1 -Mode PreflightOnly + +.EXAMPLE + # Dry-run: records every step without destroying anything: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -DryRun + +.EXAMPLE + # Live full uninstall + postcondition verification: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive + +.EXAMPLE + # Verify postconditions after in-tray uninstall: + .\validate-wsl-gateway-uninstall.ps1 -Mode PostconditionOnly + +.EXAMPLE + # Full uninstall preserving logs but deleting exec-policy: + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -PreserveExecPolicy $false + +.NOTES + Date: 2026-05-07 + Author: Aaron (Backend / Infrastructure Engineer) + Branch: feat/wsl-gateway-uninstall + + File I/O against WSL is via `wsl bash -c` only. + NEVER use \\wsl$ / \\wsl.localhost paths. + Token / key material is redacted in all output artifacts. +#> + +[CmdletBinding()] +param( + # Mode is effectively required — validated manually so that -Help works without it. + [string]$Mode = "", + + [switch]$ConfirmDestructive, + + [string]$DistroName = "OpenClawGateway", + + [bool]$PreserveLogs = $true, + + [bool]$PreserveExecPolicy = $true, + + [string]$OutputDir = "", + + [switch]$DryRun, + + # When set, -Mode Full skips the CLI delegate and executes the inline + # PowerShell step replication instead. Use for diagnostics or when + # OpenClawTray.exe is not available (e.g. standalone script on bare machine). + [switch]$NoCli, + + # Path to OpenClawTray.exe. Auto-detected from common locations when empty. + [string]$ExePath = "", + + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. "$PSScriptRoot\_uninstall-helpers.ps1" + +# Step audit trail required by Add-Step (from _uninstall-helpers.ps1). +$script:steps = @() + +# --------------------------------------------------------------------------- +# Help +# --------------------------------------------------------------------------- + +if ($Help -or [string]::IsNullOrEmpty($Mode)) { + Write-Host @' +validate-wsl-gateway-uninstall.ps1 — OpenClaw WSL gateway uninstall validator + +USAGE: + .\validate-wsl-gateway-uninstall.ps1 -Mode [options] + +MODES (required): + PreflightOnly Snapshot pre-state only. Non-destructive. + Full Snapshot -> execute 13-step uninstall -> verdict. + PostconditionOnly Verify current state against uninstall-complete expectations. + +OPTIONS: + -ConfirmDestructive Required for Full mode (unless -DryRun is also set). + -DistroName Default: OpenClawGateway (must be exact match) + -PreserveLogs Default: $true (do not delete gateway logs) + -PreserveExecPolicy Default: $true (do not delete exec-policy.json) + -OutputDir Default: .\uninstall-validation-output\\ + -DryRun Full mode records steps without any destruction. + -NoCli Full mode: skip CLI delegate; use inline PS replication. + Use for diagnostics or when the EXE is unavailable. + -ExePath Explicit path to OpenClawTray.exe (auto-detected when empty). + -Help Show this help. + +EXIT CODES: + 0 PASS All postconditions match uninstall-complete state. + 1 FAIL One or more postconditions do not match. + 2 BLOCKED Preflight blocked (wrong distro name / missing -ConfirmDestructive). + 3 ERROR Full mode failed mid-execution. + +EXAMPLES: + # Non-destructive preflight + pre-state snapshot: + .\validate-wsl-gateway-uninstall.ps1 -Mode PreflightOnly + + # Dry-run (records steps, no destruction): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -DryRun + + # Live full uninstall + verification (via CLI delegate — default): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive + + # Live full uninstall via inline PS replication (diagnostic fallback): + .\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -NoCli + + # Verify state after in-tray uninstall: + .\validate-wsl-gateway-uninstall.ps1 -Mode PostconditionOnly +'@ + exit 0 +} + +# --------------------------------------------------------------------------- +# Parameter validation +# --------------------------------------------------------------------------- + +$validModes = @('PreflightOnly', 'Full', 'PostconditionOnly') +if ($Mode -notin $validModes) { + Write-Host "ERROR: -Mode must be one of: $($validModes -join ', '). Got: '$Mode'" -ForegroundColor Red + exit 2 +} + +if (-not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { + Write-Host "ERROR: Refusing to operate on distro '$DistroName': name must be exactly 'OpenClawGateway'. Pass -DistroName OpenClawGateway." -ForegroundColor Red + exit 2 +} + +if ($Mode -eq 'Full' -and (-not $DryRun) -and (-not $ConfirmDestructive)) { + Write-Host "ERROR: -ConfirmDestructive is required for -Mode Full when -DryRun is not set. This prevents accidental destructive operations." -ForegroundColor Red + exit 2 +} + +# --------------------------------------------------------------------------- +# Path constants +# --------------------------------------------------------------------------- + +$appData = $env:APPDATA +$localAppData = $env:LOCALAPPDATA + +$setupStatePath = Join-Path $localAppData "OpenClawTray\setup-state.json" +$deviceKeyPath = Join-Path $appData "OpenClawTray\device-key-ed25519.json" +$mcpTokenPath = Join-Path $appData "OpenClawTray\mcp-token.txt" +$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" + +$autoStartRegKey = "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" +$autoStartAppName = "OpenClawTray" + +# --------------------------------------------------------------------------- +# Output directory +# --------------------------------------------------------------------------- + +$utcStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssZ") +if ([string]::IsNullOrEmpty($OutputDir)) { + $OutputDir = Join-Path (Get-Location) "uninstall-validation-output\$utcStamp" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$preStatePath = Join-Path $OutputDir "pre-state.json" +$postStatePath = Join-Path $OutputDir "post-state.json" +$stepsPath = Join-Path $OutputDir "steps.json" +$verdictPath = Join-Path $OutputDir "verdict.json" +$summaryMdPath = Join-Path $OutputDir "summary.md" + +# --------------------------------------------------------------------------- +# Token redaction +# --------------------------------------------------------------------------- + +function Invoke-Redact { + param([string]$Content) + if ([string]::IsNullOrEmpty($Content)) { return $Content } + # Redact JSON fields containing key/token material. + $r = $Content -replace '("(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)"\s*:\s*")[^"]+(")', '$1$2' + # Redact bare key=value / key: value patterns. + $r = $r -replace '(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,"''}{]+', '$1' + return $r +} + +# --------------------------------------------------------------------------- +# WSL helpers +# --------------------------------------------------------------------------- + +function Get-WslDistroLines { + try { + $raw = & wsl --list --quiet 2>&1 + return ($raw | Out-String) -split "`r?`n" | + ForEach-Object { ($_ -replace '\x00', '').Trim() } | + Where-Object { $_ } + } catch { + return @() + } +} + +function Test-DistroRegistered { + param([string]$Name) + $lines = Get-WslDistroLines + return (@($lines | Where-Object { $_ -eq $Name })).Count -gt 0 +} + +# --------------------------------------------------------------------------- +# Process helpers +# --------------------------------------------------------------------------- + +function Get-WslKeepalivePids { + param([string]$Name) + $found = [System.Collections.Generic.List[int]]::new() + try { + $wslProcs = Get-CimInstance -ClassName Win32_Process -Filter "Name LIKE 'wsl%'" -ErrorAction SilentlyContinue + foreach ($proc in $wslProcs) { + $cmd = $proc.CommandLine + if ($cmd -and + $cmd -match [regex]::Escape($Name) -and + $cmd -match 'sleep\s+2147483647') { + $found.Add([int]$proc.ProcessId) + } + } + } catch { } + return $found.ToArray() +} + +function Get-OpenClawProcessSnapshot { + try { + $procs = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'OpenClaw*' } + return @($procs | ForEach-Object { + [ordered]@{ + pid = $_.Id + name = $_.Name + path = try { $_.MainModule.FileName } catch { '' } + } + }) + } catch { return @() } +} + +# --------------------------------------------------------------------------- +# Registry helpers +# --------------------------------------------------------------------------- + +function Test-AutoStartRegistryPresent { + try { + $val = Get-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -ErrorAction SilentlyContinue + return ($null -ne $val -and $null -ne $val.$autoStartAppName) + } catch { return $false } +} + +function Remove-AutoStartRegistryValue { + try { + $existing = Get-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -ErrorAction SilentlyContinue + if ($null -ne $existing) { + Remove-ItemProperty -LiteralPath $autoStartRegKey -Name $autoStartAppName -Force -ErrorAction Stop + return $true + } + return $false + } catch { + return $false + } +} + +# --------------------------------------------------------------------------- +# State snapshot +# --------------------------------------------------------------------------- + +function Get-StateSnapshot { + param([string]$Label) + + $allDistros = Get-WslDistroLines + $openClawDistros = @($allDistros | Where-Object { $_ -like '*OpenClaw*' }) + $registered = Test-DistroRegistered -Name $DistroName + + $snapshot = [ordered]@{ + captured_at = (Get-Date).ToString("o") + label = $Label + distro_name = $DistroName + distro_registered = $registered + wsl_distros_openclaw = $openClawDistros + autostart_registry = Test-AutoStartRegistryPresent + settings_autostart = $null + device_token_is_null = $null + files = [ordered]@{ + setup_state_exists = (Test-Path -LiteralPath $setupStatePath) + device_key_exists = (Test-Path -LiteralPath $deviceKeyPath) + mcp_token_exists = (Test-Path -LiteralPath $mcpTokenPath) + settings_exists = (Test-Path -LiteralPath $settingsPath) + exec_policy_exists = (Test-Path -LiteralPath $execPolicyPath) + vhd_dir_exists = (Test-Path -LiteralPath $vhdDirPath) + } + processes_openclaw = @() + } + + # settings.AutoStart — read-only (no token content exposed) + if (Test-Path -LiteralPath $settingsPath) { + try { + $j = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + $snapshot.settings_autostart = if ($j.PSObject.Properties['AutoStart']) { [bool]$j.AutoStart } else { $null } + } catch { $snapshot.settings_autostart = '' } + } + + # DeviceToken null check — only reports bool, never exposes value + if (Test-Path -LiteralPath $deviceKeyPath) { + try { + $j = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 | ConvertFrom-Json + $dtVal = if ($j.PSObject.Properties['DeviceToken']) { $j.DeviceToken } else { $null } + $snapshot.device_token_is_null = [string]::IsNullOrEmpty($dtVal) + } catch { $snapshot.device_token_is_null = '' } + } else { + $snapshot.device_token_is_null = '' + } + + $snapshot.processes_openclaw = Get-OpenClawProcessSnapshot + + return $snapshot +} + +function Get-TrayExePath { + param([string]$Hint) + + # 1. Explicit hint from caller + if ($Hint -and (Test-Path -LiteralPath $Hint)) { return $Hint } + + # 2. Same directory as this script (Inno install layout: script is in {app}) + $candidate = Join-Path $PSScriptRoot 'OpenClaw.Tray.WinUI.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + + # 3. Parent directory (repo layout: scripts\ sibling of publish\) + $candidate = Join-Path (Split-Path $PSScriptRoot -Parent) 'publish\OpenClaw.Tray.WinUI.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + + return $null +} + +# --------------------------------------------------------------------------- +# Postcondition evaluation +# --------------------------------------------------------------------------- + +function Get-Postconditions { + # WSL distro absent? + $wslDistroAbsent = -not (Test-DistroRegistered -Name $DistroName) + + # Autostart cleared: registry absent AND settings.AutoStart == false + $regAbsent = -not (Test-AutoStartRegistryPresent) + $autoStartFalse = $false + if (Test-Path -LiteralPath $settingsPath) { + try { + $j = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + $autoStartFalse = if ($j.PSObject.Properties['AutoStart']) { -not [bool]$j.AutoStart } else { $true } + } catch { $autoStartFalse = $false } + } else { + # File absent means no auto-start configuration exists; treat as cleared. + $autoStartFalse = $true + } + $autostartCleared = $regAbsent -and $autoStartFalse + + # setup-state.json absent? + $setupStateAbsent = -not (Test-Path -LiteralPath $setupStatePath) + + # DeviceToken cleared: file absent OR DeviceToken null/empty + $deviceTokenCleared = $false + if (-not (Test-Path -LiteralPath $deviceKeyPath)) { + $deviceTokenCleared = $true + } else { + try { + $j = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 | ConvertFrom-Json + $dtVal = if ($j.PSObject.Properties['DeviceToken']) { $j.DeviceToken } else { $null } + $deviceTokenCleared = [string]::IsNullOrEmpty($dtVal) + } catch { $deviceTokenCleared = $false } + } + + # device-key-ed25519.json preserved (file itself must NOT have been deleted per v3 §C). + # If the machine never ran setup the file may legitimately be absent; + # in that case we report absent but do not fail (it can't be "preserved" if it never existed). + $deviceKeyFilePreserved = Test-Path -LiteralPath $deviceKeyPath + + # mcp-token.txt preserved (never touched — any state is correct by definition per v3 §F). + $mcpTokenPreserved = $true + + # No OpenClaw keepalive processes running. + $keepalivePids = @(Get-WslKeepalivePids -Name $DistroName) + $keepalivesAbsent = ($keepalivePids.Count -eq 0) + + return [ordered]@{ + wsl_distro_absent = $wslDistroAbsent + autostart_cleared = $autostartCleared + setup_state_absent = $setupStateAbsent + device_token_cleared = $deviceTokenCleared + device_key_file_preserved = $deviceKeyFilePreserved + mcp_token_preserved = $mcpTokenPreserved + keepalives_absent = $keepalivesAbsent + vhd_dir_absent = (-not (Test-Path -LiteralPath $vhdDirPath)) + } +} + +function Get-Verdict { + param( + [System.Collections.Specialized.OrderedDictionary]$Postconditions, + [string[]]$Errors + ) + + # 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') + $failedKeys = @($required | Where-Object { $Postconditions[$_] -ne $true }) + $errCount = if ($null -eq $Errors) { 0 } else { @($Errors).Count } + + if ($failedKeys.Count -eq 0 -and $errCount -eq 0) { return 'PASS' } + if ($failedKeys.Count -eq $required.Count) { return 'FAIL' } + return 'PARTIAL' +} + +# --------------------------------------------------------------------------- +# 13-step uninstall execution (PowerShell mirror of LocalGatewayUninstall.cs) +# --------------------------------------------------------------------------- +# IMPORTANT: This sequence MUST stay aligned with: +# src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs +# The C# engine remains the production code path. +# Any divergence found here should be filed as a commit-5 integration gap. +# --------------------------------------------------------------------------- + +function Invoke-UninstallSteps { + param([bool]$IsDryRun) + + $stepErrors = [System.Collections.Generic.List[string]]::new() + + # ------------------------------------------------------------------ Step 1 + # Preflight gate + # ------------------------------------------------------------------ Step 1 + if ($IsDryRun) { + Add-Step -Name 'preflight-gate' -Status 'DryRun' ` + -Message 'DryRun=true; no destructive changes will be made.' + } else { + Add-Step -Name 'preflight-gate' -Status 'Executed' ` + -Message 'ConfirmDestructive=true; proceeding with live uninstall.' + } + + # ------------------------------------------------------------------ Step 2 + # Stop WSL keepalive process + # ------------------------------------------------------------------ Step 2 + try { + $kPids = @(Get-WslKeepalivePids -Name $DistroName) + if ($kPids.Count -eq 0) { + Add-Step -Name 'stop-keepalive-process' -Status 'Skipped' ` + -Message "No WSL keepalive process found for '$DistroName'." + } elseif ($IsDryRun) { + Add-Step -Name 'stop-keepalive-process' -Status 'DryRun' ` + -Message "Would stop $($kPids.Count) keepalive PID(s): $($kPids -join ', ')." + } else { + $stopped = 0 + foreach ($pid in $kPids) { + try { + Stop-OpenClawProcessByPid -ProcessId $pid -Force + $stopped++ + } catch { + $stepErrors.Add("stop-keepalive-process PID $($pid): $($_.Exception.Message)") + } + } + Add-Step -Name 'stop-keepalive-process' -Status 'Executed' ` + -Message "Stopped $stopped / $($kPids.Count) keepalive process(es)." + } + } catch { + $stepErrors.Add("stop-keepalive-process: $($_.Exception.Message)") + Add-Step -Name 'stop-keepalive-process' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 3 + # Stop systemd gateway service inside WSL + # ------------------------------------------------------------------ Step 3 + try { + if (-not (Test-DistroRegistered -Name $DistroName)) { + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Skipped' ` + -Message "Distro '$DistroName' not registered." + } elseif ($IsDryRun) { + Add-Step -Name 'stop-systemd-gateway-service' -Status 'DryRun' ` + -Message "Would run: wsl -d $DistroName bash -c 'sudo systemctl stop openclaw-gateway 2>&1 || true'" + } else { + $r = Invoke-WslCommand -Command 'sudo systemctl stop openclaw-gateway 2>&1 || true' -DistroName $DistroName + $detail = ($r.Stdout + ' ' + $r.Stderr).Trim() + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Executed' ` + -Message (if ($detail) { $detail } else { 'Service stop command issued.' }) + } + } catch { + $stepErrors.Add("stop-systemd-gateway-service: $($_.Exception.Message)") + Add-Step -Name 'stop-systemd-gateway-service' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 4 + # Revoke operator token — best-effort HTTP call; gateway may already be down. + # ------------------------------------------------------------------ Step 4 + try { + $hasToken = $false + $gwUrl = 'ws://localhost:18789' + if (Test-Path -LiteralPath $settingsPath) { + $settingsJson = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($settingsJson.PSObject.Properties['Token']) { + $hasToken = -not [string]::IsNullOrWhiteSpace($settingsJson.Token) + } + if ($settingsJson.PSObject.Properties['GatewayUrl']) { + $gwUrl = $settingsJson.GatewayUrl + } + } + + if (-not $hasToken) { + Add-Step -Name 'revoke-operator-token' -Status 'Skipped' ` + -Message 'No operator token in settings.json.' + } elseif ($IsDryRun) { + Add-Step -Name 'revoke-operator-token' -Status 'DryRun' ` + -Message 'Would POST to /api/v1/operator/disconnect. Token: ***REDACTED***' + } else { + $httpBase = ($gwUrl -replace '^ws://', 'http://' -replace '^wss://', 'https://').TrimEnd('/') + $token = $settingsJson.Token + try { + $resp = Invoke-WebRequest -Uri "$httpBase/api/v1/operator/disconnect" -Method Post ` + -Headers @{ Authorization = "Bearer $token" } ` + -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop + Add-Step -Name 'revoke-operator-token' -Status 'Executed' ` + -Message "Response: $([int]$resp.StatusCode). Token: ***REDACTED***" + } catch { + # Gateway likely already down — absorb and continue. + Add-Step -Name 'revoke-operator-token' -Status 'Executed' ` + -Message "Best-effort revoke failed ($($_.Exception.GetType().Name)); gateway likely down. Token: ***REDACTED***" + } + } + } catch { + $stepErrors.Add("revoke-operator-token: $($_.Exception.Message)") + Add-Step -Name 'revoke-operator-token' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 5 + # Unregister WSL distro. + # Safety guard: only "OpenClawGateway" may be unregistered (mirrors C# AllowedDistroName). + # wsl --terminate first to cleanly stop the distro before unregistering. + # ------------------------------------------------------------------ Step 5 + try { + if ($DistroName -ne 'OpenClawGateway') { + $guard = "Refused to unregister '$DistroName': only 'OpenClawGateway' is allowed." + $stepErrors.Add($guard) + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $guard + } elseif (-not (Test-DistroRegistered -Name $DistroName)) { + Add-Step -Name 'unregister-wsl-distro' -Status 'Skipped' ` + -Message "Distro '$DistroName' not registered." + } elseif ($IsDryRun) { + Add-Step -Name 'unregister-wsl-distro' -Status 'DryRun' ` + -Message "Would run: wsl --terminate $DistroName && wsl --unregister $DistroName" + } else { + & wsl --terminate $DistroName 2>&1 | Out-Null + & wsl --unregister $DistroName 2>&1 | Out-Null + $ec = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + if ($ec -eq 0 -or $ec -eq 1) { + Add-Step -Name 'unregister-wsl-distro' -Status 'Executed' ` + -Message "wsl --unregister completed (exit $ec)." + } else { + $msg = "wsl --unregister exited $ec." + $stepErrors.Add($msg) + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $msg + } + } + } catch { + $stepErrors.Add("unregister-wsl-distro: $($_.Exception.Message)") + Add-Step -Name 'unregister-wsl-distro' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 6 + # Reset autostart. + # CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting the registry value. + # ------------------------------------------------------------------ Step 6 + try { + if ($IsDryRun) { + Add-Step -Name 'reset-autostart' -Status 'DryRun' ` + -Message "Would set settings.AutoStart=false and save, then delete HKCU\...\Run\$autoStartAppName." + } else { + # 6a — Update settings.json (AutoStart=false) FIRST. + if (Test-Path -LiteralPath $settingsPath) { + $sRaw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + $sObj = $sRaw | ConvertFrom-Json + $sObj.AutoStart = $false + $sObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 + Add-Step -Name 'persist-settings-autostart-false' -Status 'Executed' ` + -Message 'AutoStart=false persisted to settings.json.' + } else { + Add-Step -Name 'persist-settings-autostart-false' -Status 'Skipped' ` + -Message 'settings.json not found; no AutoStart field to update.' + } + + # 6b — Delete registry Run value SECOND (idempotent per v3 §B). + $removed = Remove-AutoStartRegistryValue + if ($removed) { + Add-Step -Name 'delete-autostart-registry' -Status 'Executed' ` + -Message "Deleted HKCU\...\Run\$autoStartAppName." + } else { + Add-Step -Name 'delete-autostart-registry' -Status 'Skipped' ` + -Message "Registry value '$autoStartAppName' was not present." + } + } + } catch { + $stepErrors.Add("reset-autostart: $($_.Exception.Message)") + Add-Step -Name 'reset-autostart' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 7 + # Null DeviceToken field — preserve the file per v3 §C. + # ------------------------------------------------------------------ Step 7 + try { + if (-not (Test-Path -LiteralPath $deviceKeyPath)) { + Add-Step -Name 'null-device-token' -Status 'Skipped' ` + -Message 'device-key-ed25519.json not found; nothing to null.' + } elseif ($IsDryRun) { + Add-Step -Name 'null-device-token' -Status 'DryRun' ` + -Message 'Would null DeviceToken field; keypair file preserved. Value: ***REDACTED***' + } else { + $kRaw = Get-Content -LiteralPath $deviceKeyPath -Raw -Encoding UTF8 + $kObj = $kRaw | ConvertFrom-Json + $dtVal = if ($kObj.PSObject.Properties['DeviceToken']) { $kObj.DeviceToken } else { $null } + if (-not [string]::IsNullOrEmpty($dtVal)) { + $kObj.DeviceToken = $null + if ($kObj.PSObject.Properties['DeviceTokenScopes']) { $kObj.DeviceTokenScopes = $null } + $kObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $deviceKeyPath -Encoding UTF8 + Add-Step -Name 'null-device-token' -Status 'Executed' ` + -Message 'DeviceToken set to null; keypair file preserved. Value: ***REDACTED***' + } else { + Add-Step -Name 'null-device-token' -Status 'Skipped' ` + -Message 'DeviceToken already null or absent; file preserved.' + } + } + } catch { + $stepErrors.Add("null-device-token: $($_.Exception.Message)") + Add-Step -Name 'null-device-token' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 8 + # Delete setup-state.json + # ------------------------------------------------------------------ Step 8 + try { + if (-not (Test-Path -LiteralPath $setupStatePath)) { + Add-Step -Name 'delete-setup-state' -Status 'Skipped' -Message 'setup-state.json not found.' + } elseif ($IsDryRun) { + Add-Step -Name 'delete-setup-state' -Status 'DryRun' ` + -Message "Would delete: $setupStatePath" + } else { + Remove-Item -LiteralPath $setupStatePath -Force + Add-Step -Name 'delete-setup-state' -Status 'Executed' ` + -Message "Deleted $setupStatePath." + } + } catch { + $stepErrors.Add("delete-setup-state: $($_.Exception.Message)") + Add-Step -Name 'delete-setup-state' -Status 'Failed' -Message $_.Exception.Message + } + + # ------------------------------------------------------------------ Step 9 + # Delete gateway logs (unless PreserveLogs=true). + # ------------------------------------------------------------------ Step 9 + try { + if ($PreserveLogs) { + Add-Step -Name 'delete-gateway-logs' -Status 'Skipped' -Message 'PreserveLogs=true.' + } elseif (-not (Test-Path -LiteralPath $logsDir)) { + Add-Step -Name 'delete-gateway-logs' -Status 'Skipped' ` + -Message "Logs directory not found: $logsDir" + } elseif ($IsDryRun) { + $cnt = (Get-ChildItem -LiteralPath $logsDir -File -ErrorAction SilentlyContinue).Count + Add-Step -Name 'delete-gateway-logs' -Status 'DryRun' ` + -Message "Would delete $cnt file(s) matching *.log / crash.log in $logsDir." + } else { + $logFiles = Get-ChildItem -LiteralPath $logsDir -File -ErrorAction SilentlyContinue | + Where-Object { $_.Extension -eq '.log' -or $_.Name -eq 'crash.log' } + $deleted = 0 + foreach ($f in $logFiles) { + try { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop; $deleted++ } catch { } + } + Add-Step -Name 'delete-gateway-logs' -Status 'Executed' ` + -Message "Deleted $deleted log file(s)." + } + } catch { + $stepErrors.Add("delete-gateway-logs: $($_.Exception.Message)") + Add-Step -Name 'delete-gateway-logs' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 10 + # Delete exec-policy.json (unless PreserveExecPolicy=true). + # ----------------------------------------------------------------- Step 10 + try { + if ($PreserveExecPolicy) { + Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'PreserveExecPolicy=true.' + } elseif (-not (Test-Path -LiteralPath $execPolicyPath)) { + Add-Step -Name 'delete-exec-policy' -Status 'Skipped' -Message 'exec-policy.json not found.' + } elseif ($IsDryRun) { + Add-Step -Name 'delete-exec-policy' -Status 'DryRun' ` + -Message "Would delete: $execPolicyPath" + } else { + Remove-Item -LiteralPath $execPolicyPath -Force + Add-Step -Name 'delete-exec-policy' -Status 'Executed' ` + -Message "Deleted $execPolicyPath." + } + } catch { + $stepErrors.Add("delete-exec-policy: $($_.Exception.Message)") + Add-Step -Name 'delete-exec-policy' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 11 + # Reset onboarding settings: Token, BootstrapToken, GatewayUrl. + # EnableMcpServer is deliberately left as-is (v3 §F / Q-M): + # it controls whether RequiresSetup fires post-uninstall. + # ----------------------------------------------------------------- Step 11 + try { + if ($IsDryRun) { + Add-Step -Name 'reset-onboarding-settings' -Status 'DryRun' ` + -Message "Would reset Token='', BootstrapToken='', GatewayUrl='ws://localhost:18789'. EnableMcpServer preserved. Tokens: ***REDACTED***" + } elseif (-not (Test-Path -LiteralPath $settingsPath)) { + Add-Step -Name 'reset-onboarding-settings' -Status 'Skipped' ` + -Message 'settings.json not found.' + } else { + $sRaw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + $sObj = $sRaw | ConvertFrom-Json + foreach ($field in @('Token', 'BootstrapToken')) { + if ($sObj.PSObject.Properties[$field]) { $sObj.$field = '' } + } + if ($sObj.PSObject.Properties['GatewayUrl']) { + $sObj.GatewayUrl = 'ws://localhost:18789' + } + # EnableMcpServer: NOT touched. + $sObj | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $settingsPath -Encoding UTF8 + Add-Step -Name 'reset-onboarding-settings' -Status 'Executed' ` + -Message 'Token=***REDACTED***, BootstrapToken=***REDACTED***, GatewayUrl reset. EnableMcpServer preserved.' + } + } catch { + $stepErrors.Add("reset-onboarding-settings: $($_.Exception.Message)") + Add-Step -Name 'reset-onboarding-settings' -Status 'Failed' -Message $_.Exception.Message + } + + # ----------------------------------------------------------------- Step 12 + # Preserve mcp-token.txt — no-op; logged for audit clarity. + # Per v3 §F: mcp-token is not a gateway artifact and is NEVER deleted. + # ----------------------------------------------------------------- Step 12 + Add-Step -Name 'preserve-mcp-token' -Status 'Skipped' ` + -Message 'mcp-token.txt preserved unconditionally (v3 §F). Not a gateway artifact.' + + return $stepErrors.ToArray() +} + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +function Write-SummaryMarkdown { + param([System.Collections.Specialized.OrderedDictionary]$VerdictData) + + $lines = @( + "# OpenClaw WSL Gateway Uninstall Validation", + "", + "| Field | Value |", + "|-------------|-------|", + "| Mode | $Mode |", + "| DistroName | $DistroName |", + "| DryRun | $($DryRun.IsPresent) |", + "| Verdict | $($VerdictData.verdict) |", + "| StartedAt | $($VerdictData.started_at) |", + "| FinishedAt | $($VerdictData.finished_at) |", + "| OutputDir | $OutputDir |", + "" + ) + + if ($VerdictData.postconditions -and $VerdictData.postconditions.Count -gt 0) { + $lines += "## Postconditions", "" + foreach ($key in $VerdictData.postconditions.Keys) { + $val = $VerdictData.postconditions[$key] + $icon = if ($val -eq $true) { 'PASS' } elseif ($val -eq $false) { 'FAIL' } else { 'INFO' } + $lines += "- [$icon] $key : $val" + } + $lines += "" + } + + if ($VerdictData.errors -and $VerdictData.errors.Count -gt 0) { + $lines += "## Errors", "" + foreach ($e in $VerdictData.errors) { $lines += "- $e" } + $lines += "" + } + + if ($script:steps.Count -gt 0) { + $lines += "## Steps", "" + foreach ($step in $script:steps) { + $lines += "- [$($step.status)] $($step.name): $($step.message)" + } + $lines += "" + } + + $lines | Set-Content -LiteralPath $summaryMdPath -Encoding UTF8 +} + +function Write-ColorVerdict { + param([string]$Verdict) + + $color = switch ($Verdict) { + 'PASS' { 'Green' } + 'DryRunComplete' { 'Cyan' } + 'AlreadyClean' { 'Cyan' } + 'PreflightOnly' { 'Cyan' } + 'PARTIAL' { 'Yellow' } + 'FAIL' { 'Red' } + 'ERROR' { 'Red' } + default { 'White' } + } + + Write-Host "" + Write-Host "════════════════════════════════════════════" -ForegroundColor $color + Write-Host " VERDICT : $Verdict" -ForegroundColor $color + Write-Host " Output : $OutputDir" -ForegroundColor $color + Write-Host "════════════════════════════════════════════" -ForegroundColor $color + Write-Host "" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +$verdictData = [ordered]@{ + mode = $Mode + dry_run = $DryRun.IsPresent + started_at = (Get-Date).ToString("o") + finished_at = $null + distro_name = $DistroName + preflight_passed = $false + destruction_executed = $false + postconditions = [ordered]@{} + verdict = 'UNKNOWN' + errors = @() +} + +$exitCode = 0 + +try { + switch ($Mode) { + + # =================================================================== + 'PreflightOnly' { + # =================================================================== + $registered = Test-DistroRegistered -Name $DistroName + + if (-not $registered) { + Add-Step -Name 'distro-check' -Status 'Passed' ` + -Message "Distro '$DistroName' is not registered — AlreadyClean." + $verdictData.preflight_passed = $true + $verdictData.verdict = 'AlreadyClean' + Write-Host "AlreadyClean: '$DistroName' not registered. Nothing to uninstall." -ForegroundColor Cyan + } else { + Add-Step -Name 'distro-check' -Status 'Passed' ` + -Message "Distro '$DistroName' is registered." + + $preState = Get-StateSnapshot -Label 'preflight' + $preState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $preStatePath -Encoding UTF8 + Add-Step -Name 'pre-state-snapshot' -Status 'Completed' ` + -Message "Pre-state snapshot written to: $preStatePath" + + $verdictData.preflight_passed = $true + $verdictData.verdict = 'PreflightOnly' + Write-Host "PreflightOnly: '$DistroName' registered. Pre-state: $preStatePath" -ForegroundColor Green + } + $exitCode = 0 + } + + # =================================================================== + 'Full' { + # =================================================================== + # Pre-state snapshot. + $preState = Get-StateSnapshot -Label 'pre-uninstall' + $preState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $preStatePath -Encoding UTF8 + Add-Step -Name 'pre-state-snapshot' -Status 'Completed' ` + -Message "Pre-state snapshot written to: $preStatePath" + $verdictData.preflight_passed = $true + + if (-not $preState.distro_registered) { + Write-Host "Note: '$DistroName' is not registered. Postcondition evaluation may show AlreadyClean." ` + -ForegroundColor Yellow + } + + $isDryRun = $DryRun.IsPresent + $stepErrors = @() + + # ---------------------------------------------------------------- + # CLI delegate path (default) — eliminates engine/script drift + # -NoCli falls back to the inline PS replication below. + # ---------------------------------------------------------------- + $trayExe = Get-TrayExePath -Hint $ExePath + $useCliPath = (-not $NoCli.IsPresent) -and ($null -ne $trayExe) + + if ($useCliPath) { + Write-Host "Delegating to CLI: $trayExe" -ForegroundColor Cyan + + $cliJsonPath = Join-Path $OutputDir 'uninstall-result.json' + $cliArgs = @('--uninstall', '--json-output', $cliJsonPath) + + if ($isDryRun) { $cliArgs += '--dry-run' } + if (-not $isDryRun) { $cliArgs += '--confirm-destructive' } + + try { + & $trayExe @cliArgs + $cliExitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } + + Add-Step -Name 'cli-uninstall-delegate' -Status 'Completed' ` + -Message "Exit code: $cliExitCode. JSON: $cliJsonPath" + $verdictData.destruction_executed = -not $isDryRun + + # Merge CLI result JSON into step audit trail + if (Test-Path -LiteralPath $cliJsonPath) { + try { + $cliResult = Get-Content -LiteralPath $cliJsonPath -Raw | ConvertFrom-Json + foreach ($step in $cliResult.steps) { + Add-Step -Name $step.name -Status $step.status -Message $step.detail + } + if ($cliResult.errors.Count -gt 0) { + $stepErrors = @($cliResult.errors) + } + # Persist CLI step JSON as the primary step audit trail + $cliJsonPath | Out-Null # already written to OutputDir + } catch { + $stepErrors += "cli-result-parse: $($_.Exception.Message)" + } + } + + if ($cliExitCode -ne 0) { + $stepErrors += "CLI exited $cliExitCode" + } + } catch { + $stepErrors += "cli-delegate: $($_.Exception.Message)" + Add-Step -Name 'cli-uninstall-delegate' -Status 'Failed' ` + -Message $_.Exception.Message + } + + } else { + # ---------------------------------------------------------------- + # Inline PS replication (diagnostic fallback / -NoCli) + # ---------------------------------------------------------------- + if ($NoCli.IsPresent) { + Write-Host "Inline PS replication (-NoCli)." -ForegroundColor Yellow + } else { + Write-Host "OpenClawTray.exe not found; falling back to inline PS replication." ` + -ForegroundColor Yellow + } + + $stepErrors = Invoke-UninstallSteps -IsDryRun $isDryRun + $verdictData.destruction_executed = -not $isDryRun + } + + $verdictData.errors = @($stepErrors) + + # Save step audit trail. + $script:steps | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $stepsPath -Encoding UTF8 + + # Post-state snapshot. + $postState = Get-StateSnapshot -Label 'post-uninstall' + $postState | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $postStatePath -Encoding UTF8 + Add-Step -Name 'post-state-snapshot' -Status 'Completed' ` + -Message "Post-state snapshot written to: $postStatePath" + + # Postconditions + verdict. + if ($isDryRun) { + $verdictData.verdict = 'DryRunComplete' + $exitCode = 0 + Write-Host "DryRun complete. No state was mutated." -ForegroundColor Cyan + } else { + $postconditions = Get-Postconditions + $verdictData.postconditions = $postconditions + $verdict = Get-Verdict -Postconditions $postconditions -Errors $stepErrors + $verdictData.verdict = $verdict + $exitCode = if ($verdict -eq 'PASS') { 0 } else { 1 } + } + } + + # =================================================================== + 'PostconditionOnly' { + # =================================================================== + Add-Step -Name 'postcondition-check' -Status 'Running' ` + -Message 'Evaluating current state against uninstall-complete expectations.' + + $postconditions = Get-Postconditions + $verdictData.postconditions = $postconditions + $verdict = Get-Verdict -Postconditions $postconditions -Errors @() + $verdictData.verdict = $verdict + $exitCode = if ($verdict -eq 'PASS') { 0 } else { 1 } + + Add-Step -Name 'postcondition-check' -Status 'Completed' ` + -Message "Verdict: $verdict." + } + } + +} catch { + $verdictData.verdict = 'ERROR' + $verdictData.errors = @($verdictData.errors) + @($_.Exception.Message) + Add-Step -Name 'execution-error' -Status 'Failed' -Message $_.Exception.Message + $stackTrace = if ($_.ScriptStackTrace) { $_.ScriptStackTrace } else { '' } + Write-Host "ERROR: Unhandled error in -Mode $Mode : $($_.Exception.Message)" -ForegroundColor Red + Write-Host "Stack: $stackTrace" -ForegroundColor Red + $exitCode = 3 + +} finally { + $verdictData.finished_at = (Get-Date).ToString("o") + + # Write verdict.json (always, even on error). + $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 + + # Write summary.md. + Write-SummaryMarkdown -VerdictData $verdictData + + Write-Host "Verdict JSON : $verdictPath" + Write-Host "Summary MD : $summaryMdPath" +} + +Write-ColorVerdict -Verdict $verdictData.verdict +exit $exitCode diff --git a/scripts/validate-wsl-gateway.ps1 b/scripts/validate-wsl-gateway.ps1 index 711b0f996..98f24779c 100644 --- a/scripts/validate-wsl-gateway.ps1 +++ b/scripts/validate-wsl-gateway.ps1 @@ -115,7 +115,7 @@ function Add-Step { function Test-IsOpenClawOwnedDistroName { param([string]$Name) - return $Name -eq "OpenClawGateway" -or $Name.StartsWith("OpenClawGateway", [System.StringComparison]::Ordinal) + return $Name -eq "OpenClawGateway" } function Assert-DestructiveSafety { @@ -123,7 +123,7 @@ function Assert-DestructiveSafety { throw "-ConfirmDestructiveClean is required when -Scenario is $Scenario (will unregister WSL distro '$DistroName')." } if ($Scenario -in @("FreshMachine", "Recreate") -and -not (Test-IsOpenClawOwnedDistroName -Name $DistroName)) { - throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must start with 'OpenClawGateway'." + throw "Refusing destructive action for non-OpenClaw distro '$DistroName'. Distro name must be exactly 'OpenClawGateway'." } } @@ -448,7 +448,8 @@ function Start-TrayForLocalSetup { OPENCLAW_SKIP_UPDATE_CHECK = "1" OPENCLAW_FORCE_ONBOARDING = "1" OPENCLAW_WSL_DISTRO_NAME = $DistroName - OPENCLAW_WSL_INSTALL_LOCATION = $wslInstallLocation + # TODO: OPENCLAW_WSL_INSTALL_LOCATION was removed (commit: remove OPENCLAW_WSL_INSTALL_LOCATION env-var binding). + # The install location is now derived from the distro name by the tray app. Remove this comment once uninstall support lands. OPENCLAW_WSL_ALLOW_EXISTING_DISTRO = if ($Scenario -eq "UpstreamInstall") { "1" } else { "0" } OPENCLAW_TRAY_DATA_DIR = $validationAppDataRoot OPENCLAW_TRAY_APPDATA_DIR = $validationAppDataRoot diff --git a/src/OpenClaw.Shared/DeviceIdentity.cs b/src/OpenClaw.Shared/DeviceIdentity.cs index 59e068fa2..b21bd82fc 100644 --- a/src/OpenClaw.Shared/DeviceIdentity.cs +++ b/src/OpenClaw.Shared/DeviceIdentity.cs @@ -81,6 +81,81 @@ public static bool HasStoredDeviceToken(string dataPath, IOpenClawLogger? logger public static bool HasStoredDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) => !string.IsNullOrWhiteSpace(TryReadStoredDeviceTokenForRole(dataPath, role, logger)); + + /// + /// Sets the operator DeviceToken field to null in + /// device-key-ed25519.json without deleting the file. + /// Preserves all other fields (Ed25519 keypair, algorithm, timestamps, + /// NodeDeviceToken). + /// + /// + /// true if the token was cleared; false if the file was + /// absent or the DeviceToken field was already null/empty + /// (idempotent skip). + /// + public static bool TryClearDeviceToken(string dataPath, IOpenClawLogger? logger = null) => + TryClearDeviceTokenForRole(dataPath, "operator", logger); + + /// + /// Sets the role-specific device token field to null in + /// device-key-ed25519.json without deleting the file. Preserves the + /// Ed25519 keypair and unrelated role tokens. + /// + /// + /// true if the token was cleared; false if the file was + /// absent or the role token was already null/empty. + /// + public static bool TryClearDeviceTokenForRole(string dataPath, string role, IOpenClawLogger? logger = null) + { + var tokenRole = ParseDeviceTokenRole(role); + var keyPath = Path.Combine(dataPath, "device-key-ed25519.json"); + if (!File.Exists(keyPath)) + return false; + + try + { + var json = File.ReadAllText(keyPath); + var data = JsonSerializer.Deserialize(json); + if (data == null) + return false; + + var token = tokenRole == DeviceTokenRole.Node + ? data.NodeDeviceToken + : data.DeviceToken; + if (string.IsNullOrEmpty(token)) + return false; // already null — idempotent + + if (tokenRole == DeviceTokenRole.Node) + { + data.NodeDeviceToken = null; + data.NodeDeviceTokenScopes = null; + } + else + { + data.DeviceToken = null; + data.DeviceTokenScopes = null; + } + + AtomicWriteKeyFile(keyPath, data); + logger?.Info($"{(tokenRole == DeviceTokenRole.Node ? "NodeDeviceToken" : "DeviceToken")} cleared from device-key-ed25519.json (file preserved)."); + return true; + } + catch (IOException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + catch (UnauthorizedAccessException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + logger?.Warn($"Failed to clear device token: {ex.Message}"); + return false; + } + } public DeviceIdentity(string dataPath, IOpenClawLogger? logger = null) { @@ -172,8 +247,10 @@ private void GenerateNew() if (!string.IsNullOrEmpty(dir)) McpAuthToken.TryRestrictDataDirectoryAcl(dir); - File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath); + // Save to disk via atomic temp+rename so a process-kill or power-loss + // mid-write cannot leave a torn/zero-byte key file that the next + // LoadOrCreate would treat as invalid and silently rotate the identity. + AtomicWriteKeyFile(_keyPath, data); _logger.Info($"Generated new Ed25519 device identity: {_deviceId}"); } @@ -377,8 +454,7 @@ private void StoreDeviceTokenCore(string token, string[]? scopes) { data.DeviceToken = token; data.DeviceTokenScopes = scopes; - File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); - McpAuthToken.TryRestrictSensitiveFileAcl(_keyPath); + AtomicWriteKeyFile(_keyPath, data); _logger.Info("Device token stored"); } } @@ -407,7 +483,7 @@ private void StoreNodeDeviceTokenCore(string token, string[]? scopes) { data.NodeDeviceToken = token; data.NodeDeviceTokenScopes = scopes; - File.WriteAllText(_keyPath, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + AtomicWriteKeyFile(_keyPath, data); _logger.Info("Node device token stored"); } } @@ -418,6 +494,36 @@ private void StoreNodeDeviceTokenCore(string token, string[]? scopes) } } + /// + /// Atomic write of device-key JSON: serialize to a sibling temp file + /// (.<name>.<guid>.tmp), lock its ACL, then + /// with overwrite=true. The + /// rename is atomic on NTFS — a process-kill or power-loss mid-write + /// either leaves the existing key file intact or replaces it wholesale, + /// never a torn/zero-byte file that the next LoadOrCreate would silently + /// rotate the identity over. + /// Same shape as . + /// + private static void AtomicWriteKeyFile(string path, DeviceKeyData data) + { + var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + var dir = Path.GetDirectoryName(path); + var tempDir = string.IsNullOrEmpty(dir) ? Environment.CurrentDirectory : dir; + var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(tempPath, json); + McpAuthToken.TryRestrictSensitiveFileAcl(tempPath); + File.Move(tempPath, path, overwrite: true); + } + catch + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { /* best-effort cleanup */ } + throw; + } + McpAuthToken.TryRestrictSensitiveFileAcl(path); + } + private static string[]? NormalizeScopes(IEnumerable? scopes) { if (scopes == null) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 09fd845c0..3e26f90f8 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -19,6 +19,8 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Updatum; @@ -311,6 +313,156 @@ private static void LogCrash(string source, Exception? ex) } catch { /* Ignore logging failures */ } } + + // ----------------------------------------------------------------------- + // CLI uninstall path + // Invoked when --uninstall is present in argv. Runs headlessly without + // creating the tray UI. Attaches to the parent console so stdout/stderr + // are visible when invoked from PowerShell or cmd. + // ----------------------------------------------------------------------- + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachConsole(int dwProcessId); + + private const int AttachParentProcess = -1; + + private static async Task RunCliUninstallAsync(string[] args) + { + // Attach to parent console so output is visible when invoked from + // PowerShell or cmd. Fails silently if no parent console exists. + AttachConsole(AttachParentProcess); + + bool dryRun = args.Contains("--dry-run", StringComparer.OrdinalIgnoreCase); + bool confirmDestructive = args.Contains("--confirm-destructive", StringComparer.OrdinalIgnoreCase); + + // Locate --json-output argument + string? jsonOutputPath = null; + for (int i = 0; i < args.Length - 1; i++) + { + if (string.Equals(args[i], "--json-output", StringComparison.OrdinalIgnoreCase)) + { + jsonOutputPath = args[i + 1]; + break; + } + } + + if (!confirmDestructive && !dryRun) + { + Console.Error.WriteLine( + "ERROR: --uninstall requires --confirm-destructive (or --dry-run)."); + Environment.Exit(2); + return; + } + + var settings = new SettingsManager(); + var engine = LocalGatewayUninstall.Build(settings, logger: new AppLogger()); + + LocalGatewayUninstallResult result; + try + { + result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = dryRun, + ConfirmDestructive = confirmDestructive + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Uninstall engine threw: {ex.Message}"); + Environment.Exit(1); + return; + } + + // Human-readable summary (tokens already redacted inside engine steps) + Console.WriteLine("OpenClaw Local Gateway Uninstall"); + Console.WriteLine($"DryRun: {dryRun}"); + Console.WriteLine($"Success: {result.Success}"); + Console.WriteLine($"Steps: {result.Steps.Count} ({result.SkippedSteps.Count} skipped)"); + Console.WriteLine($"Errors: {result.Errors.Count}"); + foreach (var e in result.Errors) + Console.Error.WriteLine($" ERROR: {CliRedact(e)}"); + Console.WriteLine("Postconditions:"); + Console.WriteLine($" WslDistroAbsent: {result.Postconditions.WslDistroAbsent}"); + Console.WriteLine($" AutostartCleared: {result.Postconditions.AutostartCleared}"); + Console.WriteLine($" SetupStateAbsent: {result.Postconditions.SetupStateAbsent}"); + Console.WriteLine($" DeviceTokenCleared: {result.Postconditions.DeviceTokenCleared}"); + Console.WriteLine($" McpTokenPreserved: {result.Postconditions.McpTokenPreserved}"); + Console.WriteLine($" KeepalivesAbsent: {result.Postconditions.KeepalivesAbsent}"); + Console.WriteLine($" VhdDirAbsent: {result.Postconditions.VhdDirAbsent}"); + Console.WriteLine($" LocalGatewayRecordsAbsent: {result.Postconditions.LocalGatewayRecordsAbsent}"); + Console.WriteLine($" LocalGatewayIdentityDirsAbsent: {result.Postconditions.LocalGatewayIdentityDirsAbsent}"); + + // JSON output — redaction applied to step details and error strings + if (jsonOutputPath != null) + { + try + { + var dir = Path.GetDirectoryName(jsonOutputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new + { + success = result.Success, + dry_run = dryRun, + steps = result.Steps.Select(s => new + { + name = s.Name, + status = s.Status.ToString(), + detail = CliRedact(s.Detail) + }), + errors = result.Errors.Select(CliRedact), + skipped_steps = result.SkippedSteps, + postconditions = new + { + wsl_distro_absent = result.Postconditions.WslDistroAbsent, + autostart_cleared = result.Postconditions.AutostartCleared, + setup_state_absent = result.Postconditions.SetupStateAbsent, + device_token_cleared = result.Postconditions.DeviceTokenCleared, + mcp_token_preserved = result.Postconditions.McpTokenPreserved, + keepalives_absent = result.Postconditions.KeepalivesAbsent, + vhd_dir_absent = result.Postconditions.VhdDirAbsent, + local_gateway_records_absent = result.Postconditions.LocalGatewayRecordsAbsent, + local_gateway_identity_dirs_absent = result.Postconditions.LocalGatewayIdentityDirsAbsent + } + }; + + File.WriteAllText(jsonOutputPath, JsonSerializer.Serialize( + payload, new JsonSerializerOptions { WriteIndented = true })); + + Console.WriteLine($"JSON result: {jsonOutputPath}"); + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"WARNING: Failed to write JSON output to '{jsonOutputPath}': {ex.Message}"); + } + } + + Environment.Exit(result.Success ? 0 : 1); + } + + /// + /// Redacts token/key material from a string before writing it to CLI + /// stdout or a JSON output file. Mirrors the PowerShell Invoke-Redact + /// pattern in validate-wsl-gateway-uninstall.ps1. + /// + private static string? CliRedact(string? value) + { + if (string.IsNullOrEmpty(value)) return value; + // Redact JSON field values for known secret fields. + value = System.Text.RegularExpressions.Regex.Replace( + value, + @"(""(?i:deviceToken|device_token|token|bootstrapToken|bootstrap_token|PrivateKeyBase64|PublicKeyBase64)""\s*:\s*"")[^""]+("")", + "$1$2"); + // Redact bare key=value / key: value patterns. + value = System.Text.RegularExpressions.Regex.Replace( + value, + @"(?i)((?:device|bootstrap|gateway|auth|mcp)[_-]?token\s*[:=]\s*)[^\s,""'}{]+", + "$1"); + return value; + } private static void CheckPreviousRun() { @@ -372,6 +524,19 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) _startupArgs = Environment.GetCommandLineArgs(); _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + // ----------------------------------------------------------------------- + // CLI uninstall path — headless; never shows tray or any windows. + // Approach: detect in OnLaunched before any UI is created (WinUI3 Main + // is auto-generated; earliest interception point is OnLaunched). + // Bypasses the single-instance mutex so the Inno uninstaller can invoke + // this even while the tray is running. + // ----------------------------------------------------------------------- + if (_startupArgs.Contains("--uninstall", StringComparer.OrdinalIgnoreCase)) + { + await RunCliUninstallAsync(_startupArgs); + return; // Environment.Exit called inside; defensive return + } + // Check for protocol activation (MSIX packaged apps receive deep links this way) string? protocolUri = GetProtocolActivationUri(); @@ -382,6 +547,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // two test runs against the same data dir would otherwise pick different // mutex names — and `Math.Abs(int.MinValue)` overflows. Use a stable // SHA-256 prefix instead. + // NOTE: The bare "OpenClawTray" mutex name is also referenced by + // installer.iss `AppMutex=` for install/uninstall race coordination + // (round 2, Scott #5). The suffixed test-isolation variant is + // intentionally not covered by AppMutex — production installs only + // ever use the unsuffixed name. var mutexName = "OpenClawTray"; if (DataDirOverride is not null) { @@ -1802,6 +1972,7 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) { Id = recordId, Url = gatewayUrl, + IsLocal = LocalGatewayUrlClassifier.IsLocalGatewayUrl(gatewayUrl), SshTunnel = _settings.UseSshTunnel ? new SshTunnelConfig( _settings.SshTunnelUser ?? "", diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 018246f5b..3b2bed17a 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -92,6 +92,47 @@ + + + + + + + + + + + + + + +