fix: harden Windows Parallels update smoke

This commit is contained in:
Peter Steinberger
2026-04-30 04:12:44 +01:00
parent d5e4ec9ea8
commit d363565375
6 changed files with 202 additions and 35 deletions

View File

@@ -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 ?? "",

View File

@@ -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> | 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<void> {
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<void> {
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 {

View File

@@ -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
}
}`;

View File

@@ -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<void> {
await this.runRefOnboard();
this.showGatewayStatusCompat();
}
private runRefOnboard(): void {
this.guestPowerShellBackground(
private runRefOnboard(): Promise<void> {
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<void> {
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<void> {
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<void> {
return this.guestPowerShellBackground(
"agent-turn",
`$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false

View File

@@ -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");

View File

@@ -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'");