From d363565375f5bd20865530e5d326cbd2cedced5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 04:12:44 +0100 Subject: [PATCH] fix: harden Windows Parallels update smoke --- scripts/e2e/parallels/host-command.ts | 5 +- scripts/e2e/parallels/npm-update-smoke.ts | 132 +++++++++++++++++- scripts/e2e/parallels/powershell.ts | 17 ++- scripts/e2e/parallels/windows-smoke.ts | 52 ++++--- .../parallels-npm-update-smoke.test.ts | 10 ++ test/scripts/parallels-smoke-model.test.ts | 21 +++ 6 files changed, 202 insertions(+), 35 deletions(-) diff --git a/scripts/e2e/parallels/host-command.ts b/scripts/e2e/parallels/host-command.ts index b09daf853be..3d0fbe18609 100644 --- a/scripts/e2e/parallels/host-command.ts +++ b/scripts/e2e/parallels/host-command.ts @@ -34,11 +34,12 @@ export function run(command: string, args: string[], options: RunOptions = {}): timeout: options.timeoutMs, }); - if (result.error) { + const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT"; + if (result.error && !(timedOut && options.check === false)) { throw result.error; } - const status = result.status ?? (result.signal ? 128 : 1); + const status = timedOut ? 124 : (result.status ?? (result.signal ? 128 : 1)); const commandResult = { stderr: result.stderr ?? "", stdout: result.stdout ?? "", diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index c71450b88df..7541ffc2712 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -14,6 +14,7 @@ import { resolveLatestVersion, resolveProviderAuth, run, + runStreaming, say, startHostServer, writeJson, @@ -318,7 +319,7 @@ class NpmUpdateSmoke { } } - private spawnUpdate(label: string, platform: Platform, fn: () => void): Job { + private spawnUpdate(label: string, platform: Platform, fn: () => Promise | void): Job { const logPath = path.join(this.runDir, `${platform}-update.log`); const job: Job = { done: false, @@ -343,7 +344,7 @@ class NpmUpdateSmoke { append(chunk)) as typeof process.stdout.write; process.stderr.write = ((chunk: string | Uint8Array) => append(chunk)) as typeof process.stderr.write; - fn(); + await fn(); await writeFile(logPath, log, "utf8"); return 0; } catch (error) { @@ -365,8 +366,8 @@ class NpmUpdateSmoke { this.guestMacos(this.updateScript("macos"), updateTimeoutSeconds * 1000); } - private runWindowsUpdate(): void { - this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000); + private runWindowsUpdate(): Promise { + return this.guestWindows(this.updateScript("windows"), updateTimeoutSeconds * 1000); } private runLinuxUpdate(): void { @@ -452,7 +453,27 @@ class NpmUpdateSmoke { ); } - private guestWindows(script: string, timeoutMs: number): void { + private async guestWindows(script: string, timeoutMs: number): Promise { + const fileBase = `openclaw-parallels-npm-update-windows-${process.pid}-${Date.now()}`; + const pathsScript = `$base = Join-Path $env:TEMP '${fileBase}' +$scriptPath = "$base.ps1" +$logPath = "$base.log" +$donePath = "$base.done" +$exitPath = "$base.exit"`; + const payload = `$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +${pathsScript} +try { + & { +${script} + } *>&1 | ForEach-Object { $_ | Out-String | Add-Content -Path $logPath -Encoding UTF8 } + Set-Content -Path $exitPath -Value '0' -Encoding UTF8 +} catch { + $_ | Out-String | Add-Content -Path $logPath -Encoding UTF8 + Set-Content -Path $exitPath -Value '1' -Encoding UTF8 +} finally { + Set-Content -Path $donePath -Value 'done' -Encoding UTF8 +}`; run( "prlctl", [ @@ -464,10 +485,107 @@ class NpmUpdateSmoke { "-ExecutionPolicy", "Bypass", "-EncodedCommand", - encodePowerShell(script), + encodePowerShell(`${pathsScript} +Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue +[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false)) +if (!(Test-Path $scriptPath)) { throw "background update script was not written" }`), ], - { timeoutMs }, + { input: payload, timeoutMs: Math.min(timeoutMs, 120_000) }, ); + + const launchLogPath = path.join(this.runDir, `${fileBase}-launch.log`); + const launchStatus = await runStreaming( + "prlctl", + [ + "exec", + windowsVm, + "--current-user", + "cmd.exe", + "/d", + "/s", + "/c", + `start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`, + ], + { logPath: launchLogPath, quiet: true, timeoutMs: 20_000 }, + ); + const launchLog = await readFile(launchLogPath, "utf8").catch(() => ""); + if (launchLog) { + process.stdout.write(launchLog); + } + if (launchStatus !== 0 && launchStatus !== 124) { + throw new Error(`Windows update background launch failed with exit code ${launchStatus}`); + } + + const deadline = Date.now() + timeoutMs; + let lastLogOffset = 0; + while (Date.now() < deadline) { + const poll = run( + "prlctl", + [ + "exec", + windowsVm, + "--current-user", + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(`${pathsScript} +$offset = ${lastLogOffset} +if (Test-Path $logPath) { + $bytes = [System.IO.File]::ReadAllBytes($logPath) + if ($bytes.Length -gt $offset) { + "__OPENCLAW_LOG_OFFSET__:$($bytes.Length)" + [System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset) + } +} +if (Test-Path $donePath) { + $backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' } + "__OPENCLAW_BACKGROUND_EXIT__:$backgroundExit" + '__OPENCLAW_BACKGROUND_DONE__' + if ($backgroundExit -ne '0') { exit 23 } + exit 0 +}`), + ], + { check: false, timeoutMs: Math.min(30_000, Math.max(1_000, deadline - Date.now())) }, + ); + if (poll.stdout) { + process.stdout.write(poll.stdout); + } + if (poll.stderr) { + process.stderr.write(poll.stderr); + } + const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/); + if (offsetMatch) { + lastLogOffset = Number(offsetMatch[1]); + } + if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) { + const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/); + const backgroundExit = exitMatch?.[1] ?? "0"; + if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) { + throw new Error("Windows update failed"); + } + run( + "prlctl", + [ + "exec", + windowsVm, + "--current-user", + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(`${pathsScript} +Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`), + ], + { check: false, timeoutMs: 30_000 }, + ); + return; + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`Windows update timed out after ${updateTimeoutSeconds}s`); } private guestLinux(script: string, timeoutMs: number): void { diff --git a/scripts/e2e/parallels/powershell.ts b/scripts/e2e/parallels/powershell.ts index 5b8b7ac24b8..66f5eaffe95 100644 --- a/scripts/e2e/parallels/powershell.ts +++ b/scripts/e2e/parallels/powershell.ts @@ -55,9 +55,18 @@ export const windowsOpenClawResolver = String.raw`function Resolve-OpenClawComma function Invoke-OpenClaw { param([Parameter(ValueFromRemainingArguments = $true)][string[]] $OpenClawArgs) $command = Resolve-OpenClawCommand - if ($command.Kind -eq 'node') { - & node.exe $command.Path @OpenClawArgs - } else { - & $command.Path @OpenClawArgs + $previousErrorActionPreference = $ErrorActionPreference + $previousNativeErrorActionPreference = $PSNativeCommandUseErrorActionPreference + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + try { + if ($command.Kind -eq 'node') { + & node.exe $command.Path @OpenClawArgs + } else { + & $command.Path @OpenClawArgs + } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + $PSNativeCommandUseErrorActionPreference = $previousNativeErrorActionPreference } }`; diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 21d7db730f8..516a86b6a9a 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -17,6 +17,7 @@ import { resolveProviderAuth, resolveSnapshot, run, + runStreaming, say, startHostServer, warn, @@ -607,13 +608,13 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST } } - private captureLatestRefFailure(): void { - this.runRefOnboard(); + private async captureLatestRefFailure(): Promise { + await this.runRefOnboard(); this.showGatewayStatusCompat(); } - private runRefOnboard(): void { - this.guestPowerShellBackground( + private runRefOnboard(): Promise { + return this.guestPowerShellBackground( "ref-onboard", `$ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false @@ -624,7 +625,11 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw onboard failed with exit code $LASTEX ); } - private guestPowerShellBackground(label: string, script: string, timeoutMs: number): void { + private async guestPowerShellBackground( + label: string, + script: string, + timeoutMs: number, + ): Promise { const safeLabel = label.replaceAll(/[^A-Za-z0-9_-]/g, "-"); const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`; const fileBase = `openclaw-parallels-${nonce}`; @@ -663,11 +668,10 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`, let lastLaunchStatus = 0; for (let attempt = 1; attempt <= 3; attempt++) { this.waitForGuestReady(120); - const launch = run( - "timeout", + const launchLogPath = path.join(this.runDir, `${safeLabel}-launch-${attempt}.log`); + const launchStatus = await runStreaming( + "prlctl", [ - "20s", - "prlctl", "exec", this.options.vmName, "--current-user", @@ -677,21 +681,21 @@ if (!(Test-Path $scriptPath)) { throw "background script was not written" }`, "/c", `start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`, ], - { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) }, + { logPath: launchLogPath, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(20_000) }, ); - this.log(launch.stdout); - this.log(launch.stderr); - if (launch.status === 0 || launch.status === 124) { + const launchLog = await readFile(launchLogPath, "utf8").catch(() => ""); + this.log(launchLog); + if (launchStatus === 0 || launchStatus === 124) { launched = true; break; } - lastLaunchStatus = launch.status; - if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) { + lastLaunchStatus = launchStatus; + if (launchLog.includes("restoring")) { warn(`${label} launch retry ${attempt}: VM is still restoring`); this.waitForVmNotRestoring(120); continue; } - throw new Error(`${label} background launch failed with exit code ${launch.status}`); + throw new Error(`${label} background launch failed with exit code ${launchStatus}`); } if (!launched) { throw new Error(`${label} background launch failed with exit code ${lastLaunchStatus}`); @@ -716,8 +720,10 @@ if (Test-Path $logPath) { } } if (Test-Path $donePath) { + $backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' } + "__OPENCLAW_BACKGROUND_EXIT__:$backgroundExit" '__OPENCLAW_BACKGROUND_DONE__' - if ((Test-Path $exitPath) -and ((Get-Content -Path $exitPath -Raw).Trim() -ne '0')) { exit 23 } + if ($backgroundExit -ne '0') { exit 23 } exit 0 }`), ], @@ -728,7 +734,9 @@ if (Test-Path $donePath) { lastLogOffset = Number(offsetMatch[1]); } if (result.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) { - if (result.status !== 0) { + const exitMatch = result.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/); + const backgroundExit = exitMatch?.[1] ?? "0"; + if (backgroundExit !== "0" || (result.status !== 0 && result.status !== 124)) { throw new Error(`${label} failed`); } this.guestPowerShell( @@ -775,8 +783,8 @@ Invoke-OpenClaw update status --json`, } } - private gatewayAction(action: "restart" | "stop"): void { - this.guestPowerShellBackground( + private gatewayAction(action: "restart" | "stop"): Promise { + return this.guestPowerShellBackground( `gateway-${action}`, `$ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false @@ -826,8 +834,8 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE this.guestPowerShell(`Invoke-OpenClaw gateway status ${suffix}`); } - private verifyTurn(): void { - this.guestPowerShellBackground( + private verifyTurn(): Promise { + return this.guestPowerShellBackground( "agent-turn", `$ErrorActionPreference = 'Continue' $PSNativeCommandUseErrorActionPreference = $false diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index fa35452e520..53c5b66faec 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -13,6 +13,16 @@ describe("parallels npm update smoke", () => { expect(script).toContain("await this.server?.stop()"); }); + it("runs Windows updates through a detached done-file runner", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain("openclaw-parallels-npm-update-windows"); + expect(script).toContain("runStreaming"); + expect(script).toContain("__OPENCLAW_BACKGROUND_EXIT__"); + expect(script).toContain("__OPENCLAW_BACKGROUND_DONE__"); + expect(script).toContain("Windows update timed out"); + }); + it("scrubs future plugin entries before invoking old same-guest updaters", () => { const script = readFileSync(UPDATE_SCRIPTS_PATH, "utf8"); diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 243101b75a3..716f100d15a 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -371,10 +371,29 @@ console.log(resolveUbuntuVmName("Ubuntu missing")); expect(script).toContain("guestPowerShellBackground"); expect(script).toContain("Join-Path $env:TEMP"); expect(script).toContain("__OPENCLAW_BACKGROUND_DONE__"); + expect(script).toContain("__OPENCLAW_BACKGROUND_EXIT__"); expect(script).toContain("__OPENCLAW_LOG_OFFSET__"); + expect(script).toContain("result.status !== 0 && result.status !== 124"); expect(script).toContain('start "" /min powershell.exe'); }); + it("returns timed-out host command status when check is disabled", () => { + const result = JSON.parse( + runTsEval(` +import { run } from "./${TS_PATHS.hostCommand}"; +const result = run(process.execPath, ["-e", "process.stdout.write('partial'); setTimeout(() => {}, 1000);"], { + check: false, + quiet: true, + timeoutMs: 50, +}); +console.log(JSON.stringify(result)); +`), + ) as { status: number; stdout: string }; + + expect(result.status).toBe(124); + expect(result.stdout).toEqual(expect.any(String)); + }); + it("runs the Windows agent turn through the detached done-file runner", () => { const script = readFileSync(TS_PATHS.windows, "utf8"); @@ -398,6 +417,8 @@ console.log(resolveUbuntuVmName("Ubuntu missing")); expect(powershell).toContain("windowsOpenClawResolver"); expect(powershell).toContain("Resolve-OpenClawCommand"); expect(powershell).toContain("npm\\node_modules\\openclaw\\openclaw.mjs"); + expect(powershell).toContain("$ErrorActionPreference = 'Continue'"); + expect(powershell).toContain("$PSNativeCommandUseErrorActionPreference = $false"); expect(windows).toContain("windowsOpenClawResolver"); expect(windows).toContain("Invoke-OpenClaw gateway"); expect(windows).not.toContain("Join-Path $env:APPDATA 'npm\\\\openclaw.cmd'");