From 57ddf209fa4c0fd3cdaab5065be8cd8c29a7ba33 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 19 Oct 2026 13:55:44 -0700 Subject: [PATCH 1/9] fix(setup): launch surviving Gateway cleanup without a console --- docs/ARCHITECTURE.md | 1 + docs/SETUP.md | 4 + openclaw-windows-node.slnx | 1 + .../OpenClaw.GatewayCleanupHost.csproj | 12 ++ src/OpenClaw.GatewayCleanupHost/Program.cs | 53 +++++++ .../GatewayPackageCleanup.Task.ps1 | 15 ++ .../GatewayPackageCleanup.cs | 46 +++++- .../GatewayPackageCleanup.ps1 | 9 +- .../OpenClaw.SetupEngine.csproj | 6 + .../GatewayCleanupTaskTests.cs | 134 ++++++++++++++++++ 10 files changed, 267 insertions(+), 14 deletions(-) create mode 100644 src/OpenClaw.GatewayCleanupHost/OpenClaw.GatewayCleanupHost.csproj create mode 100644 src/OpenClaw.GatewayCleanupHost/Program.cs create mode 100644 src/OpenClaw.SetupEngine/GatewayPackageCleanup.Task.ps1 create mode 100644 tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11d23b729..bb832ed6f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,6 +229,7 @@ new attempt. This boundary does not change release signing or Windows policy. | Exactly-once ordered app shutdown sequencing | `AppShutdownCoordinator` | authoritative | | App composition-root startup sequencing | `AppBootstrapper` (planned) | planned | | Packaged uninstall cleanup, preservation recovery and removed-target reconciliation | `GatewayPackageCleanup`; App only invokes its startup gate and presents recovery. Legacy name/path markers never grant unattended deletion authority; malformed receipts/settings retain pending evidence and fail closed | authoritative | +| Windowless scheduled cleanup launch after Companion removal | `OpenClaw.GatewayCleanupHost` (GUI-subsystem .NET Framework helper), embedded and deployed by `GatewayPackageCleanup`; `GatewayPackageCleanup.Task.ps1` registers its durable versioned path. PowerShell starts with `CreateNoWindow`; worker exit codes reach Task Scheduler | authoritative | | Inno migration records, preparation, and pre-start completion guard | `MigrationRecordCodec`, `MigrationPreparation`, `MigrationInventory`, `InnoMigrationStartupGuard` | authoritative | | Cross-session migration exclusion and source activity inspection | `MigrationOperationLock`, `InnoSourceActivityVerifier` (App only retains the runtime handle through process exit) | authoritative | | Store migration startup admission and finalization | `InnoInstallationDetector`, `InnoSourceRemovalVerifier`, `MigrationStartupRecordReader`, `MigrationInventoryCapture`, `StoreMigrationStartupCoordinator`, `StoreMigrationFinalizationCoordinator`, `StoreMigrationStartupGuard` | authoritative | diff --git a/docs/SETUP.md b/docs/SETUP.md index 99ba40334..5683ce828 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -191,6 +191,10 @@ Go to **Settings → Apps → Installed apps**, find **OpenClaw Companion**, and Your settings file at `%APPDATA%\OpenClawTray\settings.json` is not removed automatically, and device identity files for gateways unrelated to the one you removed (including per-gateway keys at `%APPDATA%\OpenClawTray\gateways\\device-key-ed25519.json`) are preserved. Choosing **Yes** also removes the removed gateway's own identity directory. Root operator and node tokens are preserved only while an external gateway record remains; otherwise they are cleared, even if another local or loopback gateway record remains. Delete `%APPDATA%\OpenClawTray\` manually if you want a fully clean uninstall. +For the **Companion MSIX**, managed Gateway cleanup uses a current-user interactive scheduled task that survives Companion removal. Its worker, registration script and versioned `cleanup-host-.exe` are copied to `%USERPROFILE%\.openclaw-companion\cleanup\\`. The task runs at logon and every minute through a GUI-subsystem helper that starts Windows PowerShell with `CreateNoWindow`, preventing a desktop console from being created. The helper targets the Windows-provided .NET Framework 4.8 and forwards the worker's exit code, recording failures in `cleanup-host-error.txt` beside the worker. It does not depend on VBScript, the Companion's .NET 10 runtime, or an executable inside the removed package. Versioned helper paths allow an updated Companion to refresh the task without overwriting a running helper. + +The worker checks Companion package identity, ownership sentinels and removal events, then removes only the recorded owned Gateway resources after confirmed Companion removal. Polling also retries pending cleanup and provides a second absence check at least 30 seconds after the first when removal-event evidence is unavailable. The exact one-minute interval is a cleanup-latency choice, not an AppX requirement; increasing it would only slow cleanup, not fix console creation. Opening an updated packaged Companion refreshes an existing owned cleanup task with the new action. A Gateway-only package update cannot refresh this Companion-owned task. + For the headless dev/WSL CLI uninstall (`--uninstall --dry-run`, then `--confirm-destructive`), see the [uninstall skill](../.agents/skills/uninstall/SKILL.md). For a **clean retest of native Gateway/MXC, Companion, and managed llama.cpp**, use its [hard-clean procedure](../.agents/skills/uninstall/HARD-CLEAN.md). This inventories exact targets and requires confirmation before teardown, package removal, or residual-state deletion. Shared model caches, unrelated profiles, and WSL are preserved unless explicitly included. On a demo/test device without Copilot, copy [scripts/clean-uninstall.ps1](../scripts/clean-uninstall.ps1) and run it with Windows PowerShell 5.1. Running without arguments previews the scope; `-ConfirmDestructive` applies it. Use `-All` to include dev state, receipt-backed cached models, and owned release/dev WSL distros; add `-ExcludeCachedModels` to preserve external shared cached weights. It still requires `-ConfirmDestructive` to apply and does not discover custom profile paths. Read the [standalone options and preservation rules](../.agents/skills/uninstall/HARD-CLEAN.md#standalone-script-no-copilot-required) before expanding the scope or removing shared model weights. diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 0b1ccdde8..7f1861dbb 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -16,6 +16,7 @@ + diff --git a/src/OpenClaw.GatewayCleanupHost/OpenClaw.GatewayCleanupHost.csproj b/src/OpenClaw.GatewayCleanupHost/OpenClaw.GatewayCleanupHost.csproj new file mode 100644 index 000000000..e838d3aab --- /dev/null +++ b/src/OpenClaw.GatewayCleanupHost/OpenClaw.GatewayCleanupHost.csproj @@ -0,0 +1,12 @@ + + + net48 + WinExe + AnyCPU + false + false + + + + + diff --git a/src/OpenClaw.GatewayCleanupHost/Program.cs b/src/OpenClaw.GatewayCleanupHost/Program.cs new file mode 100644 index 000000000..2a643a21d --- /dev/null +++ b/src/OpenClaw.GatewayCleanupHost/Program.cs @@ -0,0 +1,53 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace OpenClaw.GatewayCleanupHost +{ + internal static class Program + { + private static int Main(string[] args) + { + var directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + var errorPath = Path.Combine(directory, "cleanup-host-error.txt"); + try + { + if (args.Length != 1 || !Regex.IsMatch(args[0], @"\A[A-Za-z0-9._-]+\z")) + throw new ArgumentException("A valid Companion package family is required."); + + // The GUI subsystem host never allocates a console. Suppress it for the worker at creation too. + var start = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"" + + Path.Combine(directory, "cleanup.ps1") + "\" -Family " + args[0], + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + using (var process = Process.Start(start)) + { + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + Task.WaitAll(stdout, stderr); + if (process.ExitCode != 0) + File.WriteAllText(errorPath, stderr.Result); + return process.ExitCode; + } + } + catch (Exception error) when (error is ArgumentException || error is Win32Exception || + error is InvalidOperationException || error is IOException || error is UnauthorizedAccessException) + { + File.WriteAllText(errorPath, error.ToString()); + return 1; + } + } + } +} diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.Task.ps1 b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.Task.ps1 new file mode 100644 index 000000000..a2af911ba --- /dev/null +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.Task.ps1 @@ -0,0 +1,15 @@ +param( + [Parameter(Mandatory)][string]$TaskName, + [Parameter(Mandatory)][string]$HostPath, + [Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9._-]+$')][string]$Family +) +$ErrorActionPreference = 'Stop' +if (-not (Test-Path -LiteralPath $HostPath -PathType Leaf)) { + throw 'The windowless cleanup host is missing. Automatic Gateway cleanup could not be registered.' +} +$action = New-ScheduledTaskAction -Execute $HostPath -Argument $Family +$principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited +$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) +$logon = New-ScheduledTaskTrigger -AtLogOn -User $principal.UserId +$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -StartWhenAvailable +Register-ScheduledTask -TaskName $TaskName -Action $action -Principal $principal -Trigger @($trigger, $logon) -Settings $settings -Force | Out-Null diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs index befd4881e..683fbae15 100644 --- a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -58,15 +59,10 @@ public static Task RegisterAsync(ICommandRunner commands, string? distroName = n var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".openclaw-companion", "cleanup", family.ToString()); if (!register && !preserve && !File.Exists(Path.Combine(directory, "owned.json"))) return directory; - Directory.CreateDirectory(directory); var scriptPath = Path.Combine(directory, "cleanup.ps1"); - using var source = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( - "OpenClaw.SetupEngine.GatewayPackageCleanup.ps1") - ?? throw new InvalidOperationException("The Gateway cleanup helper is missing."); - using var reader = new StreamReader(source); - await AtomicFile.WriteAllTextAsync(scriptPath, await reader.ReadToEndAsync(ct), ct); + var hostPath = await DeployAsync(directory, ct); var arguments = new List { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", scriptPath, "-Family", family.ToString() }; + "-File", scriptPath, "-Family", family.ToString(), "-CleanupHostPath", hostPath }; if (register) arguments.Add("-Register"); if (preserve) arguments.Add("-Preserve"); if (createdDistro) arguments.Add("-CreatedDistro"); @@ -90,6 +86,42 @@ public static Task RegisterAsync(ICommandRunner commands, string? distroName = n return directory; } + internal static async Task DeployAsync(string directory, CancellationToken ct = default) + { + Directory.CreateDirectory(directory); + string hostPath; + using (var host = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( + "OpenClaw.SetupEngine.GatewayCleanupHost.exe") + ?? throw new InvalidOperationException("The Gateway cleanup host is missing.")) + { + using var content = new MemoryStream(); + await host.CopyToAsync(content, ct); + var bytes = content.ToArray(); + // Immutable versioned paths allow refresh while the previous task's executable is running. + hostPath = Path.Combine(directory, "cleanup-host-" + Convert.ToHexString(SHA256.HashData(bytes)) + ".exe"); + var existing = File.Exists(hostPath) ? await File.ReadAllBytesAsync(hostPath, ct) : []; + if (!bytes.AsSpan().SequenceEqual(existing)) + { + var temporary = hostPath + ".tmp"; + await File.WriteAllBytesAsync(temporary, bytes, ct); + File.Move(temporary, hostPath, overwrite: true); + } + } + foreach (var (resource, file) in new[] + { + ("GatewayPackageCleanup.ps1", "cleanup.ps1"), + ("GatewayPackageCleanup.Task.ps1", "cleanup-task.ps1") + }) + { + using var source = typeof(GatewayPackageCleanup).Assembly.GetManifestResourceStream( + "OpenClaw.SetupEngine." + resource) + ?? throw new InvalidOperationException("The Gateway cleanup helper is missing."); + using var reader = new StreamReader(source); + await AtomicFile.WriteAllTextAsync(Path.Combine(directory, file), await reader.ReadToEndAsync(ct), ct); + } + return hostPath; + } + private sealed record Removal(string? DistroName, string? InstallPath, string? NativeFamily); private sealed record LegacySetupState(string? DistroName, string? GatewayUrl); private sealed record LegacySettings(string? GatewayUrl, bool? UseSshTunnel, string? PreferredGatewayId); diff --git a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 index 1b97dd7e7..2e1b844d7 100644 --- a/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 +++ b/src/OpenClaw.SetupEngine/GatewayPackageCleanup.ps1 @@ -1,5 +1,6 @@ param( [Parameter(Mandatory)][string]$Family, + [string]$CleanupHostPath, [switch]$Register, [switch]$Preserve, [switch]$CreatedDistro, @@ -78,13 +79,7 @@ function Test-PackageRemovedSince([string]$since) { return $false } function Register-CleanupTask { - $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' - $action = New-ScheduledTaskAction -Execute $powershell -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$PSCommandPath`" -Family $Family" - $principal = New-ScheduledTaskPrincipal -UserId ([Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited - $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 1) - $logon = New-ScheduledTaskTrigger -AtLogOn -User $principal.UserId - $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -StartWhenAvailable - Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Trigger @($trigger, $logon) -Settings $settings -Force | Out-Null + & (Join-Path $PSScriptRoot 'cleanup-task.ps1') -TaskName $taskName -HostPath $CleanupHostPath -Family $Family } New-Item -ItemType Directory -Path $root -Force | Out-Null $lock = $null diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj index fe9dfe347..5ff1b7313 100644 --- a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -9,6 +9,12 @@ + + diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs new file mode 100644 index 000000000..28030a4d7 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Reflection.PortableExecutable; +using System.Text.Json; +using OpenClaw.TestSupport; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class GatewayCleanupTaskTests +{ + private static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(60); + + [Theory] + [InlineData(0)] + [InlineData(37)] + public async Task RegisteredAction_RunsWorkerWithoutDesktopConsole_AndPreservesExitCode(int exitCode) + { + Assert.True(OperatingSystem.IsWindows(), "Cleanup launch proof requires native Windows."); + using var temp = new TempDirectory(); + var directory = Directory.CreateDirectory(temp.Combine("cleanup with spaces & apostrophe's")).FullName; + var hostPath = await GatewayPackageCleanup.DeployAsync(directory); + // A repeated startup must work while the current helper is locked for execution. + using (var runningImage = new FileStream(hostPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + Assert.Equal(hostPath, await GatewayPackageCleanup.DeployAsync(directory)); + await File.WriteAllTextAsync(Path.Combine(directory, "host-path.txt"), hostPath); + var workerPath = Path.Combine(directory, "cleanup.ps1"); + await File.WriteAllTextAsync(workerPath, $$""" + param([string]$Family) + $ErrorActionPreference = 'Stop' + Add-Type @' + using System; + using System.Runtime.InteropServices; + public static class ConsoleProbe { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr window); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window); + } + '@ + $window = [ConsoleProbe]::GetConsoleWindow() + [pscustomobject]@{ + Family = $Family + HasConsole = ($window -ne [IntPtr]::Zero) + HasDesktopConsole = ($window -ne [IntPtr]::Zero -and [ConsoleProbe]::GetParent($window) -ne [IntPtr]::new(-3)) + Visible = [ConsoleProbe]::IsWindowVisible($window) + } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'result.json') -Encoding UTF8 + if ({{exitCode}} -ne 0) { [Console]::Error.WriteLine('synthetic worker failure') } + exit {{exitCode}} + """); + var harnessPath = Path.Combine(directory, "register-test.ps1"); + await File.WriteAllTextAsync(harnessPath, """ + $ErrorActionPreference = 'Stop' + Import-Module ScheduledTasks -ErrorAction Stop + # Only the persistence boundary is replaced. Task actions, principals and triggers are real CIM objects. + $capturePath = Join-Path $PSScriptRoot 'action.json' + Remove-Item Function:\Register-ScheduledTask -ErrorAction Stop + Set-Item Function:\Register-ScheduledTask -Options Constant -Value { + param($TaskName, $Action, $Principal, $Trigger, $Settings, [switch]$Force) + [pscustomobject]@{ + Execute = $Action.Execute + Arguments = $Action.Arguments + LogonType = [string]$Principal.LogonType + RunLevel = [string]$Principal.RunLevel + Interval = $Trigger[0].Repetition.Interval + TriggerCount = @($Trigger).Count + ReplacesExisting = $Force.IsPresent + } | ConvertTo-Json | Set-Content -LiteralPath $capturePath -Encoding UTF8 + } + & (Join-Path $PSScriptRoot 'cleanup-task.ps1') -TaskName 'OpenClaw disposable launch proof' ` + -HostPath (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'host-path.txt') -Raw) -Family 'Fixture_123' + """); + var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var registration = new ProcessStartInfo(powershell); + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", harnessPath }) + registration.ArgumentList.Add(argument); + Assert.Equal(0, await RunAsync(registration, createNoWindow: true)); + using var action = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "action.json"))); + var value = action.RootElement; + Assert.Equal("Interactive", value.GetProperty("LogonType").GetString()); + Assert.Equal("Limited", value.GetProperty("RunLevel").GetString()); + Assert.Equal("PT1M", value.GetProperty("Interval").GetString()); + Assert.Equal(2, value.GetProperty("TriggerCount").GetInt32()); + Assert.True(value.GetProperty("ReplacesExisting").GetBoolean()); + + // Check the actual scheduler entrypoint, not just the console state after PowerShell startup. + // A console-subsystem action can flash and detach before the worker can sample it. + using (var binary = File.OpenRead(value.GetProperty("Execute").GetString()!)) + using (var pe = new PEReader(binary)) + Assert.Equal(Subsystem.WindowsGui, pe.PEHeaders.PEHeader!.Subsystem); + + // Do not use CREATE_NO_WINDOW here: that would mask a regression to direct powershell.exe. + var launch = new ProcessStartInfo(value.GetProperty("Execute").GetString()!, + value.GetProperty("Arguments").GetString()!); + Assert.Equal(exitCode, await RunAsync(launch, createNoWindow: false)); + using var result = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "result.json"))); + Assert.Equal("Fixture_123", result.RootElement.GetProperty("Family").GetString()); + Assert.False(result.RootElement.GetProperty("HasConsole").GetBoolean()); + Assert.False(result.RootElement.GetProperty("HasDesktopConsole").GetBoolean(), + "The cleanup worker must not allocate a desktop console, even one hidden after startup."); + Assert.False(result.RootElement.GetProperty("Visible").GetBoolean()); + var errorPath = Path.Combine(directory, "cleanup-host-error.txt"); + if (exitCode != 0) + Assert.Contains("synthetic worker failure", await File.ReadAllTextAsync(errorPath)); + else + Assert.False(File.Exists(errorPath)); + } + + private static async Task RunAsync(ProcessStartInfo start, bool createNoWindow) + { + start.UseShellExecute = false; + start.CreateNoWindow = createNoWindow; + start.WindowStyle = ProcessWindowStyle.Hidden; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(ProcessTimeout); + try + { + await process.WaitForExitAsync(timeout.Token); + Assert.Equal("", (await stderr).Trim()); + await stdout; + return process.ExitCode; + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } +} From d9e30f17328b1e63310447ad578a195fb97be8b3 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Mon, 19 Oct 2026 23:49:41 -0700 Subject: [PATCH 2/9] test(setup): verify cleanup launcher user-visible behavior --- docs/SETUP.md | 2 +- docs/TEST_COVERAGE.md | 22 + .../CleanupVisibilityProbe.cs | 572 ++++++++++++++++++ .../GatewayCleanupTaskTests.cs | 172 +++++- .../OpenClaw.SetupEngine.Tests.csproj | 4 + .../RunCleanupVisibilityProbe.ps1 | 24 + 6 files changed, 763 insertions(+), 33 deletions(-) create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/RunCleanupVisibilityProbe.ps1 diff --git a/docs/SETUP.md b/docs/SETUP.md index 5683ce828..4dfee5c0e 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -193,7 +193,7 @@ Your settings file at `%APPDATA%\OpenClawTray\settings.json` is not removed auto For the **Companion MSIX**, managed Gateway cleanup uses a current-user interactive scheduled task that survives Companion removal. Its worker, registration script and versioned `cleanup-host-.exe` are copied to `%USERPROFILE%\.openclaw-companion\cleanup\\`. The task runs at logon and every minute through a GUI-subsystem helper that starts Windows PowerShell with `CreateNoWindow`, preventing a desktop console from being created. The helper targets the Windows-provided .NET Framework 4.8 and forwards the worker's exit code, recording failures in `cleanup-host-error.txt` beside the worker. It does not depend on VBScript, the Companion's .NET 10 runtime, or an executable inside the removed package. Versioned helper paths allow an updated Companion to refresh the task without overwriting a running helper. -The worker checks Companion package identity, ownership sentinels and removal events, then removes only the recorded owned Gateway resources after confirmed Companion removal. Polling also retries pending cleanup and provides a second absence check at least 30 seconds after the first when removal-event evidence is unavailable. The exact one-minute interval is a cleanup-latency choice, not an AppX requirement; increasing it would only slow cleanup, not fix console creation. Opening an updated packaged Companion refreshes an existing owned cleanup task with the new action. A Gateway-only package update cannot refresh this Companion-owned task. +The worker checks Companion package identity, ownership sentinels and removal events, then removes only the recorded owned Gateway resources after confirmed Companion removal. Polling also retries pending cleanup and provides a second absence check at least 30 seconds after the first when removal-event evidence is unavailable. The exact one-minute interval is a cleanup-latency choice, not an AppX requirement; increasing it would only slow cleanup, not prevent a visible launch. Opening an updated packaged Companion refreshes an existing owned cleanup task with the new action. A Gateway-only package update cannot refresh this Companion-owned task. For the headless dev/WSL CLI uninstall (`--uninstall --dry-run`, then `--confirm-destructive`), see the [uninstall skill](../.agents/skills/uninstall/SKILL.md). For a **clean retest of native Gateway/MXC, Companion, and managed llama.cpp**, use its [hard-clean procedure](../.agents/skills/uninstall/HARD-CLEAN.md). This inventories exact targets and requires confirmation before teardown, package removal, or residual-state deletion. Shared model caches, unrelated profiles, and WSL are preserved unless explicitly included. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index a3446cc86..be7e15223 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -81,6 +81,28 @@ shared thread-pool scheduling latency. ### Additional projects +Gateway cleanup task registration, refresh, quoting and failure propagation run +in the ordinary SetupEngine suite. The registration fixture replaces only the +persistence boundary and cannot register a real scheduled task. The two desktop +visibility tests use the existing integration opt-in because their positive +control deliberately displays a classic console. Run them on an interactive +Windows desktop without concurrent desktop automation: + +```powershell +$env:OPENCLAW_REPO_ROOT = (Get-Location).Path +$env:OPENCLAW_RUN_INTEGRATION = '1' +dotnet test .\tests\OpenClaw.SetupEngine.Tests\OpenClaw.SetupEngine.Tests.csproj --no-restore --filter 'FullyQualifiedName~GatewayCleanupTaskTests' +``` + +The visibility contract is no on-screen visible window or taskbar entry, not +absence of an HWND. WinEvent observations and periodic window sampling cover +startup through worker exit; native UI Automation checks the shell taskbar, +including visible Zebar bars when present. The visible-console positive control +must be detected by both window observers and actual taskbar enumeration. +Unavailable shell/provider access fails the selected proof instead of treating +a window-style heuristic as taskbar evidence. Sampling gaps remain a limit of +the measurement, not proof that an arbitrarily short transient is impossible. + The chat composer's real keyboard regression is opt-in because it uses Windows `SendInput` on a visible, focused proof window. Run it only on an isolated interactive desktop with no concurrent computer-use or human input, Caps Lock diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs new file mode 100644 index 000000000..20bb69a32 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +// Native UIA is required for the modern shell taskbar. Framework UIA2 only +// exposed two container elements on the proof host, hiding all app buttons. +// Unused declarations preserve native vtable slots without extra dependencies. +[ComImport, Guid("30cbe57d-d9d0-452a-ab13-7ac5ac4825ee"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface CleanupUia +{ + void CompareElements(); + void CompareRuntimeIds(); + CleanupUiaElement GetRootElement(); + CleanupUiaElement ElementFromHandle(IntPtr hwnd); + void ElementFromPoint(); + void GetFocusedElement(); + void GetRootElementBuildCache(); + void ElementFromHandleBuildCache(); + void ElementFromPointBuildCache(); + void GetFocusedElementBuildCache(); + void CreateTreeWalker(); + void GetControlViewWalker(); + void GetContentViewWalker(); + void GetRawViewWalker(); + void GetRawViewCondition(); + void GetControlViewCondition(); + void GetContentViewCondition(); + CleanupUiaCacheRequest CreateCacheRequest(); + [return: MarshalAs(UnmanagedType.IUnknown)] object CreateTrueCondition(); +} + +[ComImport, Guid("d22108aa-8ac5-49a5-837b-37bbb3d7591e"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface CleanupUiaElement +{ + void SetFocus(); + [return: MarshalAs(UnmanagedType.SafeArray, SafeArraySubType = VarEnum.VT_I4)] int[] GetRuntimeId(); + void FindFirst(); + CleanupUiaElements FindAll(int scope, [MarshalAs(UnmanagedType.IUnknown)] object condition); + void FindFirstBuildCache(); + CleanupUiaElements FindAllBuildCache(int scope, [MarshalAs(UnmanagedType.IUnknown)] object condition, + CleanupUiaCacheRequest cache); + void BuildUpdatedCache(); + [return: MarshalAs(UnmanagedType.Struct)] object GetCurrentPropertyValue(int property); + void GetCurrentPropertyValueEx(); + [return: MarshalAs(UnmanagedType.Struct)] object GetCachedPropertyValue(int property); +} + +[ComImport, Guid("b32a92b5-bc25-4078-9c08-d7ee95c48e03"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface CleanupUiaCacheRequest +{ + void AddProperty(int property); + void AddPattern(); + void Clone(); + void GetTreeScope(); + void SetTreeScope(); + void GetTreeFilter(); + void SetTreeFilter(); + void GetAutomationElementMode(); + void SetAutomationElementMode(int mode); +} + +[ComImport, Guid("14314595-b4bc-4055-95f2-58f2e42c9855"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface CleanupUiaElements +{ + int GetLength(); + CleanupUiaElement GetElement(int index); +} + +public sealed class CleanupVisibilityProbe : IDisposable +{ + private const int WindowSampleIntervalMs = 15; + private const int ProcessTreeIntervalMs = 100; + private const int TaskbarCoalesceIntervalMs = 250; + public sealed class Observation + { + public string Source; + public int Pid; + public long Handle; + public string ClassName; + public string Match; + public bool OnScreen; + public bool ShowEvent; + public double ElapsedMs; + } + + public sealed class Result + { + public int ExitCode; + public int WindowSamples; + public int TaskbarSamples; + public int TaskbarsFound; + public int MaximumTaskbarElements; + public double MaximumWindowSampleGapMs; + public double MaximumTaskbarSampleGapMs; + public double ElapsedMs; + public double ObserverCpuMs; + public string StartedAtUtc; + public string CompletedAtUtc; + public int[] OwnedProcessIds; + public string[] Errors; + public Observation[] Observations; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Startup + { + public int cb; + public string reserved, desktop, title; + public int x, y, width, height, xc, yc, fill, flags; + public short show, reservedSize; + public IntPtr reservedPtr, stdin, stdout, stderr; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo { public IntPtr process, thread; public int pid, tid; } + [StructLayout(LayoutKind.Sequential)] + private struct Message + { + public IntPtr hwnd; + public uint msg; + public IntPtr wp, lp; + public uint time; + public int x, y; + public uint extra; + } + [StructLayout(LayoutKind.Sequential)] + private struct Rect { public int left, top, right, bottom; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry + { + public uint size, usage, pid; + public UIntPtr heap; + public uint module, threads, parent; + public int priority; + public uint flags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string name; + } + private delegate bool WindowCallback(IntPtr hwnd, IntPtr state); + private delegate void EventCallback(IntPtr hook, uint ev, IntPtr hwnd, int obj, int child, uint thread, uint time); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess(string app, StringBuilder command, IntPtr pa, IntPtr ta, bool inherit, + int flags, IntPtr env, string directory, ref Startup startup, out ProcessInfo info); + [DllImport("kernel32.dll")] private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetProcessTimes( + IntPtr process, out long creation, out long exit, out long kernel, out long user); + [DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern bool Process32First(IntPtr snapshot, ref ProcessEntry entry); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern bool Process32Next(IntPtr snapshot, ref ProcessEntry entry); + [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] private static extern bool EnumWindows(WindowCallback callback, IntPtr state); + [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr window, uint flags); + [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr window, out Rect rect); + [DllImport("user32.dll")] private static extern IntPtr MonitorFromRect(ref Rect rect, uint flags); + [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr hwnd, StringBuilder text, int length); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int length); + [DllImport("user32.dll")] private static extern IntPtr SetWinEventHook(uint min, uint max, IntPtr module, EventCallback callback, uint pid, uint tid, uint flags); + [DllImport("user32.dll")] private static extern bool UnhookWinEvent(IntPtr hook); + [DllImport("user32.dll")] private static extern bool GetMessage(out Message msg, IntPtr hwnd, uint min, uint max); + [DllImport("user32.dll")] private static extern bool PostThreadMessage(uint tid, uint msg, IntPtr wp, IntPtr lp); + + private readonly ConcurrentDictionary owned = new ConcurrentDictionary(); + private readonly ConcurrentDictionary ownedWindows = new ConcurrentDictionary(); + private readonly ConcurrentDictionary consoleOwners = new ConcurrentDictionary(); + private readonly ConcurrentDictionary consoleWindows = new ConcurrentDictionary(); + private readonly ConcurrentDictionary titles = new ConcurrentDictionary(StringComparer.Ordinal); + private readonly ConcurrentQueue events = new ConcurrentQueue(); + private readonly ConcurrentQueue errors = new ConcurrentQueue(); + private readonly Stopwatch clock = Stopwatch.StartNew(); + private readonly TimeSpan initialCpu = Process.GetCurrentProcess().TotalProcessorTime; + private readonly ManualResetEvent stop = new ManualResetEvent(false); + private readonly ManualResetEvent eventReady = new ManualResetEvent(false); + private readonly ManualResetEvent taskbarReady = new ManualResetEvent(false); + private readonly AutoResetEvent taskbarChanged = new AutoResetEvent(false); + private readonly Thread eventThread, taskbarThread; + private readonly string token; + private EventCallback eventCallback; + private uint eventThreadId; + private int taskbarSamples, taskbarsFound; + private int maximumTaskbarElements; + private int windowSamples; + private double maximumTaskbarGap; + + public CleanupVisibilityProbe(string token) + { + this.token = token; + EnumWindows((hwnd, state) => { + var name = ClassName(hwnd); + if (name == "ConsoleWindowClass" || name == "CASCADIA_HOSTING_WINDOW_CLASS") + consoleWindows.TryAdd(hwnd.ToInt64(), true); + return true; + }, IntPtr.Zero); + eventThread = new Thread(WatchEvents) { IsBackground = true }; + taskbarThread = new Thread(WatchTaskbar) { IsBackground = true }; + taskbarThread.SetApartmentState(ApartmentState.MTA); + eventThread.Start(); + taskbarThread.Start(); + if (!eventReady.WaitOne(10000) || !taskbarReady.WaitOne(10000)) + throw new TimeoutException("Visibility observers did not initialize."); + ThrowObserverErrors(); + } + + private void ThrowObserverErrors() + { + if (!errors.IsEmpty) throw new InvalidOperationException(string.Join("\n", errors.ToArray())); + } + + public Result Snapshot() + { + return new Result { + WindowSamples = windowSamples, TaskbarSamples = taskbarSamples, TaskbarsFound = taskbarsFound, + MaximumTaskbarElements = maximumTaskbarElements, + ElapsedMs = clock.Elapsed.TotalMilliseconds, + ObserverCpuMs = (Process.GetCurrentProcess().TotalProcessorTime - initialCpu).TotalMilliseconds, + MaximumTaskbarSampleGapMs = maximumTaskbarGap, Errors = errors.ToArray(), + StartedAtUtc = startedAtUtc.ToString("o"), CompletedAtUtc = DateTime.UtcNow.ToString("o"), + OwnedProcessIds = GetOwnedProcessIds(), + Observations = Array.FindAll(events.ToArray(), e => e.Source == "TaskbarUIA" || IsOwnedWindow(e)) + }; + } + + private static string ClassName(IntPtr hwnd) + { + var text = new StringBuilder(256); + GetClassName(hwnd, text, text.Capacity); + return text.ToString(); + } + + private bool IsOwnedWindow(Observation observation) + { + return owned.ContainsKey(observation.Pid) || ownedWindows.ContainsKey(observation.Handle); + } + + private readonly DateTime startedAtUtc = DateTime.UtcNow; + + private bool StartedDuringProbe(int pid) + { + // StartTime in Windows PowerShell's Framework requests broader access. + var process = OpenProcess(0x1000, false, pid); // PROCESS_QUERY_LIMITED_INFORMATION + if (process == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == 87) return false; // The snapshot entry exited. + throw new System.ComponentModel.Win32Exception(error, + "Cannot inspect candidate process " + pid + " with limited query access (Win32 " + error + ")."); + } + try + { + long creation, exit, kernel, user; + if (!GetProcessTimes(process, out creation, out exit, out kernel, out user)) + { + int error = Marshal.GetLastWin32Error(); + throw new System.ComponentModel.Win32Exception(error, + "Cannot read creation time for candidate process " + pid + " (Win32 " + error + ")."); + } + return DateTime.FromFileTimeUtc(creation) >= startedAtUtc; + } + finally { CloseHandle(process); } + } + + private int[] GetOwnedProcessIds() + { + var ids = new HashSet(owned.Keys); + ids.UnionWith(consoleOwners.Keys); + return new List(ids).ToArray(); + } + + private void ObserveWindow(string source, IntPtr hwnd, bool showEvent = false) + { + uint pid; + GetWindowThreadProcessId(hwnd, out pid); + bool isOwned = owned.ContainsKey((int)pid) || ownedWindows.ContainsKey(hwnd.ToInt64()); + if (source == "WindowSample" && !isOwned && !consoleWindows.ContainsKey(hwnd.ToInt64())) return; + string className = ClassName(hwnd); + bool isConsole = className == "ConsoleWindowClass" || className == "CASCADIA_HOSTING_WINDOW_CLASS"; + if (isConsole) consoleWindows.TryAdd(hwnd.ToInt64(), true); + if (source == "WindowSample" && !isOwned && !isConsole) return; + var title = new StringBuilder(1024); + if (isOwned || isConsole) + { + GetWindowText(hwnd, title, title.Capacity); + if (title.ToString().IndexOf(token, StringComparison.Ordinal) >= 0) + { + titles.TryAdd(title.ToString(), true); + // Terminal can multiplex unrelated windows in the same process. + ownedWindows.TryAdd(hwnd.ToInt64(), true); + if (className == "ConsoleWindowClass") consoleOwners.TryAdd((int)pid, true); + isOwned = true; + } + } + if (source == "WindowSample" && !isOwned) return; + // A SHOW event can be delivered after a fast hide. Preserve the event + // instead of losing exactly the transient startup flash being tested. + if (showEvent && GetAncestor(hwnd, 2) != hwnd) return; + if (!showEvent && !IsWindowVisible(hwnd)) return; + Rect rect; + bool onScreen = GetWindowRect(hwnd, out rect) && rect.right > rect.left && rect.bottom > rect.top && + MonitorFromRect(ref rect, 0) != IntPtr.Zero; + events.Enqueue(new Observation { + Source = source, Pid = (int)pid, Handle = hwnd.ToInt64(), ClassName = className, + OnScreen = onScreen, ShowEvent = showEvent, ElapsedMs = clock.Elapsed.TotalMilliseconds + }); + } + + private void WatchEvents() + { + eventThreadId = GetCurrentThreadId(); + eventCallback = (hook, ev, hwnd, obj, child, thread, time) => { + if (hwnd != IntPtr.Zero && obj == 0 && child == 0 && (ev == 0x8000 || ev == 0x8002)) + ObserveWindow("WinEvent", hwnd, ev == 0x8002); + }; + var handle = SetWinEventHook(0x8000, 0x8003, IntPtr.Zero, eventCallback, 0, 0, 0); + if (handle == IntPtr.Zero) errors.Enqueue("SetWinEventHook failed."); + eventReady.Set(); + try + { + if (handle != IntPtr.Zero) + { + Message message; + while (GetMessage(out message, IntPtr.Zero, 0, 0)) { } + } + } + finally { if (handle != IntPtr.Zero) UnhookWinEvent(handle); } + } + + private void WatchTaskbar() + { + var taskbars = new List(); + CleanupUia automation = null; + CleanupUiaCacheRequest cache = null; + object condition = null; + try + { + automation = (CleanupUia)Activator.CreateInstance(Type.GetTypeFromCLSID( + new Guid("e22ad333-b25f-460c-83d0-0581107395c9"), true)); + condition = automation.CreateTrueCondition(); + cache = automation.CreateCacheRequest(); + cache.SetAutomationElementMode(0); + foreach (int property in new[] { 30002, 30005, 30011, 30012, 30013, 30022, 30026, 30092, 30093 }) + cache.AddProperty(property); + EnumWindows((hwnd, state) => { + var name = ClassName(hwnd); + bool customBar = false; + if (name == "Tauri Window" && IsWindowVisible(hwnd)) + { + uint pid; + GetWindowThreadProcessId(hwnd, out pid); + using (var process = Process.GetProcessById((int)pid)) + customBar = process.ProcessName.Equals("zebar", StringComparison.OrdinalIgnoreCase); + } + if (name == "Shell_TrayWnd" || name == "Shell_SecondaryTrayWnd" || customBar) + taskbars.Add(automation.ElementFromHandle(hwnd)); + return true; + }, IntPtr.Zero); + taskbarsFound = taskbars.Count; + if (taskbars.Count == 0) throw new InvalidOperationException("No interactive shell taskbar was found."); + bool baselineComplete = false; + double previous = clock.Elapsed.TotalMilliseconds; + do + { + foreach (var taskbar in taskbars) + { + var elements = taskbar.FindAllBuildCache(4, condition, cache); + try + { + int count = elements.GetLength(); + maximumTaskbarElements = Math.Max(maximumTaskbarElements, count); + for (int index = 0; index < count; index++) + { + var element = elements.GetElement(index); + try + { + string name = Convert.ToString(element.GetCachedPropertyValue(30005)); + string detail = name + "\n" + element.GetCachedPropertyValue(30013) + "\n" + + element.GetCachedPropertyValue(30026) + "\n" + element.GetCachedPropertyValue(30011) + + "\n" + element.GetCachedPropertyValue(30092) + "\n" + element.GetCachedPropertyValue(30093); + int pid = Convert.ToInt32(element.GetCachedPropertyValue(30002)); + bool tokenMatched = detail.IndexOf(token, StringComparison.Ordinal) >= 0; + bool titleMatched = titles.ContainsKey(name); + if (baselineComplete && (owned.ContainsKey(pid) || + tokenMatched || titleMatched)) + { + events.Enqueue(new Observation { + Source = "TaskbarUIA", Pid = pid, + Match = tokenMatched ? "UniqueToken" : titleMatched ? "OwnedWindowTitle" : "OwnedPid", + ClassName = Convert.ToString(element.GetCachedPropertyValue(30012)), + OnScreen = !Convert.ToBoolean(element.GetCachedPropertyValue(30022)), + ElapsedMs = clock.Elapsed.TotalMilliseconds + }); + } + } + finally { Marshal.ReleaseComObject(element); } + } + } + finally { Marshal.ReleaseComObject(elements); } + } + double now = clock.Elapsed.TotalMilliseconds; + maximumTaskbarGap = Math.Max(maximumTaskbarGap, now - previous); + previous = now; + Interlocked.Increment(ref taskbarSamples); + baselineComplete = true; + taskbarReady.Set(); + // UIA provider traversals are expensive. Window/WinEvent sampling owns flash detection; + // taskbar snapshots are throttled even when shell structure events arrive in a burst. + if (stop.WaitOne(TaskbarCoalesceIntervalMs)) break; + taskbarChanged.WaitOne(TaskbarCoalesceIntervalMs); + } while (!stop.WaitOne(0)); + } + catch (Exception error) { errors.Enqueue("Taskbar UIA: " + error); taskbarReady.Set(); } + finally + { + foreach (var taskbar in taskbars) Marshal.ReleaseComObject(taskbar); + if (condition != null) Marshal.ReleaseComObject(condition); + if (cache != null) Marshal.ReleaseComObject(cache); + if (automation != null) Marshal.ReleaseComObject(automation); + } + } + + private void RefreshTree() + { + var snapshot = CreateToolhelp32Snapshot(2, 0); + if (snapshot == new IntPtr(-1)) throw new System.ComponentModel.Win32Exception(); + try + { + var entries = new List(); + var entry = new ProcessEntry { size = (uint)Marshal.SizeOf(typeof(ProcessEntry)) }; + if (!Process32First(snapshot, ref entry)) throw new System.ComponentModel.Win32Exception(); + do { entries.Add(entry); } while (Process32Next(snapshot, ref entry)); + bool changed; + do + { + changed = false; + foreach (var item in entries) + if (owned.ContainsKey((int)item.pid) || owned.ContainsKey((int)item.parent)) + { + if (string.Equals(item.name, "WindowsTerminal.exe", StringComparison.OrdinalIgnoreCase)) + continue; + // Parent PIDs can outlive their process and be reused by this probe. + if (!owned.ContainsKey((int)item.pid) && StartedDuringProbe((int)item.pid) && + owned.TryAdd((int)item.pid, true)) changed = true; + } + } while (changed); + } + finally { CloseHandle(snapshot); } + } + + public Result Run(string executable, string arguments, string directory, string readyName, string releaseName, + bool positiveControl, int timeoutSeconds, string externalPidFile, bool noWindowStarter) + { + var startup = new Startup { + cb = Marshal.SizeOf(typeof(Startup)), flags = positiveControl ? 1 : 0, show = 1, title = token + }; + ProcessInfo info; + // Register the root PID before resuming, so startup events remain attributable. + if (!CreateProcess(executable, new StringBuilder("\"" + executable + "\" " + arguments), IntPtr.Zero, + IntPtr.Zero, false, noWindowStarter ? 0x08000004 : 0x14, IntPtr.Zero, directory, ref startup, out info)) + throw new System.ComponentModel.Win32Exception(); + owned.TryAdd(info.pid, true); + int samples = 0, afterReady = 0, readyTaskbarSamples = 0; + long nextTreeRefresh = 0; + bool workerReady = false, released = false; + double previous = clock.Elapsed.TotalMilliseconds, maximumGap = 0; + using (var ready = EventWaitHandle.OpenExisting(readyName)) + using (var release = EventWaitHandle.OpenExisting(releaseName)) + using (var process = Process.GetProcessById(info.pid)) + { + try + { + ResumeThread(info.thread); + while (!process.HasExited) + { + ThrowObserverErrors(); + if (clock.ElapsedMilliseconds >= nextTreeRefresh) + { + RefreshTree(); + nextTreeRefresh = clock.ElapsedMilliseconds + ProcessTreeIntervalMs; + } + EnumWindows((hwnd, state) => { ObserveWindow("WindowSample", hwnd); return true; }, IntPtr.Zero); + samples++; + windowSamples = samples; + double now = clock.Elapsed.TotalMilliseconds; + maximumGap = Math.Max(maximumGap, now - previous); + previous = now; + if (!workerReady && ready.WaitOne(0)) + { + workerReady = true; + readyTaskbarSamples = taskbarSamples; + if (!string.IsNullOrEmpty(externalPidFile)) + { + int workerPid = int.Parse(System.IO.File.ReadAllText(externalPidFile), + System.Globalization.CultureInfo.InvariantCulture); + using (var worker = Process.GetProcessById(workerPid)) + if (!worker.ProcessName.Equals("powershell", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("External proof worker is not Windows PowerShell."); + owned.TryAdd(workerPid, true); + } + } + if (workerReady) + { + afterReady++; + var recorded = events.ToArray(); + bool eventSeen = Array.Exists(recorded, e => e.Source == "WinEvent" && e.OnScreen && IsOwnedWindow(e)); + bool sampleSeen = Array.Exists(recorded, e => e.Source == "WindowSample" && e.OnScreen && IsOwnedWindow(e)); + bool buttonSeen = Array.Exists(recorded, e => e.Source == "TaskbarUIA" && e.OnScreen); + if (!released && afterReady >= 20 && taskbarSamples >= readyTaskbarSamples + 2 && + (!positiveControl || (eventSeen && sampleSeen && buttonSeen))) + { + release.Set(); + released = true; + } + } + if (clock.Elapsed > TimeSpan.FromSeconds(timeoutSeconds)) + throw new TimeoutException("Launch visibility probe timed out; positive controls require both window observers and taskbar UIA."); + stop.WaitOne(WindowSampleIntervalMs); + } + process.WaitForExit(); + if (!workerReady) throw new InvalidOperationException("Worker did not signal ready."); + uint exitCode; + if (!GetExitCodeProcess(info.process, out exitCode)) + throw new System.ComponentModel.Win32Exception(); + var result = new Result { + ExitCode = unchecked((int)exitCode), WindowSamples = samples, TaskbarSamples = taskbarSamples, + TaskbarsFound = taskbarsFound, MaximumWindowSampleGapMs = maximumGap, + MaximumTaskbarElements = maximumTaskbarElements, + MaximumTaskbarSampleGapMs = maximumTaskbarGap, + ElapsedMs = clock.Elapsed.TotalMilliseconds, + ObserverCpuMs = (Process.GetCurrentProcess().TotalProcessorTime - initialCpu).TotalMilliseconds + }; + Dispose(); + result.StartedAtUtc = startedAtUtc.ToString("o"); + result.CompletedAtUtc = DateTime.UtcNow.ToString("o"); + result.OwnedProcessIds = GetOwnedProcessIds(); + result.Errors = errors.ToArray(); + result.Observations = Array.FindAll(events.ToArray(), e => e.Source == "TaskbarUIA" || IsOwnedWindow(e)); + ThrowObserverErrors(); + return result; + } + finally + { + release.Set(); + if (!process.HasExited) + { + if (!process.WaitForExit(5000)) { process.Kill(); process.WaitForExit(); } + } + CloseHandle(info.thread); + CloseHandle(info.process); + } + } + } + + private bool disposed; + public void Dispose() + { + if (disposed) return; + disposed = true; + stop.Set(); + PostThreadMessage(eventThreadId, 0x12, IntPtr.Zero, IntPtr.Zero); + if (!eventThread.Join(10000) || !taskbarThread.Join(10000)) + throw new TimeoutException("A visibility observer did not stop."); + stop.Dispose(); + eventReady.Dispose(); + taskbarReady.Dispose(); + taskbarChanged.Dispose(); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs index 28030a4d7..888ad4567 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs @@ -1,7 +1,7 @@ using System.Diagnostics; -using System.Reflection.PortableExecutable; using System.Text.Json; using OpenClaw.TestSupport; +using OpenClaw.Tray.IntegrationTests; namespace OpenClaw.SetupEngine.Tests; @@ -12,37 +12,46 @@ public sealed class GatewayCleanupTaskTests [Theory] [InlineData(0)] [InlineData(37)] - public async Task RegisteredAction_RunsWorkerWithoutDesktopConsole_AndPreservesExitCode(int exitCode) + public Task RegisteredAction_PreservesExitCodeAndDiagnostics(int exitCode) => + VerifyRegisteredActionAsync(exitCode, observeVisibility: false); + + [IntegrationFact] + public async Task RegisteredAction_StaysInvisibleWithoutTaskbarButton() + { + foreach (var exitCode in new[] { 0, 37 }) + await VerifyRegisteredActionAsync(exitCode, observeVisibility: true); + } + + private static async Task VerifyRegisteredActionAsync(int exitCode, bool observeVisibility) { Assert.True(OperatingSystem.IsWindows(), "Cleanup launch proof requires native Windows."); using var temp = new TempDirectory(); - var directory = Directory.CreateDirectory(temp.Combine("cleanup with spaces & apostrophe's")).FullName; + var directory = Directory.CreateDirectory(temp.Combine("cleanup %PATH% & apostrophe's \u00e9\u4e2d")).FullName; var hostPath = await GatewayPackageCleanup.DeployAsync(directory); // A repeated startup must work while the current helper is locked for execution. using (var runningImage = new FileStream(hostPath, FileMode.Open, FileAccess.Read, FileShare.Read)) Assert.Equal(hostPath, await GatewayPackageCleanup.DeployAsync(directory)); - await File.WriteAllTextAsync(Path.Combine(directory, "host-path.txt"), hostPath); + await File.WriteAllTextAsync(Path.Combine(directory, "host-path.txt"), hostPath, new System.Text.UTF8Encoding(true)); var workerPath = Path.Combine(directory, "cleanup.ps1"); + var token = "OpenClaw-visibility-" + Guid.NewGuid().ToString("N"); + using var ready = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-ready"); + using var release = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-release"); await File.WriteAllTextAsync(workerPath, $$""" param([string]$Family) $ErrorActionPreference = 'Stop' - Add-Type @' - using System; - using System.Runtime.InteropServices; - public static class ConsoleProbe { - [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); - [DllImport("user32.dll")] public static extern IntPtr GetParent(IntPtr window); - [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window); - } - '@ - $window = [ConsoleProbe]::GetConsoleWindow() + if ({{(observeVisibility ? "$true" : "$false")}}) { [Console]::Title = '{{token}}' } [pscustomobject]@{ Family = $Family - HasConsole = ($window -ne [IntPtr]::Zero) - HasDesktopConsole = ($window -ne [IntPtr]::Zero -and [ConsoleProbe]::GetParent($window) -ne [IntPtr]::new(-3)) - Visible = [ConsoleProbe]::IsWindowVisible($window) } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'result.json') -Encoding UTF8 - if ({{exitCode}} -ne 0) { [Console]::Error.WriteLine('synthetic worker failure') } + $ready = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-ready') + $release = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-release') + try { + if ({{(observeVisibility ? "$true" : "$false")}}) { + $ready.Set() | Out-Null + if (-not $release.WaitOne(45000)) { throw 'Visibility controller did not release the worker.' } + } + } finally { $ready.Dispose(); $release.Dispose() } + if ({{exitCode}} -ne 0) { Write-Error 'synthetic worker failure' -ErrorAction Continue } exit {{exitCode}} """); var harnessPath = Path.Combine(directory, "register-test.ps1"); @@ -81,29 +90,127 @@ await File.WriteAllTextAsync(harnessPath, """ Assert.Equal(2, value.GetProperty("TriggerCount").GetInt32()); Assert.True(value.GetProperty("ReplacesExisting").GetBoolean()); - // Check the actual scheduler entrypoint, not just the console state after PowerShell startup. - // A console-subsystem action can flash and detach before the worker can sample it. - using (var binary = File.OpenRead(value.GetProperty("Execute").GetString()!)) - using (var pe = new PEReader(binary)) - Assert.Equal(Subsystem.WindowsGui, pe.PEHeaders.PEHeader!.Subsystem); - - // Do not use CREATE_NO_WINDOW here: that would mask a regression to direct powershell.exe. var launch = new ProcessStartInfo(value.GetProperty("Execute").GetString()!, value.GetProperty("Arguments").GetString()!); - Assert.Equal(exitCode, await RunAsync(launch, createNoWindow: false)); + if (observeVisibility) + { + using var visibility = await ObserveAsync(launch, directory, token, positiveControl: false); + Assert.Equal(exitCode, visibility.RootElement.GetProperty("ExitCode").GetInt32()); + Assert.Empty(visibility.RootElement.GetProperty("Errors").EnumerateArray()); + Assert.DoesNotContain(visibility.RootElement.GetProperty("Observations").EnumerateArray(), + item => item.GetProperty("OnScreen").GetBoolean()); + Assert.True(visibility.RootElement.GetProperty("WindowSamples").GetInt32() >= 20); + Assert.True(visibility.RootElement.GetProperty("TaskbarSamples").GetInt32() >= 2); + } + else + { + Assert.Equal(exitCode, await RunAsync(launch, createNoWindow: false)); + } using var result = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(directory, "result.json"))); Assert.Equal("Fixture_123", result.RootElement.GetProperty("Family").GetString()); - Assert.False(result.RootElement.GetProperty("HasConsole").GetBoolean()); - Assert.False(result.RootElement.GetProperty("HasDesktopConsole").GetBoolean(), - "The cleanup worker must not allocate a desktop console, even one hidden after startup."); - Assert.False(result.RootElement.GetProperty("Visible").GetBoolean()); var errorPath = Path.Combine(directory, "cleanup-host-error.txt"); if (exitCode != 0) - Assert.Contains("synthetic worker failure", await File.ReadAllTextAsync(errorPath)); + { + var error = await File.ReadAllTextAsync(errorPath); + // Windows PowerShell wraps rendered errors at the host's line width. + var message = System.Text.RegularExpressions.Regex.Replace(error, @"\s+", " "); + Assert.True(message.Contains("synthetic worker failure", StringComparison.Ordinal), error); + } else Assert.False(File.Exists(errorPath)); } + [IntegrationFact] + public async Task VisibilityProbe_VisibleConsole_IsDetectedByEventsSamplesAndTaskbar() + { + using var temp = new TempDirectory(); + var token = "OpenClaw-positive-" + Guid.NewGuid().ToString("N"); + using var ready = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-ready"); + using var release = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-release"); + var worker = temp.Combine("positive.ps1"); + await File.WriteAllTextAsync(worker, $$""" + [Console]::Title = '{{token}}' + $ready = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-ready') + $release = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-release') + try { + $ready.Set() | Out-Null + if (-not $release.WaitOne(45000)) { throw 'Visibility positive control was not detected.' } + } finally { $ready.Dispose(); $release.Dispose() } + """); + var powershell = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var launch = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "conhost.exe"), + "\"" + powershell + "\" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"" + worker + "\""); + using var visibility = await ObserveAsync(launch, temp.Path, token, positiveControl: true); + Assert.Equal(0, visibility.RootElement.GetProperty("ExitCode").GetInt32()); + var observations = visibility.RootElement.GetProperty("Observations").EnumerateArray().ToArray(); + foreach (var source in new[] { "WinEvent", "WindowSample" }) + Assert.Contains(observations, item => item.GetProperty("Source").GetString() == source && + item.GetProperty("OnScreen").GetBoolean()); + Assert.Contains(observations, item => item.GetProperty("Source").GetString() == "TaskbarUIA" && + item.GetProperty("OnScreen").GetBoolean()); + } + + private static async Task ObserveAsync(ProcessStartInfo launch, string directory, string token, + bool positiveControl) + { + var output = Path.Combine(directory, "visibility.json"); + var monitor = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe")); + foreach (var argument in new[] { + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", + Path.Combine(AppContext.BaseDirectory, "RunCleanupVisibilityProbe.ps1"), + "-Executable", launch.FileName, + "-ArgumentsBase64", Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(launch.Arguments)), + "-Directory", directory, "-Token", token, "-ReadyName", @"Local\" + token + "-ready", + "-ReleaseName", @"Local\" + token + "-release", "-ResultPath", output }) + monitor.ArgumentList.Add(argument); + if (positiveControl) monitor.ArgumentList.Add("-PositiveControl"); + Assert.Equal(0, await RunAsync(monitor, createNoWindow: true)); + return JsonDocument.Parse(await File.ReadAllTextAsync(output)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Launcher_MissingOrTerminatingWorker_ReportsFailure(bool missing) + { + using var temp = new TempDirectory(); + var launcher = await GatewayPackageCleanup.DeployAsync(temp.Path); + var worker = Path.Combine(temp.Path, "cleanup.ps1"); + if (missing) File.Delete(worker); + else await File.WriteAllTextAsync(worker, "throw 'synthetic terminating cleanup failure'"); + var start = new ProcessStartInfo(launcher, "Fixture_123"); + Assert.NotEqual(0, await RunAsync(start, createNoWindow: false)); + Assert.False(string.IsNullOrWhiteSpace(await File.ReadAllTextAsync(temp.Combine("cleanup-host-error.txt")))); + } + + [Fact] + public async Task Registration_MissingHelper_RejectsBeforeTaskPersistence() + { + using var temp = new TempDirectory(); + var launcher = await GatewayPackageCleanup.DeployAsync(temp.Path); + File.Delete(launcher); + await File.WriteAllTextAsync(temp.Combine("blocked.ps1"), """ + $ErrorActionPreference = 'Stop' + Import-Module ScheduledTasks -ErrorAction Stop + Remove-Item Function:\Register-ScheduledTask -ErrorAction Stop + Set-Item Function:\Register-ScheduledTask -Options Constant -Value { throw 'Persistence must not run.' } + try { + & (Join-Path $PSScriptRoot 'cleanup-task.ps1') -TaskName 'Never registered' ` + -HostPath (Join-Path $PSScriptRoot 'missing-helper.exe') -Family 'Fixture_123' + throw 'Expected missing helper rejection.' + } catch { + if ($_.Exception.Message -notlike '*windowless cleanup host is missing*') { throw } + } + """); + var start = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe")); + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", temp.Combine("blocked.ps1") }) + start.ArgumentList.Add(argument); + Assert.Equal(0, await RunAsync(start, createNoWindow: true)); + } + private static async Task RunAsync(ProcessStartInfo start, bool createNoWindow) { start.UseShellExecute = false; @@ -118,7 +225,8 @@ private static async Task RunAsync(ProcessStartInfo start, bool createNoWin try { await process.WaitForExitAsync(timeout.Token); - Assert.Equal("", (await stderr).Trim()); + var error = (await stderr).Trim(); + Assert.True(error.Length == 0, error); await stdout; return process.ExitCode; } diff --git a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj index e0e59745f..a7f51b6e0 100644 --- a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj +++ b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj @@ -1,6 +1,10 @@ + + + + Date: Tue, 20 Oct 2026 14:14:28 -0700 Subject: [PATCH 3/9] test(setup): describe custom taskbars generically --- docs/TEST_COVERAGE.md | 2 +- .../OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index be7e15223..826614a71 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -97,7 +97,7 @@ dotnet test .\tests\OpenClaw.SetupEngine.Tests\OpenClaw.SetupEngine.Tests.csproj The visibility contract is no on-screen visible window or taskbar entry, not absence of an HWND. WinEvent observations and periodic window sampling cover startup through worker exit; native UI Automation checks the shell taskbar, -including visible Zebar bars when present. The visible-console positive control +including visible custom-shell surfaces when present. The visible-console positive control must be detected by both window observers and actual taskbar enumeration. Unavailable shell/provider access fails the selected proof instead of treating a window-style heuristic as taskbar evidence. Sampling gaps remain a limit of diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs index 20bb69a32..215561d51 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs @@ -347,14 +347,7 @@ private void WatchTaskbar() cache.AddProperty(property); EnumWindows((hwnd, state) => { var name = ClassName(hwnd); - bool customBar = false; - if (name == "Tauri Window" && IsWindowVisible(hwnd)) - { - uint pid; - GetWindowThreadProcessId(hwnd, out pid); - using (var process = Process.GetProcessById((int)pid)) - customBar = process.ProcessName.Equals("zebar", StringComparison.OrdinalIgnoreCase); - } + bool customBar = name == "Tauri Window" && IsWindowVisible(hwnd); if (name == "Shell_TrayWnd" || name == "Shell_SecondaryTrayWnd" || customBar) taskbars.Add(automation.ElementFromHandle(hwnd)); return true; From 4947736e6819c965107eb21a60cc0b9a6dbfb033 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Tue, 20 Oct 2026 14:30:15 -0700 Subject: [PATCH 4/9] test(setup): restrict custom taskbar snapshots to tool surfaces --- tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs index 215561d51..78c853574 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs @@ -155,6 +155,7 @@ private static extern bool CreateProcess(string app, StringBuilder command, IntP [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] private static extern bool EnumWindows(WindowCallback callback, IntPtr state); [DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll", EntryPoint = "GetWindowLongW")] private static extern int GetWindowLong(IntPtr window, int index); [DllImport("user32.dll")] private static extern IntPtr GetAncestor(IntPtr window, uint flags); [DllImport("user32.dll")] private static extern bool GetWindowRect(IntPtr window, out Rect rect); [DllImport("user32.dll")] private static extern IntPtr MonitorFromRect(ref Rect rect, uint flags); @@ -347,7 +348,10 @@ private void WatchTaskbar() cache.AddProperty(property); EnumWindows((hwnd, state) => { var name = ClassName(hwnd); - bool customBar = name == "Tauri Window" && IsWindowVisible(hwnd); + Rect bounds; + bool customBar = name == "Tauri Window" && IsWindowVisible(hwnd) && + (GetWindowLong(hwnd, -20) & 0x80) != 0 && // WS_EX_TOOLWINDOW, not an ordinary app window. + GetWindowRect(hwnd, out bounds) && bounds.right - bounds.left > 1 && bounds.bottom - bounds.top > 1; if (name == "Shell_TrayWnd" || name == "Shell_SecondaryTrayWnd" || customBar) taskbars.Add(automation.ElementFromHandle(hwnd)); return true; From 8826fb449d85c27f88e53446a3874e69aa955903 Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Thu, 22 Oct 2026 01:06:25 -0700 Subject: [PATCH 5/9] fix(setup): sign cleanup helper before embedding release resources --- .github/workflows/ci.yml | 106 +++++++++++++++++- docs/RELEASING.md | 21 ++++ scripts/Test-CleanupHostSignature.ps1 | 60 ++++++++++ scripts/Test-ReleaseExecutableSignatures.ps1 | 5 + scripts/test-ci-workflow-contract.ps1 | 15 ++- scripts/test-cleanup-host-signing.ps1 | 83 ++++++++++++++ .../OpenClaw.SetupEngine.csproj | 16 +++ 7 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 scripts/Test-CleanupHostSignature.ps1 create mode 100644 scripts/test-cleanup-host-signing.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bc31f1de..c110413db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -603,6 +603,10 @@ jobs: -TrxFileName OpenClaw.SetupEngine.Tests.trx -Runtime win-x64 + - name: Test cleanup helper signing boundary + shell: pwsh + run: .\scripts\test-cleanup-host-signing.ps1 + - name: Run Dev containment settings tests run: > dotnet test tests/OpenClaw.Tray.Tests @@ -996,13 +1000,78 @@ jobs: TestResults/E2E/ if-no-files-found: warn + sign-cleanup-host: + name: Sign embedded cleanup host before compilation + needs: [change-classification, metadata] + if: (startsWith(github.ref, 'refs/tags/v') || inputs.promotion_alpha != '') && needs.metadata.result == 'success' + runs-on: windows-latest + environment: release-signing + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ inputs.promotion_source_sha || github.sha }} + persist-credentials: false + + - *promotion-version-tag + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + - name: Build AnyCPU cleanup host + run: dotnet build src/OpenClaw.GatewayCleanupHost -c Release -o signing-cleanup-host + + - name: Disable NuGet source mapping for cleanup signing + shell: pwsh + run: | + if (Test-Path NuGet.Config) { Rename-Item NuGet.Config NuGet.Config.signing.bak } + + - name: Azure Login for Cleanup Signing + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign cleanup executable before embedding + uses: azure/artifact-signing-action@v2 + with: + endpoint: https://eus.codesigning.azure.net/ + signing-account-name: openclaw + certificate-profile-name: openclaw + files-folder: signing-cleanup-host + files-folder-filter: exe + files-folder-depth: 1 + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify signed cleanup input + shell: pwsh + run: > + .\scripts\Test-CleanupHostSignature.ps1 + -HostPath signing-cleanup-host\OpenClaw.GatewayCleanupHost.exe -RequireSigned + -SignerSubject "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US" + + - uses: actions/upload-artifact@v7 + with: + name: openclaw-signed-cleanup-host + path: signing-cleanup-host/OpenClaw.GatewayCleanupHost.exe + if-no-files-found: error + build-x64: name: x64 release publish smoke - needs: [change-classification, metadata] - if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && needs.change-classification.outputs.x64_release == 'true' }} + needs: [change-classification, metadata, sign-cleanup-host] + if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.sign-cleanup-host.result == 'success' || needs.sign-cleanup-host.result == 'skipped') && needs.change-classification.outputs.x64_release == 'true' }} runs-on: windows-latest env: OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} + RequireSignedCleanupHost: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.promotion_alpha != '' }} + OpenClawSignedCleanupHostPath: ${{ needs.sign-cleanup-host.result == 'success' && format('{0}\signed-cleanup-host\OpenClaw.GatewayCleanupHost.exe', github.workspace) || '' }} steps: - uses: actions/checkout@v7 @@ -1029,6 +1098,13 @@ jobs: - name: Restore WinUI Tray App run: dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + - name: Download signed cleanup host + if: needs.sign-cleanup-host.result == 'success' + uses: actions/download-artifact@v8 + with: + name: openclaw-signed-cleanup-host + path: signed-cleanup-host + - name: Publish WinUI Tray App run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r win-x64 --self-contained --no-restore -o publish -p:Version=$env:OPENCLAW_BUILD_VERSION -p:InformationalVersion=$env:OPENCLAW_BUILD_VERSION @@ -1058,11 +1134,13 @@ jobs: build-arm64: name: ARM64 release publish - needs: [change-classification, metadata] - if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && needs.change-classification.outputs.arm64_release == 'true' }} + needs: [change-classification, metadata, sign-cleanup-host] + if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.sign-cleanup-host.result == 'success' || needs.sign-cleanup-host.result == 'skipped') && needs.change-classification.outputs.arm64_release == 'true' }} runs-on: windows-11-arm env: OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} + RequireSignedCleanupHost: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.promotion_alpha != '' }} + OpenClawSignedCleanupHostPath: ${{ needs.sign-cleanup-host.result == 'success' && format('{0}\signed-cleanup-host\OpenClaw.GatewayCleanupHost.exe', github.workspace) || '' }} steps: - uses: actions/checkout@v7 @@ -1089,6 +1167,13 @@ jobs: - name: Restore WinUI Tray App run: dotnet restore src/OpenClaw.Tray.WinUI -r win-arm64 + - name: Download signed cleanup host + if: needs.sign-cleanup-host.result == 'success' + uses: actions/download-artifact@v8 + with: + name: openclaw-signed-cleanup-host + path: signed-cleanup-host + - name: Publish WinUI Tray App run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r win-arm64 --self-contained --no-restore -o publish -p:Version=$env:OPENCLAW_BUILD_VERSION -p:InformationalVersion=$env:OPENCLAW_BUILD_VERSION @@ -1120,12 +1205,14 @@ jobs: build-msix: name: MSIX artifacts (${{ matrix.architecture }}) - needs: [change-classification, metadata, reserve-msix-version] - if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped') && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} + needs: [change-classification, metadata, reserve-msix-version, sign-cleanup-host] + if: ${{ !cancelled() && needs.change-classification.result == 'success' && needs.metadata.result == 'success' && (needs.sign-cleanup-host.result == 'success' || needs.sign-cleanup-host.result == 'skipped') && (needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped') && (needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true') }} # Dev-signed packages publish on tag releases. Unsigned Store packages stay workflow-only. runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-latest' }} env: OPENCLAW_BUILD_VERSION: ${{ needs.metadata.outputs.semVer }} + RequireSignedCleanupHost: ${{ startsWith(github.ref, 'refs/tags/v') || inputs.promotion_alpha != '' }} + OpenClawSignedCleanupHostPath: ${{ needs.sign-cleanup-host.result == 'success' && format('{0}\signed-cleanup-host\OpenClaw.GatewayCleanupHost.exe', github.workspace) || '' }} DEV_MSIX_REVISION: ${{ github.run_number }} MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo || needs.metadata.outputs.msixVersionInfo }} MSIX_SOURCE_VERSION: ${{ needs.reserve-msix-version.outputs.sourceVersion || needs.metadata.outputs.msixSourceVersion }} @@ -1168,6 +1255,13 @@ jobs: $info | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$env:RUNNER_TEMP\openclaw-msix-version.json" -Encoding utf8 + - name: Download signed cleanup host + if: needs.sign-cleanup-host.result == 'success' + uses: actions/download-artifact@v8 + with: + name: openclaw-signed-cleanup-host + path: signed-cleanup-host + - name: Build and validate unsigned Store MSIX shell: pwsh run: > diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fd8201049..a9612f421 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -866,6 +866,7 @@ OpenClaw-owned binaries: - `OpenClaw.SetupEngine.dll` - `OpenClaw.Shared.dll` - `OpenClawTray.FunctionalUI.dll` +- `OpenClaw.GatewayCleanupHost.exe` embedded inside `OpenClaw.SetupEngine.dll` Third-party/runtime executables that must not be OpenClaw-signed: @@ -923,6 +924,25 @@ registration should have a federated credential for: ## How CI signs payload binaries +On release tags and alpha promotion builds, `sign-cleanup-host` first builds the +AnyCPU .NET Framework cleanup executable and signs it through the existing +protected `release-signing` environment. The x64, ARM64 and Store/Dev MSIX build +jobs download that same signed artifact before compiling SetupEngine. +`OpenClawSignedCleanupHostPath` selects those bytes instead of rebuilding the +helper through its project reference. `RequireSignedCleanupHost=true` rejects a +missing signing input; any supplied signing input must have a valid Authenticode +signature. Ordinary unsigned PR and local development builds keep the project +reference path. + +SetupEngine verifies the input before embedding and checks the actual compiled +resource afterward for a matching hash and valid signature. The release payload +verifier also extracts that resource and requires the expected OpenClaw signer. +Signing only the outer DLL or MSIX does not sign an embedded executable. Do not +post-sign a loose helper and assume the previously embedded bytes changed. +`scripts\test-cleanup-host-signing.ps1` exercises the real build with an existing +OS-signed fixture, unsigned development output, missing/unsigned signing inputs +and mismatched resource bytes, without creating certificates or changing trust. + The release workflow does not recursively sign every `.exe`. Instead it creates temporary signing input directories with hardlinks to only the OpenClaw-owned executables and DLLs from the x64 and ARM64 payloads, then runs Azure Artifact @@ -939,6 +959,7 @@ For release tags, the **Build and Test** workflow should run: - `change-classification` with the `full` result - `fast-validation` +- `sign-cleanup-host` before release payload compilation - `test` - `e2etests` shards: `setup-connect`, `revocation-recovery`, and `network-recovery` - `build` matrix entries shown by GitHub as `build (win-x64)` and `build (win-arm64)` diff --git a/scripts/Test-CleanupHostSignature.ps1 b/scripts/Test-CleanupHostSignature.ps1 new file mode 100644 index 000000000..5ce048a3c --- /dev/null +++ b/scripts/Test-CleanupHostSignature.ps1 @@ -0,0 +1,60 @@ +#requires -Version 7 +<# +.SYNOPSIS +Verifies the cleanup helper before embedding and checks the actual embedded bytes. +#> +[CmdletBinding()] +param( + [string]$HostPath, + [string]$AssemblyPath, + [switch]$RequireSigned, + [string]$SignerSubject +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if (!$HostPath -and !$AssemblyPath) { throw 'Specify HostPath or AssemblyPath.' } +$temporaryDirectory = $null +try { + $path = $HostPath + if ($AssemblyPath) { + $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("openclaw-cleanup-signature-" + [Guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $temporaryDirectory + $path = Join-Path $temporaryDirectory 'OpenClaw.GatewayCleanupHost.exe' + $stream = [IO.File]::OpenRead((Resolve-Path -LiteralPath $AssemblyPath).Path) + $pe = [Reflection.PortableExecutable.PEReader]::new($stream) + try { + $metadata = [Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe) + $found = $false + foreach ($handle in $metadata.ManifestResources) { + $resource = $metadata.GetManifestResource($handle) + if ($metadata.GetString($resource.Name) -cne 'OpenClaw.SetupEngine.GatewayCleanupHost.exe') { continue } + if (!$resource.Implementation.IsNil) { throw 'Cleanup helper must be embedded, not linked.' } + $block = $pe.GetSectionData($pe.PEHeaders.CorHeader.ResourcesDirectory.RelativeVirtualAddress) + $reader = $block.GetReader([int]$resource.Offset, $block.Length - [int]$resource.Offset) + $bytes = $reader.ReadBytes($reader.ReadInt32()) + [IO.File]::WriteAllBytes($path, $bytes) + $found = $true + break + } + if (!$found) { throw 'SetupEngine is missing its embedded cleanup helper.' } + } + finally { $pe.Dispose(); $stream.Dispose() } + if ($HostPath -and (Get-FileHash -LiteralPath $HostPath).Hash -cne (Get-FileHash -LiteralPath $path).Hash) { + throw 'Embedded cleanup helper differs from the signing input.' + } + } + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($RequireSigned -and $signature.Status -ne 'Valid') { + throw "Cleanup helper is not validly Authenticode-signed: $($signature.Status)." + } + if ($RequireSigned -and $SignerSubject -and + ![string]::Equals($signature.SignerCertificate.Subject, $SignerSubject, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Cleanup helper is not signed by the expected release signer.' + } + Write-Host "Cleanup helper verified: signature=$($signature.Status); sha256=$((Get-FileHash -LiteralPath $path).Hash); embedded=$([bool]$AssemblyPath)" +} +finally { + if ($temporaryDirectory) { [IO.Directory]::Delete($temporaryDirectory, $true) } +} +exit 0 diff --git a/scripts/Test-ReleaseExecutableSignatures.ps1 b/scripts/Test-ReleaseExecutableSignatures.ps1 index 357e2c3b1..31c691c48 100644 --- a/scripts/Test-ReleaseExecutableSignatures.ps1 +++ b/scripts/Test-ReleaseExecutableSignatures.ps1 @@ -149,4 +149,9 @@ if ($errors.Count -gt 0) { exit 1 } +& (Join-Path $PSScriptRoot 'Test-CleanupHostSignature.ps1') ` + -AssemblyPath (Join-Path $payloadRoot 'OpenClaw.SetupEngine.dll') ` + -RequireSigned:$RequireSignedOpenClaw -SignerSubject $OpenClawSignerSubject +if ($LASTEXITCODE -ne 0) { throw 'Embedded cleanup helper signing policy failed.' } + Write-Host "Release binary signing policy passed." -ForegroundColor Green diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 2aeb24d20..1d6c9f735 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -738,10 +738,21 @@ $releaseBuilds = [ordered]@{ Native = "Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime -SkipNativeLoadProbe" } } +function Assert-JobDependencies { + param([string]$Name, [string[]]$Expected) + $job = Get-JobBlock $Name + $match = [regex]::Match($job, '(?m)^ needs:\s*\[([^\]]+)\]') + if (!$match.Success) { throw "Missing dependency list for $Name." } + $actual = @($match.Groups[1].Value.Split(',') | ForEach-Object { $_.Trim() }) + if (Compare-Object $Expected $actual) { + throw "Unexpected dependencies for ${Name}: $($actual -join ', ')." + } +} + foreach ($build in $releaseBuilds.GetEnumerator()) { $job = Get-JobBlock $build.Key + Assert-JobDependencies -Name $build.Key -Expected @('change-classification', 'metadata', 'sign-cleanup-host') foreach ($token in @( - "needs: [change-classification, metadata]", "fetch-depth: 0", "needs.change-classification.outputs.$($build.Value.Output) == 'true'", "runs-on: $($build.Value.Runner)", @@ -759,8 +770,8 @@ foreach ($build in $releaseBuilds.GetEnumerator()) { } $buildMsixJob = Get-JobBlock "build-msix" +Assert-JobDependencies -Name 'build-msix' -Expected @('change-classification', 'metadata', 'reserve-msix-version', 'sign-cleanup-host') foreach ($token in @( - "needs: [change-classification, metadata, reserve-msix-version]", "needs.metadata.result == 'success'", "(needs.reserve-msix-version.result == 'success' || needs.reserve-msix-version.result == 'skipped')", "needs.change-classification.outputs.x64_release == 'true' || needs.change-classification.outputs.arm64_release == 'true'", diff --git a/scripts/test-cleanup-host-signing.ps1 b/scripts/test-cleanup-host-signing.ps1 new file mode 100644 index 000000000..bf3c25154 --- /dev/null +++ b/scripts/test-cleanup-host-signing.ps1 @@ -0,0 +1,83 @@ +#requires -Version 7 +<# +.SYNOPSIS +Exercises the real SetupEngine build's signed-resource boundary without signing or changing trust. +#> +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$project = Join-Path $root 'src\OpenClaw.SetupEngine\OpenClaw.SetupEngine.csproj' +$verify = Join-Path $PSScriptRoot 'Test-CleanupHostSignature.ps1' +$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("openclaw-cleanup-build-test-" + [Guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $temporaryDirectory + +function Invoke-TestBuild { + param([string]$Name, [string[]]$Properties, [string]$ExpectedError) + $output = Join-Path $temporaryDirectory $Name + $log = Join-Path $temporaryDirectory "$Name.log" + & dotnet build $project -c Release --no-restore "-p:OutDir=$output\" @Properties *> $log + $code = $LASTEXITCODE + if ($ExpectedError) { + if ($code -eq 0 -or !(Get-Content -LiteralPath $log -Raw).Contains($ExpectedError)) { + Get-Content -LiteralPath $log + throw "Expected build rejection was not observed: $Name." + } + Write-Host "PASS: $Name rejected." + } + else { + if ($code -ne 0) { Get-Content -LiteralPath $log; throw "Build failed: $Name." } + return (Join-Path $output 'OpenClaw.SetupEngine.dll') + } +} + +try { + # Use an existing OS-signed fixture. Never create a certificate or modify trust. + $signedFixture = Join-Path ([Environment]::GetFolderPath('System')) 'WindowsPowerShell\v1.0\powershell.exe' + if ((Get-AuthenticodeSignature -LiteralPath $signedFixture).Status -ne 'Valid') { + throw 'The Windows PowerShell signed fixture must have a valid signature.' + } + $unsignedAssembly = Invoke-TestBuild -Name unsigned-dev -Properties @( + '-p:RequireSignedCleanupHost=false', '-p:OpenClawSignedCleanupHostPath=') + & $verify -AssemblyPath $unsignedAssembly + Write-Host 'PASS: unsigned development build embeds a helper.' + + Invoke-TestBuild -Name missing-signing-input -Properties @( + '-p:RequireSignedCleanupHost=true', '-p:OpenClawSignedCleanupHostPath=') ` + -ExpectedError 'Signed releases require OpenClawSignedCleanupHostPath' + + # The deployed development image is the actual unsigned production helper. + $unsignedHost = Join-Path $temporaryDirectory 'unsigned-host.exe' + $assemblyStream = [IO.File]::OpenRead($unsignedAssembly) + $pe = [Reflection.PortableExecutable.PEReader]::new($assemblyStream) + try { + $metadata = [Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe) + foreach ($handle in $metadata.ManifestResources) { + $resource = $metadata.GetManifestResource($handle) + if ($metadata.GetString($resource.Name) -cne 'OpenClaw.SetupEngine.GatewayCleanupHost.exe') { continue } + $block = $pe.GetSectionData($pe.PEHeaders.CorHeader.ResourcesDirectory.RelativeVirtualAddress) + $reader = $block.GetReader([int]$resource.Offset, $block.Length - [int]$resource.Offset) + [IO.File]::WriteAllBytes($unsignedHost, $reader.ReadBytes($reader.ReadInt32())) + break + } + } + finally { $pe.Dispose(); $assemblyStream.Dispose() } + Invoke-TestBuild -Name unsigned-signing-input -Properties @( + '-p:RequireSignedCleanupHost=true', "-p:OpenClawSignedCleanupHostPath=$unsignedHost") ` + -ExpectedError 'not validly Authenticode-signed' + + $signedAssembly = Invoke-TestBuild -Name signed-input -Properties @( + '-p:RequireSignedCleanupHost=true', "-p:OpenClawSignedCleanupHostPath=$signedFixture") + & $verify -AssemblyPath $signedAssembly -HostPath $signedFixture -RequireSigned + Write-Host 'PASS: actual SetupEngine resource matches the signed input and retains its signature.' + + # Exit status is part of the verifier's observable contract, not a source marker. + & pwsh -NoProfile -File $verify -AssemblyPath $signedAssembly -HostPath $unsignedHost *> (Join-Path $temporaryDirectory 'mismatch.log') + if ($LASTEXITCODE -eq 0) { throw 'Verifier accepted a stale/different embedded helper.' } + Write-Host 'PASS: mismatched embedded helper rejected.' +} +finally { [IO.Directory]::Delete($temporaryDirectory, $true) } +Write-Host 'Cleanup signing regression: 5 cases passed.' +exit 0 diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj index 5ff1b7313..1ea1c2649 100644 --- a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -11,14 +11,30 @@ + + + + + + + + + + From d3a9bc46d10f4177dd9ad5d1b0fcfe32ffc315de Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Thu, 22 Oct 2026 01:11:44 -0700 Subject: [PATCH 6/9] fix(build): require signed cleanup resources by default in Release --- docs/RELEASING.md | 11 +++++++---- scripts/test-cleanup-host-signing.ps1 | 10 ++++++++-- src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj | 1 + 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index a9612f421..dd313b49b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -929,10 +929,13 @@ AnyCPU .NET Framework cleanup executable and signs it through the existing protected `release-signing` environment. The x64, ARM64 and Store/Dev MSIX build jobs download that same signed artifact before compiling SetupEngine. `OpenClawSignedCleanupHostPath` selects those bytes instead of rebuilding the -helper through its project reference. `RequireSignedCleanupHost=true` rejects a -missing signing input; any supplied signing input must have a valid Authenticode -signature. Ordinary unsigned PR and local development builds keep the project -reference path. +helper through its project reference. Non-dev Release builds default to +`RequireSignedCleanupHost=true` and reject a missing signing input; any supplied +signing input must have a valid Authenticode signature. Debug and Dev builds +retain the unsigned project-reference path. Non-published PR release smoke and +MSIX fixture builds explicitly set `RequireSignedCleanupHost=false`; the same +override is available for intentional unsigned local Release testing, not +release publication. SetupEngine verifies the input before embedding and checks the actual compiled resource afterward for a matching hash and valid signature. The release payload diff --git a/scripts/test-cleanup-host-signing.ps1 b/scripts/test-cleanup-host-signing.ps1 index bf3c25154..c629aa731 100644 --- a/scripts/test-cleanup-host-signing.ps1 +++ b/scripts/test-cleanup-host-signing.ps1 @@ -13,6 +13,8 @@ $project = Join-Path $root 'src\OpenClaw.SetupEngine\OpenClaw.SetupEngine.csproj $verify = Join-Path $PSScriptRoot 'Test-CleanupHostSignature.ps1' $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ("openclaw-cleanup-build-test-" + [Guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $temporaryDirectory +$originalSigningRequirement = $env:RequireSignedCleanupHost +Remove-Item Env:RequireSignedCleanupHost -ErrorAction SilentlyContinue function Invoke-TestBuild { param([string]$Name, [string[]]$Properties, [string]$ExpectedError) @@ -45,7 +47,7 @@ try { Write-Host 'PASS: unsigned development build embeds a helper.' Invoke-TestBuild -Name missing-signing-input -Properties @( - '-p:RequireSignedCleanupHost=true', '-p:OpenClawSignedCleanupHostPath=') ` + '-p:OpenClawSignedCleanupHostPath=') ` -ExpectedError 'Signed releases require OpenClawSignedCleanupHostPath' # The deployed development image is the actual unsigned production helper. @@ -78,6 +80,10 @@ try { if ($LASTEXITCODE -eq 0) { throw 'Verifier accepted a stale/different embedded helper.' } Write-Host 'PASS: mismatched embedded helper rejected.' } -finally { [IO.Directory]::Delete($temporaryDirectory, $true) } +finally { + [IO.Directory]::Delete($temporaryDirectory, $true) + if ($null -eq $originalSigningRequirement) { Remove-Item Env:RequireSignedCleanupHost -ErrorAction SilentlyContinue } + else { $env:RequireSignedCleanupHost = $originalSigningRequirement } +} Write-Host 'Cleanup signing regression: 5 cases passed.' exit 0 diff --git a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj index 1ea1c2649..61197e069 100644 --- a/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj +++ b/src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj @@ -5,6 +5,7 @@ enable enable OpenClaw.SetupEngine + true From 721114754db8f7472a9dd9cfe61414683ab3ff7e Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Thu, 22 Oct 2026 14:42:19 -0700 Subject: [PATCH 7/9] fix(tests): preserve cleanup proof failures and parent coverage Record actual nullable native exits and window sample gaps on failure. Require observer release, retain startup observations, and qualify the scheduled signed host parent by executable path and creation identity. Add native AppLocker capture with explicit callback errors, record-boundary replay, policy-applied gating, and exact provider/channel/SID/rule evidence. Add a proof worker that records handshake failures and handles successful PowerShell workers without requiring a populated LASTEXITCODE. Validation: 24/24 pure behavior cases; Framework native compilation; four PowerShell parsers; full build.ps1 passed. Shared: 4485 passed, 1 failed, 35 skipped. Failure is the MXC shell PATH readonly-grant expectation under the current isolated host work path. No assertions or budgets were relaxed. Pending: isolated Tray tests, worker process integration, fresh native AppLocker event qualification, signed-resource enforcing acceptance, and actual uninstall-window proof. Event-loss cause remains unproven. Local checkpoint only; all pushes held. --- .../CleanupAppLockerCapture.cs | 89 +++++++++++++ .../CleanupAppLockerEvidence.cs | 117 ++++++++++++++++++ .../CleanupAppLockerEvidenceTests.cs | 98 +++++++++++++++ .../CleanupProofWorkerTests.cs | 75 +++++++++++ .../CleanupVisibilityProbe.cs | 95 +++++++++++--- .../CleanupVisibilityRunState.cs | 84 +++++++++++++ .../CleanupVisibilityRunStateTests.cs | 84 +++++++++++++ .../InvokeCleanupProofWorker.ps1 | 50 ++++++++ .../OpenClaw.SetupEngine.Tests.csproj | 5 + .../RunCleanupVisibilityProbe.ps1 | 8 +- 10 files changed, 689 insertions(+), 16 deletions(-) create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunState.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunStateTests.cs create mode 100644 tests/OpenClaw.SetupEngine.Tests/InvokeCleanupProofWorker.ps1 diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs new file mode 100644 index 000000000..4bc0765a4 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs @@ -0,0 +1,89 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; +using System.Threading; + +public sealed class CleanupAppLockerCapture : IDisposable +{ + private readonly object gate = new object(); + private readonly AutoResetEvent changed = new AutoResetEvent(false); + private readonly EventLogWatcher watcher; + private Exception callbackError; + private bool disposed; + public readonly CleanupAppLockerEvidence Evidence; + + public CleanupAppLockerCapture(long minimumRecordId) + { + Evidence = new CleanupAppLockerEvidence(minimumRecordId); + var query = new EventLogQuery(CleanupAppLockerEvidence.Channel, PathType.LogName, Evidence.Query); + // Replay only records after the caller's boundary, including subscription startup. + watcher = new EventLogWatcher(query, null, true); + watcher.EventRecordWritten += OnRecord; + try { watcher.Enabled = true; } + catch { watcher.Dispose(); changed.Dispose(); throw; } + } + + private void OnRecord(object sender, EventRecordWrittenEventArgs args) + { + lock (gate) + { + try + { + using (var record = args.EventRecord) + { + if (disposed) return; + if (args.EventException != null) throw args.EventException; + if (record != null) Evidence.Observe(record.ToXml()); + } + } + catch (Exception error) { if (!disposed) callbackError = error; } + finally { if (!disposed) changed.Set(); } + } + } + + private string WaitFor(Func read, int timeoutMilliseconds) + { + var clock = Stopwatch.StartNew(); + while (true) + { + lock (gate) + { + if (disposed) throw new ObjectDisposedException("CleanupAppLockerCapture"); + if (callbackError != null) throw new InvalidOperationException("AppLocker event subscription failed.", callbackError); + } + var result = read(); + if (result.Length != 0) return result; + int remaining = timeoutMilliseconds - checked((int)clock.ElapsedMilliseconds); + if (remaining <= 0) throw new TimeoutException("No qualified AppLocker event within the unchanged observation budget."); + changed.WaitOne(remaining); + } + } + + public void WaitForPolicyApplied(int timeoutMilliseconds) + { + WaitFor(delegate + { + Evidence.RequireHealthyPolicy(); + return Evidence.PolicyApplied ? "applied" : string.Empty; + }, timeoutMilliseconds); + } + + public string WaitForExecution(string path, int eventId, string actorSid, string systemDrive, + string ruleId, int timeoutMilliseconds) + { + return WaitFor(delegate { return Evidence.FindExecution(path, eventId, actorSid, systemDrive, ruleId); }, + timeoutMilliseconds); + } + + public void Dispose() + { + lock (gate) + { + if (disposed) return; + disposed = true; + } + watcher.Enabled = false; + watcher.Dispose(); + lock (gate) changed.Dispose(); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs new file mode 100644 index 000000000..c20c758ec --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Xml; + +public sealed class CleanupAppLockerEvidence +{ + public const string Provider = "Microsoft-Windows-AppLocker"; + public const string Channel = "Microsoft-Windows-AppLocker/EXE and DLL"; + private readonly object gate = new object(); + private readonly List records = new List(); + private readonly long minimumRecordId; + private long appliedRecordId = -1; + private bool policyError; + + public CleanupAppLockerEvidence(long minimumRecordId) + { + if (minimumRecordId < 0) throw new ArgumentOutOfRangeException("minimumRecordId"); + this.minimumRecordId = minimumRecordId; + } + + public string Query + { + get + { + return "*[System[Provider[@Name='" + Provider + + "'] and (EventID=8000 or EventID=8001 or EventID=8002 or EventID=8003 or EventID=8004)" + + " and EventRecordID > " + minimumRecordId.ToString(CultureInfo.InvariantCulture) + "]]"; + } + } + + public bool PolicyApplied + { + get { lock (gate) return appliedRecordId > minimumRecordId && !policyError; } + } + + public long AppliedRecordId + { + get { lock (gate) return appliedRecordId; } + } + + public void RequireHealthyPolicy() + { + lock (gate) + if (policyError) + throw new InvalidOperationException("AppLocker reported policy conversion failure (8000)."); + } + + private static string Value(XmlDocument document, string path) + { + var node = document.SelectSingleNode(path); + return node == null ? string.Empty : node.InnerText; + } + + private static string SystemValue(XmlDocument document, string name) + { + return Value(document, "/*[local-name()='Event']/*[local-name()='System']/*[local-name()='" + name + "']"); + } + + private static string PayloadValue(XmlDocument document, string name) + { + return Value(document, "//*[local-name()='" + name + + "' or (local-name()='Data' and @Name='" + name + "')]"); + } + + public bool Observe(string xml) + { + var document = new XmlDocument { XmlResolver = null }; + document.LoadXml(xml); + var provider = document.SelectSingleNode( + "/*[local-name()='Event']/*[local-name()='System']/*[local-name()='Provider']/@Name"); + long recordId; + int id; + if (provider == null || provider.Value != Provider || SystemValue(document, "Channel") != Channel || + !long.TryParse(SystemValue(document, "EventRecordID"), out recordId) || recordId <= minimumRecordId || + !int.TryParse(SystemValue(document, "EventID"), out id) || id < 8000 || id > 8004) + return false; + lock (gate) + { + records.Add(document); + if (id == 8000) policyError = true; + if (id == 8001) appliedRecordId = Math.Max(appliedRecordId, recordId); + } + return true; + } + + public string FindExecution(string path, int expectedId, string actorSid, string systemDrive, string expectedRuleId) + { + if (expectedId != 8002 && expectedId != 8004) + throw new ArgumentOutOfRangeException("expectedId", "Audit events are not enforcing execution proof."); + Guid expectedRule; + if (!Guid.TryParse(expectedRuleId, out expectedRule)) + throw new ArgumentException("An exact AppLocker rule GUID is required.", "expectedRuleId"); + lock (gate) + { + if (policyError) throw new InvalidOperationException("AppLocker reported policy conversion failure (8000)."); + if (appliedRecordId <= minimumRecordId) + throw new InvalidOperationException("No synchronized AppLocker policy-applied event (8001)."); + foreach (var document in records) + { + var recordId = long.Parse(SystemValue(document, "EventRecordID"), CultureInfo.InvariantCulture); + if (recordId <= appliedRecordId || + SystemValue(document, "EventID") != expectedId.ToString(CultureInfo.InvariantCulture)) + continue; + var file = PayloadValue(document, "FilePath"); + if (file.StartsWith("%OSDRIVE%", StringComparison.OrdinalIgnoreCase)) + file = systemDrive + file.Substring("%OSDRIVE%".Length); + Guid rule; + if (string.Equals(file, path, StringComparison.OrdinalIgnoreCase) && + string.Equals(PayloadValue(document, "TargetUser"), actorSid, StringComparison.OrdinalIgnoreCase) && + Guid.TryParse(PayloadValue(document, "RuleId"), out rule) && rule == expectedRule) + return document.OuterXml; + } + } + return string.Empty; + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs new file mode 100644 index 000000000..d3ce3fa55 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs @@ -0,0 +1,98 @@ +using System.Xml; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class CleanupAppLockerEvidenceTests +{ + private const string Sid = "S-1-5-21-1000"; + private const string Rule = "{12345678-1111-2222-3333-444444444444}"; + + private static string Event(int id, long record, string channel = "Microsoft-Windows-AppLocker/EXE and DLL", + string provider = "Microsoft-Windows-AppLocker", string actor = Sid, string rule = Rule) => + $""" + + {id}{record}{channel} + + %osdrive%\proof\host.exe{actor}{rule} + + + """; + + [Theory] + [InlineData(8000, true)] + [InlineData(8001, true)] + [InlineData(8002, true)] + [InlineData(8003, true)] + [InlineData(8004, true)] + [InlineData(8005, false)] + public void SubscriptionQuery_SelectsActualProviderAndExePolicyEvents(int id, bool expected) + { + var evidence = new CleanupAppLockerEvidence(20); + var document = new XmlDocument(); + // EventLog's restricted XPath treats event namespaces implicitly. + document.LoadXml($"{id}21"); + Assert.Equal(expected, document.SelectSingleNode(evidence.Query) is not null); + } + + [Theory] + [InlineData("Microsoft-Windows-AppLocker/MSI and Script", "Microsoft-Windows-AppLocker")] + [InlineData("Microsoft-Windows-AppLocker/EXE and DLL", "Microsoft-Windows-CodeIntegrity")] + public void WrongPublisherOrChannel_CannotQualifyPolicy(string channel, string provider) + { + var evidence = new CleanupAppLockerEvidence(20); + Assert.False(evidence.Observe(Event(8001, 21, channel, provider))); + Assert.False(evidence.PolicyApplied); + } + + [Fact] + public void ExistingOrPrePolicyExecution_CannotQualifyNewEnforcement() + { + var evidence = new CleanupAppLockerEvidence(20); + Assert.False(evidence.Observe(Event(8001, 20))); + Assert.Throws(() => + evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule)); + evidence.Observe(Event(8004, 21)); + evidence.Observe(Event(8001, 22)); + Assert.Equal(string.Empty, evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule)); + evidence.Observe(Event(8004, 23)); + Assert.NotEmpty(evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule)); + } + + [Fact] + public void AuditWrongActorAndWrongRule_AreNotEnforcingDenialEvidence() + { + var evidence = new CleanupAppLockerEvidence(0); + evidence.Observe(Event(8001, 1)); + evidence.Observe(Event(8003, 2)); + evidence.Observe(Event(8004, 3, actor: "S-1-5-18")); + evidence.Observe(Event(8004, 4, rule: "{99999999-1111-2222-3333-444444444444}")); + Assert.Equal(string.Empty, evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule)); + Assert.Throws(() => + evidence.FindExecution(@"C:\proof\host.exe", 8003, Sid, "C:", Rule)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PolicyConversionFailure_StopsAcceptance(bool afterApplied) + { + var evidence = new CleanupAppLockerEvidence(0); + if (afterApplied) + evidence.Observe(Event(8001, 1)); + evidence.Observe(Event(8000, 2)); + Assert.Equal(afterApplied ? 1 : -1, evidence.AppliedRecordId); + Assert.False(evidence.PolicyApplied); + Assert.Throws(evidence.RequireHealthyPolicy); + Assert.Throws(() => + evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule)); + } + + [Fact] + public void RuleIdentity_UsesTheActualGuidRatherThanBraceFormatting() + { + var evidence = new CleanupAppLockerEvidence(0); + evidence.Observe(Event(8001, 1)); + evidence.Observe(Event(8002, 2, rule: "12345678-1111-2222-3333-444444444444")); + Assert.NotEmpty(evidence.FindExecution(@"C:\proof\host.exe", 8002, Sid, "C:", Rule)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs new file mode 100644 index 000000000..6d4576b0a --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Text.Json; +using OpenClaw.TestSupport; +using OpenClaw.Tray.IntegrationTests; + +namespace OpenClaw.SetupEngine.Tests; + +public sealed class CleanupProofWorkerTests +{ + [IntegrationFact] + public async Task ScriptStatusAndNativeExit_ArePreservedAcrossActualPowerShellInvocation() + { + foreach (var (script, expectedExit, expectedSuccess) in new[] + { + ("param($Family); 'successful cmdlet-only cleanup' | Out-Null", 0, true), + ("param($Family); exit 37", 37, false), + ("param($Family); throw 'fixture worker failure'", 1, false), + }) + { + using var directory = new TempDirectory(); + var worker = directory.Combine("production-worker.ps1"); + var result = directory.Combine("worker-result.json"); + var pidFile = directory.Combine("worker-pid.txt"); + await File.WriteAllTextAsync(worker, script); + var token = "Local\\CleanupProofWorker-" + Guid.NewGuid().ToString("N"); + using var ready = new EventWaitHandle(false, EventResetMode.ManualReset, token + "-ready"); + using var release = new EventWaitHandle(true, EventResetMode.ManualReset, token + "-release"); + using var finish = new EventWaitHandle(false, EventResetMode.ManualReset, token + "-finish"); + var start = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in new[] + { + "-NoProfile", "-NonInteractive", "-File", + Path.Combine(AppContext.BaseDirectory, "InvokeCleanupProofWorker.ps1"), + "-WorkerPath", worker, "-Family", "Fixture_123", "-ReadyName", token + "-ready", + "-ReleaseName", token + "-release", "-FinishName", token + "-finish", + "-ExternalPidFile", pidFile, "-ResultPath", result, + }) + start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + try + { + await process.WaitForExitAsync(timeout.Token); + await Task.WhenAll(stdout, stderr); + Assert.Equal(expectedExit, process.ExitCode); + Assert.True(ready.WaitOne(0)); + Assert.True(finish.WaitOne(0)); + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(result)); + Assert.Equal(expectedExit, document.RootElement.GetProperty("ExitCode").GetInt32()); + Assert.Equal(expectedSuccess, document.RootElement.GetProperty("Succeeded").GetBoolean()); + if (expectedSuccess) + Assert.Equal(JsonValueKind.Null, document.RootElement.GetProperty("LastNativeExitAfterWorker").ValueKind); + Assert.Equal(process.Id, int.Parse(await File.ReadAllTextAsync(pidFile))); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs index 78c853574..533f149f6 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityProbe.cs @@ -89,7 +89,7 @@ public sealed class Observation public sealed class Result { - public int ExitCode; + public int? ExitCode; public int WindowSamples; public int TaskbarSamples; public int TaskbarsFound; @@ -175,6 +175,9 @@ private static extern bool CreateProcess(string app, StringBuilder command, IntP private readonly ConcurrentQueue events = new ConcurrentQueue(); private readonly ConcurrentQueue errors = new ConcurrentQueue(); private readonly Stopwatch clock = Stopwatch.StartNew(); + private double maximumWindowGap; + private readonly CleanupVisibilityRunState runState = new CleanupVisibilityRunState(); + private readonly List externalProcesses = new List(); private readonly TimeSpan initialCpu = Process.GetCurrentProcess().TotalProcessorTime; private readonly ManualResetEvent stop = new ManualResetEvent(false); private readonly ManualResetEvent eventReady = new ManualResetEvent(false); @@ -216,8 +219,10 @@ private void ThrowObserverErrors() public Result Snapshot() { return new Result { + ExitCode = runState.RootExitCode, WindowSamples = windowSamples, TaskbarSamples = taskbarSamples, TaskbarsFound = taskbarsFound, MaximumTaskbarElements = maximumTaskbarElements, + MaximumWindowSampleGapMs = maximumWindowGap, ElapsedMs = clock.Elapsed.TotalMilliseconds, ObserverCpuMs = (Process.GetCurrentProcess().TotalProcessorTime - initialCpu).TotalMilliseconds, MaximumTaskbarSampleGapMs = maximumTaskbarGap, Errors = errors.ToArray(), @@ -236,7 +241,9 @@ private static string ClassName(IntPtr hwnd) private bool IsOwnedWindow(Observation observation) { - return owned.ContainsKey(observation.Pid) || ownedWindows.ContainsKey(observation.Handle); + return (owned.ContainsKey(observation.Pid) && + runState.CanAttributeObservation(observation.Pid, observation.ElapsedMs)) || + ownedWindows.ContainsKey(observation.Handle); } private readonly DateTime startedAtUtc = DateTime.UtcNow; @@ -277,12 +284,14 @@ private void ObserveWindow(string source, IntPtr hwnd, bool showEvent = false) { uint pid; GetWindowThreadProcessId(hwnd, out pid); - bool isOwned = owned.ContainsKey((int)pid) || ownedWindows.ContainsKey(hwnd.ToInt64()); - if (source == "WindowSample" && !isOwned && !consoleWindows.ContainsKey(hwnd.ToInt64())) return; + bool isOwned = owned.ContainsKey((int)pid) || runState.IsExternalProcess((int)pid) || + ownedWindows.ContainsKey(hwnd.ToInt64()); + if (source == "WindowSample" && !isOwned && !runState.CaptureUnownedStartupSamples && + !consoleWindows.ContainsKey(hwnd.ToInt64())) return; string className = ClassName(hwnd); bool isConsole = className == "ConsoleWindowClass" || className == "CASCADIA_HOSTING_WINDOW_CLASS"; if (isConsole) consoleWindows.TryAdd(hwnd.ToInt64(), true); - if (source == "WindowSample" && !isOwned && !isConsole) return; + if (source == "WindowSample" && !isOwned && !isConsole && !runState.CaptureUnownedStartupSamples) return; var title = new StringBuilder(1024); if (isOwned || isConsole) { @@ -296,7 +305,7 @@ private void ObserveWindow(string source, IntPtr hwnd, bool showEvent = false) isOwned = true; } } - if (source == "WindowSample" && !isOwned) return; + if (source == "WindowSample" && !isOwned && !runState.CaptureUnownedStartupSamples) return; // A SHOW event can be delivered after a fast hide. Preserve the event // instead of losing exactly the transient startup flash being tested. if (showEvent && GetAncestor(hwnd, 2) != hwnd) return; @@ -451,6 +460,62 @@ private void RefreshTree() public Result Run(string executable, string arguments, string directory, string readyName, string releaseName, bool positiveControl, int timeoutSeconds, string externalPidFile, bool noWindowStarter) { + return Run(executable, arguments, directory, readyName, releaseName, positiveControl, + timeoutSeconds, externalPidFile, noWindowStarter, null); + } + + private static int ParentPid(int pid) + { + var snapshot = CreateToolhelp32Snapshot(2, 0); + if (snapshot == new IntPtr(-1)) throw new System.ComponentModel.Win32Exception(); + try + { + var entry = new ProcessEntry { size = (uint)Marshal.SizeOf(typeof(ProcessEntry)) }; + if (!Process32First(snapshot, ref entry)) throw new System.ComponentModel.Win32Exception(); + do { if (entry.pid == (uint)pid) return checked((int)entry.parent); } + while (Process32Next(snapshot, ref entry)); + throw new InvalidOperationException("External worker process identity disappeared."); + } + finally { CloseHandle(snapshot); } + } + + private void RegisterExternalWorker(int workerPid, string expectedHostPath) + { + var worker = Process.GetProcessById(workerPid); + Process host = null; + try + { + if (!worker.ProcessName.Equals("powershell", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("External proof worker is not Windows PowerShell."); + var workerHandle = worker.Handle; + if (!string.IsNullOrEmpty(expectedHostPath)) + { + host = Process.GetProcessById(ParentPid(workerPid)); + var hostHandle = host.Handle; + runState.RegisterExternalWorker(workerPid, host.Id, host.MainModule.FileName, + (host.StartTime.ToUniversalTime() - startedAtUtc).TotalMilliseconds, + (worker.StartTime.ToUniversalTime() - startedAtUtc).TotalMilliseconds); + owned.TryAdd(host.Id, true); + externalProcesses.Add(host); + host = null; + } + else runState.MarkWorkerReady(); + owned.TryAdd(workerPid, true); + externalProcesses.Add(worker); + worker = null; + } + finally + { + if (worker != null) worker.Dispose(); + if (host != null) host.Dispose(); + } + } + + public Result Run(string executable, string arguments, string directory, string readyName, string releaseName, + bool positiveControl, int timeoutSeconds, string externalPidFile, bool noWindowStarter, + string expectedExternalHostPath) + { + runState.ConfigureExternalHost(expectedExternalHostPath); var startup = new Startup { cb = Marshal.SizeOf(typeof(Startup)), flags = positiveControl ? 1 : 0, show = 1, title = token }; @@ -484,20 +549,19 @@ public Result Run(string executable, string arguments, string directory, string windowSamples = samples; double now = clock.Elapsed.TotalMilliseconds; maximumGap = Math.Max(maximumGap, now - previous); + maximumWindowGap = maximumGap; previous = now; if (!workerReady && ready.WaitOne(0)) { - workerReady = true; readyTaskbarSamples = taskbarSamples; if (!string.IsNullOrEmpty(externalPidFile)) { int workerPid = int.Parse(System.IO.File.ReadAllText(externalPidFile), System.Globalization.CultureInfo.InvariantCulture); - using (var worker = Process.GetProcessById(workerPid)) - if (!worker.ProcessName.Equals("powershell", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("External proof worker is not Windows PowerShell."); - owned.TryAdd(workerPid, true); + RegisterExternalWorker(workerPid, expectedExternalHostPath); } + else runState.MarkWorkerReady(); + workerReady = true; } if (workerReady) { @@ -506,11 +570,12 @@ public Result Run(string executable, string arguments, string directory, string bool eventSeen = Array.Exists(recorded, e => e.Source == "WinEvent" && e.OnScreen && IsOwnedWindow(e)); bool sampleSeen = Array.Exists(recorded, e => e.Source == "WindowSample" && e.OnScreen && IsOwnedWindow(e)); bool buttonSeen = Array.Exists(recorded, e => e.Source == "TaskbarUIA" && e.OnScreen); - if (!released && afterReady >= 20 && taskbarSamples >= readyTaskbarSamples + 2 && - (!positiveControl || (eventSeen && sampleSeen && buttonSeen))) + if (!released && runState.CanRelease(afterReady, taskbarSamples - readyTaskbarSamples, + positiveControl, eventSeen, sampleSeen, buttonSeen)) { release.Set(); released = true; + runState.MarkReleased(); } } if (clock.Elapsed > TimeSpan.FromSeconds(timeoutSeconds)) @@ -518,10 +583,11 @@ public Result Run(string executable, string arguments, string directory, string stop.WaitOne(WindowSampleIntervalMs); } process.WaitForExit(); - if (!workerReady) throw new InvalidOperationException("Worker did not signal ready."); uint exitCode; if (!GetExitCodeProcess(info.process, out exitCode)) throw new System.ComponentModel.Win32Exception(); + runState.RecordRootExit(unchecked((int)exitCode)); + runState.RequireCompleteHandshake(); var result = new Result { ExitCode = unchecked((int)exitCode), WindowSamples = samples, TaskbarSamples = taskbarSamples, TaskbarsFound = taskbarsFound, MaximumWindowSampleGapMs = maximumGap, @@ -561,6 +627,7 @@ public void Dispose() PostThreadMessage(eventThreadId, 0x12, IntPtr.Zero, IntPtr.Zero); if (!eventThread.Join(10000) || !taskbarThread.Join(10000)) throw new TimeoutException("A visibility observer did not stop."); + foreach (var process in externalProcesses) process.Dispose(); stop.Dispose(); eventReady.Dispose(); taskbarReady.Dispose(); diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunState.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunState.cs new file mode 100644 index 000000000..7d890bcde --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunState.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Concurrent; +using System.IO; + +public sealed class CleanupVisibilityRunState +{ + public const int RequiredWindowSamplesAfterReady = 20; + public const int RequiredTaskbarSamplesAfterReady = 2; + private readonly ConcurrentDictionary externalStarts = new ConcurrentDictionary(); + private volatile bool workerReady; + private string expectedHostPath = string.Empty; + + public int? RootExitCode { get; private set; } + public bool WorkerReady { get { return workerReady; } } + public bool Released { get; private set; } + public bool CaptureUnownedStartupSamples { get { return expectedHostPath.Length != 0 && !workerReady; } } + + public void ConfigureExternalHost(string path) + { + expectedHostPath = string.IsNullOrEmpty(path) ? string.Empty : Path.GetFullPath(path); + } + + public void MarkWorkerReady() + { + if (expectedHostPath.Length != 0) + throw new InvalidOperationException("The actual external cleanup host must be registered before readiness."); + workerReady = true; + } + + public void RegisterExternalWorker(int workerPid, int hostPid, string actualHostPath, + double hostCreatedElapsedMs, double workerCreatedElapsedMs) + { + if (expectedHostPath.Length == 0 || !string.Equals(expectedHostPath, Path.GetFullPath(actualHostPath), + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("External worker parent is not the expected cleanup host."); + if (workerPid <= 0 || hostPid <= 0 || workerPid == hostPid || + double.IsNaN(hostCreatedElapsedMs) || double.IsNaN(workerCreatedElapsedMs) || + double.IsInfinity(hostCreatedElapsedMs) || double.IsInfinity(workerCreatedElapsedMs) || + hostCreatedElapsedMs < 0 || workerCreatedElapsedMs < hostCreatedElapsedMs) + throw new InvalidOperationException("External worker/host process identities are not from this probe."); + externalStarts[hostPid] = hostCreatedElapsedMs; + externalStarts[workerPid] = workerCreatedElapsedMs; + workerReady = true; + } + + public bool CanAttributeObservation(int pid, double observedElapsedMs) + { + double createdElapsedMs; + return !externalStarts.TryGetValue(pid, out createdElapsedMs) || observedElapsedMs >= createdElapsedMs; + } + + public bool IsExternalProcess(int pid) + { + return externalStarts.ContainsKey(pid); + } + + public bool CanRelease(int windowSamplesAfterReady, int taskbarSamplesAfterReady, bool positiveControl, + bool showEventSeen, bool sampledWindowSeen, bool taskbarButtonSeen) + { + return workerReady && !Released && + windowSamplesAfterReady >= RequiredWindowSamplesAfterReady && + taskbarSamplesAfterReady >= RequiredTaskbarSamplesAfterReady && + (!positiveControl || (showEventSeen && sampledWindowSeen && taskbarButtonSeen)); + } + + public void MarkReleased() + { + Released = true; + } + + public void RecordRootExit(int exitCode) + { + RootExitCode = exitCode; + } + + public void RequireCompleteHandshake() + { + if (!workerReady) + throw new InvalidOperationException("Worker did not signal ready. Root native exit: " + + (RootExitCode.HasValue ? RootExitCode.Value.ToString() : "unknown") + "."); + if (!Released) + throw new InvalidOperationException("Worker exited before synchronized observer release."); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunStateTests.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunStateTests.cs new file mode 100644 index 000000000..0241ceaca --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupVisibilityRunStateTests.cs @@ -0,0 +1,84 @@ +namespace OpenClaw.SetupEngine.Tests; + +public sealed class CleanupVisibilityRunStateTests +{ + [Fact] + public void MissingReadiness_PreservesActualFailureRatherThanReportingDefaultSuccess() + { + var state = new CleanupVisibilityRunState(); + Assert.Null(state.RootExitCode); + state.RecordRootExit(37); + var error = Assert.Throws(state.RequireCompleteHandshake); + Assert.Contains("Root native exit: 37", error.Message); + Assert.Equal(37, state.RootExitCode); + } + + [Fact] + public void ReadyThenEarlySuccessfulExit_DoesNotPassWithoutObservationRelease() + { + var state = new CleanupVisibilityRunState(); + state.MarkWorkerReady(); + state.RecordRootExit(0); + Assert.Throws(state.RequireCompleteHandshake); + } + + [Fact] + public void ExternalParent_MustMatchActualHostBeforeStartupSamplingStops() + { + var state = new CleanupVisibilityRunState(); + state.ConfigureExternalHost(@"C:\proof\OpenClaw.GatewayCleanupHost.exe"); + Assert.True(state.CaptureUnownedStartupSamples); + Assert.Throws(() => + state.RegisterExternalWorker(12, 11, @"C:\other.exe", 100, 120)); + Assert.False(state.WorkerReady); + Assert.True(state.CaptureUnownedStartupSamples); + Assert.Throws(state.MarkWorkerReady); + state.RegisterExternalWorker(12, 11, @"C:\proof\OpenClaw.GatewayCleanupHost.exe", 100, 120); + Assert.False(state.CaptureUnownedStartupSamples); + Assert.False(state.CanAttributeObservation(11, 99)); + Assert.True(state.CanAttributeObservation(11, 100)); + Assert.False(state.CanAttributeObservation(12, 119)); + Assert.True(state.CanAttributeObservation(12, 120)); + } + + [Theory] + [InlineData(12, 0, 100, 120)] + [InlineData(12, 12, 100, 120)] + [InlineData(12, 11, -1, 120)] + [InlineData(12, 11, 100, 99)] + public void InvalidOrPreexistingExternalIdentity_CannotSignalReady(int worker, int host, + double hostCreated, double workerCreated) + { + var state = new CleanupVisibilityRunState(); + state.ConfigureExternalHost(@"C:\proof\host.exe"); + Assert.Throws(() => + state.RegisterExternalWorker(worker, host, @"C:\proof\host.exe", hostCreated, workerCreated)); + Assert.False(state.WorkerReady); + } + + [Theory] + [InlineData(19, 2, false)] + [InlineData(20, 1, false)] + [InlineData(20, 2, true)] + public void ReleaseRequiresExistingWindowAndTaskbarCoverage(int windows, int taskbars, bool expected) + { + var state = new CleanupVisibilityRunState(); + state.MarkWorkerReady(); + Assert.Equal(expected, state.CanRelease(windows, taskbars, false, false, false, false)); + } + + [Fact] + public void PositiveControl_RequiresAllThreeIndependentVisibilityWitnesses() + { + var state = new CleanupVisibilityRunState(); + state.MarkWorkerReady(); + Assert.False(state.CanRelease(20, 2, true, true, true, false)); + Assert.False(state.CanRelease(20, 2, true, true, false, true)); + Assert.False(state.CanRelease(20, 2, true, false, true, true)); + Assert.True(state.CanRelease(20, 2, true, true, true, true)); + state.MarkReleased(); + state.RecordRootExit(0); + state.RequireCompleteHandshake(); + Assert.False(state.CanRelease(20, 2, true, true, true, true)); + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/InvokeCleanupProofWorker.ps1 b/tests/OpenClaw.SetupEngine.Tests/InvokeCleanupProofWorker.ps1 new file mode 100644 index 000000000..a410dd636 --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/InvokeCleanupProofWorker.ps1 @@ -0,0 +1,50 @@ +param( + [Parameter(Mandatory)][string]$WorkerPath, + [Parameter(Mandatory)][string]$Family, + [Parameter(Mandatory)][string]$ReadyName, + [Parameter(Mandatory)][string]$ReleaseName, + [Parameter(Mandatory)][string]$FinishName, + [Parameter(Mandatory)][string]$ExternalPidFile, + [Parameter(Mandatory)][string]$ResultPath +) +$ErrorActionPreference='Stop' +$ObservationReleaseTimeoutMilliseconds=10000 +$ready=$null +$release=$null +$finish=$null +$exitCode=1 +$result=[ordered]@{ + Pid=$PID;Family=$Family;Stage='open-handles';Succeeded=$false;ExitCode=$null + LastNativeExitAfterWorker=$null;ErrorType=$null;Error=$null +} +try { + $ready=[Threading.EventWaitHandle]::OpenExisting($ReadyName) + $release=[Threading.EventWaitHandle]::OpenExisting($ReleaseName) + $finish=[Threading.EventWaitHandle]::OpenExisting($FinishName) + [IO.File]::WriteAllText($ExternalPidFile,[string]$PID) + $result.Stage='await-observer-release' + $result|ConvertTo-Json|Set-Content -LiteralPath $ResultPath -Encoding UTF8 + $ready.Set()|Out-Null + if(!$release.WaitOne($ObservationReleaseTimeoutMilliseconds)){ + throw 'Observer did not release synchronized actual scheduled worker' + } + $result.Stage='production-worker' + & $WorkerPath -Family $Family + $succeeded=$? + $result.LastNativeExitAfterWorker=$LASTEXITCODE + # A successful PowerShell script need not execute a native command. + if($succeeded){$exitCode=0} + elseif($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0){$exitCode=[int]$LASTEXITCODE} + else{$exitCode=1} + $result.Succeeded=$succeeded + $result.Stage='complete' +} catch { + $result.ErrorType=$_.Exception.GetType().Name + $result.Error=$_.Exception.Message +} finally { + $result.ExitCode=$exitCode + $result|ConvertTo-Json|Set-Content -LiteralPath $ResultPath -Encoding UTF8 + if($finish){$finish.Set()|Out-Null} + foreach($handle in @($ready,$release,$finish)){if($handle){$handle.Dispose()}} +} +exit $exitCode diff --git a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj index a7f51b6e0..5019f01ea 100644 --- a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj +++ b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj @@ -4,7 +4,12 @@ + + + + + Date: Thu, 22 Oct 2026 16:16:53 -0700 Subject: [PATCH 8/9] fix(tests): reconcile AppLocker records without live notifications Keep exact event identity and unchanged observation deadlines. Reconcile readable records when live subscription notifications do not arrive, deduplicate replay, and propagate query failures. Native guest diagnosis showed readable 8001 records despite live waits expiring; parser and historical replay worked. Owned-path diagnostic later correlated 8001, 8002 and 8004. Final deadline guard is fail closed after reader I/O. Host validation: 27 focused cases passed; Framework native compile and full build passed; Shared 4486 passed, 35 skipped. Preserve earlier transient Shared fixture pending-outcome failure separately. Full current-head guest, Tray, signed cleanup and uninstall proof remain pending. Local only, all pushes held. --- .../CleanupAppLockerCapture.cs | 29 ++++++++++++++-- .../CleanupAppLockerEvidence.cs | 25 ++++++++++---- .../CleanupAppLockerEvidenceTests.cs | 33 +++++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs index 4bc0765a4..eeb510d9b 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerCapture.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Eventing.Reader; using System.Threading; @@ -11,10 +12,13 @@ public sealed class CleanupAppLockerCapture : IDisposable private Exception callbackError; private bool disposed; public readonly CleanupAppLockerEvidence Evidence; + private long reconciledRecordId; + private const int ReconciliationIntervalMilliseconds = 100; public CleanupAppLockerCapture(long minimumRecordId) { Evidence = new CleanupAppLockerEvidence(minimumRecordId); + reconciledRecordId = minimumRecordId; var query = new EventLogQuery(CleanupAppLockerEvidence.Channel, PathType.LogName, Evidence.Query); // Replay only records after the caller's boundary, including subscription startup. watcher = new EventLogWatcher(query, null, true); @@ -51,11 +55,30 @@ private string WaitFor(Func read, int timeoutMilliseconds) if (disposed) throw new ObjectDisposedException("CleanupAppLockerCapture"); if (callbackError != null) throw new InvalidOperationException("AppLocker event subscription failed.", callbackError); } - var result = read(); - if (result.Length != 0) return result; + var result = Evidence.ReconcileAndRead(QueryAvailable, read); int remaining = timeoutMilliseconds - checked((int)clock.ElapsedMilliseconds); if (remaining <= 0) throw new TimeoutException("No qualified AppLocker event within the unchanged observation budget."); - changed.WaitOne(remaining); + if (result.Length != 0) return result; + changed.WaitOne(Math.Min(remaining, ReconciliationIntervalMilliseconds)); + } + } + + private IEnumerable QueryAvailable() + { + var query = new EventLogQuery(CleanupAppLockerEvidence.Channel, PathType.LogName, + Evidence.QueryAfter(reconciledRecordId)); + using (var reader = new EventLogReader(query)) + { + EventRecord record; + while ((record = reader.ReadEvent(TimeSpan.Zero)) != null) + { + using (record) + { + // A readable record must not depend on live subscription notification latency. + yield return record.ToXml(); + reconciledRecordId = Math.Max(reconciledRecordId, record.RecordId.GetValueOrDefault()); + } + } } } diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs index c20c758ec..0b2231f34 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidence.cs @@ -9,6 +9,7 @@ public sealed class CleanupAppLockerEvidence public const string Channel = "Microsoft-Windows-AppLocker/EXE and DLL"; private readonly object gate = new object(); private readonly List records = new List(); + private readonly HashSet recordIds = new HashSet(); private readonly long minimumRecordId; private long appliedRecordId = -1; private bool policyError; @@ -21,12 +22,23 @@ public CleanupAppLockerEvidence(long minimumRecordId) public string Query { - get - { - return "*[System[Provider[@Name='" + Provider + - "'] and (EventID=8000 or EventID=8001 or EventID=8002 or EventID=8003 or EventID=8004)" + - " and EventRecordID > " + minimumRecordId.ToString(CultureInfo.InvariantCulture) + "]]"; - } + get { return QueryAfter(minimumRecordId); } + } + + public string QueryAfter(long recordId) + { + if (recordId < minimumRecordId) throw new ArgumentOutOfRangeException("recordId"); + return "*[System[Provider[@Name='" + Provider + + "'] and (EventID=8000 or EventID=8001 or EventID=8002 or EventID=8003 or EventID=8004)" + + " and EventRecordID > " + recordId.ToString(CultureInfo.InvariantCulture) + "]]"; + } + + public string ReconcileAndRead(Func> queryAvailable, Func read) + { + string result = read(); + if (result.Length != 0) return result; + foreach (string xml in queryAvailable()) Observe(xml); + return read(); } public bool PolicyApplied @@ -77,6 +89,7 @@ public bool Observe(string xml) return false; lock (gate) { + if (!recordIds.Add(recordId)) return false; records.Add(document); if (id == 8000) policyError = true; if (id == 8001) appliedRecordId = Math.Max(appliedRecordId, recordId); diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs index d3ce3fa55..3ca01604f 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupAppLockerEvidenceTests.cs @@ -95,4 +95,37 @@ public void RuleIdentity_UsesTheActualGuidRatherThanBraceFormatting() evidence.Observe(Event(8002, 2, rule: "12345678-1111-2222-3333-444444444444")); Assert.NotEmpty(evidence.FindExecution(@"C:\proof\host.exe", 8002, Sid, "C:", Rule)); } + + [Fact] + public void MissingNotification_ReconcilesReadablePolicyAndExecutionRecords() + { + var evidence = new CleanupAppLockerEvidence(20); + var result = evidence.ReconcileAndRead( + () => new[] { Event(8001, 21), Event(8004, 22) }, + () => evidence.PolicyApplied + ? evidence.FindExecution(@"C:\proof\host.exe", 8004, Sid, "C:", Rule) + : string.Empty); + Assert.NotEmpty(result); + Assert.Equal(21, evidence.AppliedRecordId); + Assert.False(evidence.Observe(Event(8004, 22))); + } + + [Fact] + public void ReconciliationQueryFailure_PropagatesInsteadOfBecomingMissingEvents() + { + var evidence = new CleanupAppLockerEvidence(20); + var failure = new InvalidOperationException("query access failure"); + Assert.Same(failure, Assert.Throws(() => + evidence.ReconcileAndRead(() => throw failure, () => string.Empty))); + } + + [Fact] + public void ReconciliationWithoutRecords_DoesNotInventAcceptance() + { + var evidence = new CleanupAppLockerEvidence(20); + Assert.Equal(string.Empty, evidence.ReconcileAndRead( + () => Array.Empty(), () => evidence.PolicyApplied ? "applied" : string.Empty)); + Assert.Equal(-1, evidence.AppliedRecordId); + Assert.Throws(() => evidence.QueryAfter(19)); + } } From 9c521dd8e0e1e0f5397148f4d30935f80f5db1fa Mon Sep 17 00:00:00 2001 From: "Paul Campbell (AgOS)" Date: Thu, 22 Oct 2026 20:35:21 -0700 Subject: [PATCH 9/9] test(setup): qualify cleanup fixtures under native desktop policy and grouping --- .../CleanupPositiveConsoleIdentity.cs | 46 +++++++++++++++++++ .../CleanupProofWorkerTests.cs | 3 +- .../GatewayCleanupTaskTests.cs | 5 ++ .../OpenClaw.SetupEngine.Tests.csproj | 2 + 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/OpenClaw.SetupEngine.Tests/CleanupPositiveConsoleIdentity.cs diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupPositiveConsoleIdentity.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupPositiveConsoleIdentity.cs new file mode 100644 index 000000000..a80516d0e --- /dev/null +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupPositiveConsoleIdentity.cs @@ -0,0 +1,46 @@ +using System; +using System.Runtime.InteropServices; + +public static class CleanupPositiveConsoleIdentity +{ + [StructLayout(LayoutKind.Sequential)] + private struct PropertyKey { public Guid Format; public uint Id; } + + [StructLayout(LayoutKind.Explicit, Size = 24)] + private struct PropertyValue + { + [FieldOffset(0)] public ushort Type; + [FieldOffset(8)] public IntPtr Text; + } + + [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IPropertyStore + { + [PreserveSig] int GetCount(out uint count); + [PreserveSig] int GetAt(uint index, out PropertyKey key); + [PreserveSig] int GetValue(ref PropertyKey key, out PropertyValue value); + [PreserveSig] int SetValue(ref PropertyKey key, ref PropertyValue value); + [PreserveSig] int Commit(); + } + + [DllImport("kernel32.dll")] private static extern IntPtr GetConsoleWindow(); + [DllImport("shell32.dll")] private static extern int SHGetPropertyStoreForWindow( + IntPtr window, ref Guid interfaceId, [MarshalAs(UnmanagedType.Interface)] out IPropertyStore store); + + public static void SetTaskbarIdentity(string token) + { + var window = GetConsoleWindow(); + if (window == IntPtr.Zero) throw new InvalidOperationException("Positive control has no console window."); + var iid = typeof(IPropertyStore).GUID; + IPropertyStore store; + Marshal.ThrowExceptionForHR(SHGetPropertyStoreForWindow(window, ref iid, out store)); + var key = new PropertyKey { Format = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), Id = 5 }; + var value = new PropertyValue { Type = 31, Text = Marshal.StringToCoTaskMemUni(token) }; + try + { + Marshal.ThrowExceptionForHR(store.SetValue(ref key, ref value)); + Marshal.ThrowExceptionForHR(store.Commit()); + } + finally { Marshal.FreeCoTaskMem(value.Text); Marshal.ReleaseComObject(store); } + } +} diff --git a/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs b/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs index 6d4576b0a..b04153278 100644 --- a/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/CleanupProofWorkerTests.cs @@ -35,9 +35,10 @@ public async Task ScriptStatusAndNativeExit_ArePreservedAcrossActualPowerShellIn RedirectStandardOutput = true, RedirectStandardError = true, }; + start.Environment["PSExecutionPolicyPreference"] = "Restricted"; foreach (var argument in new[] { - "-NoProfile", "-NonInteractive", "-File", + "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", Path.Combine(AppContext.BaseDirectory, "InvokeCleanupProofWorker.ps1"), "-WorkerPath", worker, "-Family", "Fixture_123", "-ReadyName", token + "-ready", "-ReleaseName", token + "-release", "-FinishName", token + "-finish", diff --git a/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs index 888ad4567..1dc2a364e 100644 --- a/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/GatewayCleanupTaskTests.cs @@ -128,8 +128,13 @@ public async Task VisibilityProbe_VisibleConsole_IsDetectedByEventsSamplesAndTas using var ready = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-ready"); using var release = new EventWaitHandle(false, EventResetMode.ManualReset, @"Local\" + token + "-release"); var worker = temp.Combine("positive.ps1"); + File.Copy(Path.Combine(AppContext.BaseDirectory, "CleanupPositiveConsoleIdentity.cs"), + temp.Combine("CleanupPositiveConsoleIdentity.cs")); await File.WriteAllTextAsync(worker, $$""" [Console]::Title = '{{token}}' + Add-Type -Path (Join-Path $PSScriptRoot 'CleanupPositiveConsoleIdentity.cs') + # Grouped taskbar buttons expose the AppUserModelID, not the console title. + [CleanupPositiveConsoleIdentity]::SetTaskbarIdentity('{{token}}') $ready = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-ready') $release = [Threading.EventWaitHandle]::OpenExisting('Local\{{token}}-release') try { diff --git a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj index 5019f01ea..1855ede98 100644 --- a/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj +++ b/tests/OpenClaw.SetupEngine.Tests/OpenClaw.SetupEngine.Tests.csproj @@ -3,6 +3,8 @@ + +