mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix: harden Windows Parallels update smoke
This commit is contained in:
@@ -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 ?? "",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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'");
|
||||
|
||||
Reference in New Issue
Block a user