From c728d604b282aa1f3343219e6f03fe993d3a6203 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 19:37:07 +0100 Subject: [PATCH] fix: harden parallels smoke harness --- scripts/e2e/parallels/guest-transports.ts | 74 +++++- scripts/e2e/parallels/host-server.ts | 89 +++++--- scripts/e2e/parallels/linux-smoke.ts | 71 ++++-- scripts/e2e/parallels/macos-smoke.ts | 24 +- scripts/e2e/parallels/npm-update-scripts.ts | 21 +- scripts/e2e/parallels/package-artifact.ts | 182 ++++++++++++--- scripts/e2e/parallels/powershell.ts | 50 ++++ scripts/e2e/parallels/provider-auth.ts | 2 +- scripts/e2e/parallels/windows-smoke.ts | 239 ++++++++++++++++---- test/scripts/parallels-smoke-model.test.ts | 47 +++- 10 files changed, 637 insertions(+), 162 deletions(-) diff --git a/scripts/e2e/parallels/guest-transports.ts b/scripts/e2e/parallels/guest-transports.ts index de4482f2950..ee63c9f765d 100644 --- a/scripts/e2e/parallels/guest-transports.ts +++ b/scripts/e2e/parallels/guest-transports.ts @@ -5,6 +5,7 @@ import type { CommandResult } from "./types.ts"; export interface GuestExecOptions { check?: boolean; + input?: string; timeoutMs?: number; } @@ -17,6 +18,7 @@ export class LinuxGuest { exec(args: string[], options: GuestExecOptions = {}): string { const result = run("prlctl", ["exec", this.vmName, "/usr/bin/env", "HOME=/root", ...args], { check: options.check, + input: options.input, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs), }); @@ -26,8 +28,23 @@ export class LinuxGuest { } bash(script: string): string { - const encoded = Buffer.from(script, "utf8").toString("base64"); - return this.exec(["bash", "-lc", `printf '%s' '${encoded}' | base64 -d | bash`]); + const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`; + const write = run( + "prlctl", + ["exec", this.vmName, "/usr/bin/env", "HOME=/root", "dd", `of=${scriptPath}`, "bs=1048576"], + { + input: script, + quiet: true, + timeoutMs: this.phases.remainingTimeoutMs(), + }, + ); + this.phases.append(write.stdout); + this.phases.append(write.stderr); + try { + return this.exec(["bash", scriptPath]); + } finally { + this.exec(["rm", "-f", scriptPath], { check: false }); + } } } @@ -75,6 +92,7 @@ export class MacosGuest { : ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args]; const result = run("prlctl", transportArgs, { check: options.check, + input: options.input, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs), }); @@ -84,7 +102,13 @@ export class MacosGuest { } sh(script: string, env: Record = {}): string { - return this.exec(["/bin/bash", "-lc", script], { env }); + const scriptPath = `/tmp/openclaw-parallels-${process.pid}-${Date.now()}.sh`; + this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], { input: script }); + try { + return this.exec(["/bin/bash", scriptPath], { env }); + } finally { + this.exec(["/bin/rm", "-f", scriptPath], { check: false }); + } } } @@ -95,27 +119,63 @@ export class WindowsGuest { ) {} exec(args: string[], options: GuestExecOptions = {}): string { + return this.run(args, options).stdout.trim(); + } + + run(args: string[], options: GuestExecOptions = {}): CommandResult { const result = run("prlctl", ["exec", this.vmName, "--current-user", ...args], { check: options.check, + input: options.input, quiet: true, timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs), }); this.phases.append(result.stdout); this.phases.append(result.stderr); - return result.stdout.trim(); + return result; } powershell(script: string, options: GuestExecOptions = {}): string { - return this.exec( + const scriptName = `openclaw-parallels-${process.pid}-${Date.now()}.ps1`; + const writeScript = `$scriptPath = Join-Path $env:TEMP ${JSON.stringify(scriptName)} +[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))`; + const write = run( + "prlctl", [ + "exec", + this.vmName, + "--current-user", "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", - encodePowerShell(script), + encodePowerShell(writeScript), ], - options, + { + input: script, + quiet: true, + timeoutMs: this.phases.remainingTimeoutMs(120_000), + }, ); + this.phases.append(write.stdout); + this.phases.append(write.stderr); + const scriptPath = `%TEMP%\\${scriptName}`; + try { + return this.exec( + [ + "cmd.exe", + "/d", + "/s", + "/c", + `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`, + ], + options, + ); + } finally { + this.exec(["cmd.exe", "/d", "/s", "/c", `del /F /Q "${scriptPath}"`], { + check: false, + timeoutMs: 30_000, + }); + } } } diff --git a/scripts/e2e/parallels/host-server.ts b/scripts/e2e/parallels/host-server.ts index fabce0daeb3..2ac5cc3f2ca 100644 --- a/scripts/e2e/parallels/host-server.ts +++ b/scripts/e2e/parallels/host-server.ts @@ -1,8 +1,7 @@ -import { createReadStream } from "node:fs"; -import { stat } from "node:fs/promises"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createServer } from "node:http"; +import { createConnection } from "node:net"; import path from "node:path"; -import { exists } from "./filesystem.ts"; import { die, run, say, sh, warn } from "./host-command.ts"; import type { HostServer } from "./types.ts"; @@ -65,43 +64,67 @@ export async function startHostServer(input: { artifactPath: string; label: string; }): Promise { - const artifactName = path.basename(input.artifactPath); - const server = createServer(async (request, response) => { - const requestPath = decodeURIComponent( - new URL(request.url ?? "/", "http://127.0.0.1").pathname, - ); - const fileName = path.basename(requestPath); - const filePath = path.join(input.dir, fileName); - if (fileName !== artifactName && !(await exists(filePath))) { - response.statusCode = 404; - response.end("not found"); - return; - } - try { - const info = await stat(filePath); - response.setHeader("Content-Length", String(info.size)); - response.setHeader("Content-Type", "application/octet-stream"); - createReadStream(filePath).pipe(response); - } catch { - response.statusCode = 404; - response.end("not found"); - } - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(input.port, "0.0.0.0", () => resolve()); - }); - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : input.port; + const actualPort = input.port || allocateHostPort(); + const child = spawn( + "python3", + ["-m", "http.server", String(actualPort), "--bind", "0.0.0.0", "--directory", input.dir], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); + await waitForHostServer(child, actualPort); say(`Serve ${input.label} on ${input.hostIp}:${actualPort}`); return { hostIp: input.hostIp, port: actualPort, stop: async () => { - await new Promise((resolve) => server.close(() => resolve())); + child.kill("SIGTERM"); + await new Promise((resolve) => { + child.once("exit", () => resolve()); + setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 2_000).unref(); + }); }, urlFor: (filePath) => `http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`, }; } + +async function waitForHostServer( + child: ChildProcessWithoutNullStreams, + port: number, +): Promise { + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const startedAt = Date.now(); + while (Date.now() - startedAt < 10_000) { + if (child.exitCode != null) { + die(`host artifact server exited early: ${stderr.trim() || `exit ${child.exitCode}`}`); + } + if (await canConnect(port)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + child.kill("SIGTERM"); + die(`host artifact server did not start on port ${port}: ${stderr.trim()}`); +} + +async function canConnect(port: number): Promise { + return await new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + socket.setTimeout(250, () => { + socket.destroy(); + resolve(false); + }); + }); +} diff --git a/scripts/e2e/parallels/linux-smoke.ts b/scripts/e2e/parallels/linux-smoke.ts index 841f29c2f13..257fe2c80cc 100755 --- a/scripts/e2e/parallels/linux-smoke.ts +++ b/scripts/e2e/parallels/linux-smoke.ts @@ -335,12 +335,14 @@ class LinuxSmoke { await this.phase("fresh.onboard-ref", 180, () => this.runRefOnboard()); await this.phase("fresh.inject-bad-plugin", 90, () => this.injectBadPluginFixture()); await this.phase("fresh.gateway-start", 240, () => this.startGatewayBackground()); - await this.phase("fresh.bad-plugin-diagnostic", 90, () => this.verifyBadPluginDiagnostic()); + await this.phase("fresh.bad-plugin-diagnostic", 90, () => + this.verifyBadPluginDiagnostic("fresh"), + ); await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus()); this.status.freshGateway = "pass"; await this.phase( "fresh.first-local-agent-turn", - Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 300), + Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 900), () => this.verifyLocalTurn(), ); this.status.freshAgent = "pass"; @@ -362,12 +364,14 @@ class LinuxSmoke { await this.phase("upgrade.inject-bad-plugin", 90, () => this.injectBadPluginFixture()); await this.phase("upgrade.onboard-ref", 180, () => this.runRefOnboard()); await this.phase("upgrade.gateway-start", 240, () => this.startGatewayBackground()); - await this.phase("upgrade.bad-plugin-diagnostic", 90, () => this.verifyBadPluginDiagnostic()); + await this.phase("upgrade.bad-plugin-diagnostic", 90, () => + this.verifyBadPluginDiagnostic("upgrade"), + ); await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus()); this.status.upgradeGateway = "pass"; await this.phase( "upgrade.first-local-agent-turn", - Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 300), + Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 900), () => this.verifyLocalTurn(), ); this.status.upgradeAgent = "pass"; @@ -550,17 +554,18 @@ plugin_dir = "/root/.openclaw/test-bad-plugin" if plugin_dir not in paths: paths.append(plugin_dir) allow = plugins.get("allow") -if isinstance(allow, list) and "test-bad-plugin" not in allow: - allow.append("test-bad-plugin") +if not isinstance(allow, list): + allow = plugins["allow"] = ["openai"] +for plugin_id in ("test-bad-plugin", "openai"): + if plugin_id not in allow: + allow.append(plugin_id) config_path.write_text(json.dumps(config, indent=2) + "\n") PY`); } private startGatewayBackground(): void { const bonjourEnv = this.disableBonjour ? " OPENCLAW_DISABLE_BONJOUR=1" : ""; - this.guestExec([ - "bash", - "-lc", + this.guestBash( String.raw`pkill -f "openclaw gateway run" >/dev/null 2>&1 || true rm -f /tmp/openclaw-parallels-linux-gateway.log setsid sh -lc ` + @@ -570,7 +575,7 @@ setsid sh -lc ` + )} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1`, ) + String.raw` >/dev/null 2>&1 < /dev/null &`, - ]); + ); const deadline = Date.now() + 240_000; while (Date.now() < deadline) { if (this.showGatewayStatusCompat(false)) { @@ -635,12 +640,34 @@ setsid sh -lc ` + throw new Error("gateway status did not become RPC-ready"); } - private verifyBadPluginDiagnostic(): void { - this.guestExec([ - "bash", - "-lc", - 'grep -F "failed to load setup entry" /tmp/openclaw-parallels-linux-gateway.log', - ]); + private async verifyBadPluginDiagnostic(lane: "fresh" | "upgrade"): Promise { + const warning = + "channel plugin manifest declares test-bad-plugin without channelConfigs metadata"; + const gatewayStartLog = await readFile( + path.join(this.runDir, `${lane}.gateway-start.log`), + "utf8", + ); + if (!gatewayStartLog.includes(warning)) { + throw new Error(`bad plugin diagnostic missing: ${warning}`); + } + this.log(warning); + this.guestBash(String.raw`set -euo pipefail +python3 - <<'PY' +import json +from pathlib import Path +config_path = Path("/root/.openclaw/openclaw.json") +config = json.loads(config_path.read_text()) if config_path.exists() else {} +plugins = config.setdefault("plugins", {}) +load = plugins.setdefault("load", {}) +paths = load.get("paths") +if isinstance(paths, list): + load["paths"] = [path for path in paths if path != "/root/.openclaw/test-bad-plugin"] +allow = plugins.get("allow") +if isinstance(allow, list): + plugins["allow"] = [plugin_id for plugin_id in allow if plugin_id != "test-bad-plugin"] +config_path.write_text(json.dumps(config, indent=2) + "\n") +PY +rm -rf /root/.openclaw/test-bad-plugin`); } private verifyLocalTurn(): void { @@ -654,21 +681,15 @@ setsid sh -lc ` + "--strict-json", ]); this.prepareAgentWorkspace(); - this.guestExec([ - "/bin/sh", - "-lc", + this.guestBash( `exec /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} openclaw agent --local --agent main --session-id parallels-linux-smoke --message ${shellQuote( "Reply with exact ASCII text OK only.", )} --json`, - ]); + ); } private prepareAgentWorkspace(): void { - this.guestExec([ - "/bin/sh", - "-lc", - posixAgentWorkspaceScript("Parallels Linux smoke test assistant."), - ]); + this.guestBash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant.")); } private async extractLastVersion(phaseId: string): Promise { diff --git a/scripts/e2e/parallels/macos-smoke.ts b/scripts/e2e/parallels/macos-smoke.ts index d894a5f05a3..bcc0d86ba1b 100755 --- a/scripts/e2e/parallels/macos-smoke.ts +++ b/scripts/e2e/parallels/macos-smoke.ts @@ -457,14 +457,14 @@ class MacosSmoke { } private async runFreshLane(): Promise { - await this.phase("fresh.restore-snapshot", 360, () => this.restoreSnapshot()); + await this.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot()); await this.phase("fresh.reset-state", 180, () => this.resetState()); await this.phase("fresh.install-main", this.targetInstallsDirectly() ? 420 : 420, () => this.installMain("openclaw-main-fresh.tgz"), ); this.status.freshVersion = await this.extractLastVersion("fresh.install-main"); await this.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion()); - await this.phase("fresh.verify-bundle-permissions", 60, () => this.verifyBundlePermissions()); + await this.phase("fresh.verify-bundle-permissions", 180, () => this.verifyBundlePermissions()); await this.phase("fresh.onboard-ref", 180, () => this.runRefOnboard()); await this.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded()); await this.phase("fresh.gateway-status", 180, () => this.verifyGateway()); @@ -473,7 +473,7 @@ class MacosSmoke { this.status.freshDashboard = "pass"; await this.phase( "fresh.first-agent-turn", - Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 240), + Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 900), () => this.verifyTurn(), ); this.status.freshAgent = "pass"; @@ -486,7 +486,7 @@ class MacosSmoke { } private async runUpgradeLane(): Promise { - await this.phase("upgrade.restore-snapshot", 360, () => this.restoreSnapshot()); + await this.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot()); await this.phase("upgrade.reset-state", 180, () => this.resetState()); await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease()); this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest"); @@ -510,7 +510,7 @@ class MacosSmoke { ); this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main"); await this.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion()); - await this.phase("upgrade.verify-bundle-permissions", 60, () => + await this.phase("upgrade.verify-bundle-permissions", 180, () => this.verifyBundlePermissions(), ); } else { @@ -530,7 +530,7 @@ class MacosSmoke { this.status.upgradeDashboard = "pass"; await this.phase( "upgrade.first-agent-turn", - Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 240), + Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 900), () => this.verifyTurn(), ); this.status.upgradeAgent = "pass"; @@ -707,10 +707,18 @@ class MacosSmoke { if (!restored) { throw new Error("snapshot restore failed"); } - if (this.snapshot.state === "poweroff") { + const status = run("prlctl", ["status", this.options.vmName], { + check: false, + quiet: true, + timeoutMs: 60_000, + }).stdout; + if (this.snapshot.state === "poweroff" || status.includes(" stopped")) { waitForVmStatus(this.options.vmName, "stopped", 360); say(`Start restored poweroff snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); + } else if (status.includes(" suspended")) { + say(`Resume restored snapshot ${this.snapshot.name}`); + run("prlctl", ["start", this.options.vmName], { quiet: true }); } this.waitForCurrentUser(); } @@ -966,7 +974,7 @@ exit 1`); ]); this.guestSh( `${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")} -exec /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} ${guestNode} ${guestOpenClawEntry} agent --agent main --session-id parallels-macos-smoke --message ${shellQuote( +exec /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} ${guestNode} ${guestOpenClawEntry} agent --local --agent main --session-id parallels-macos-smoke --message ${shellQuote( "Reply with exact ASCII text OK only.", )} --json`, ); diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index cec83ae2284..c1fdfa1026c 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -1,6 +1,6 @@ import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts"; import { shellQuote } from "./host-command.ts"; -import { psSingleQuote } from "./powershell.ts"; +import { psSingleQuote, windowsOpenClawResolver } from "./powershell.ts"; import type { ProviderAuth } from "./types.ts"; export interface NpmUpdateScriptInput { @@ -54,6 +54,7 @@ ${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} /opt/homebrew/bin/ export function windowsUpdateScript(input: NpmUpdateScriptInput): string { return `$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false +${windowsOpenClawResolver} function Remove-FuturePluginEntries { $configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' if (-not (Test-Path $configPath)) { return } @@ -73,8 +74,7 @@ function Remove-FuturePluginEntries { $config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8 } function Stop-OpenClawGatewayProcesses { - $openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' - & $openclaw gateway stop *>&1 | Out-Host + Invoke-OpenClaw gateway stop *>&1 | Out-Host Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match 'openclaw.*gateway' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } @@ -82,19 +82,18 @@ function Stop-OpenClawGatewayProcesses { Remove-FuturePluginEntries Stop-OpenClawGatewayProcesses $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' -$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json +Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" } -$version = & $openclaw --version +$version = Invoke-OpenClaw --version $version ${windowsVersionCheck(input.expectedNeedle)} -& $openclaw gateway restart -& $openclaw gateway status --deep --require-rpc -& $openclaw models set ${psSingleQuote(input.auth.modelId)} -& $openclaw config set agents.defaults.skipBootstrap true --strict-json +Invoke-OpenClaw gateway restart +Invoke-OpenClaw gateway status --deep --require-rpc +Invoke-OpenClaw models set ${psSingleQuote(input.auth.modelId)} +Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json ${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")} Set-Item -Path ('Env:' + ${psSingleQuote(input.auth.apiKeyEnv)}) -Value ${psSingleQuote(input.auth.apiKeyValue)} -& $openclaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`; +Invoke-OpenClaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`; } export function linuxUpdateScript(input: NpmUpdateScriptInput): string { diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts index 9b55f8e0c78..92cf42dd474 100644 --- a/scripts/e2e/parallels/package-artifact.ts +++ b/scripts/e2e/parallels/package-artifact.ts @@ -1,4 +1,5 @@ -import { copyFile, mkdir } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { exists, readJson } from "./filesystem.ts"; @@ -28,7 +29,13 @@ export async function ensureCurrentBuild(input: { requireControlUi?: boolean; checkDirty?: boolean; }): Promise { - void input.lockDir; + await withPackageLock(input.lockDir, async () => ensureCurrentBuildUnlocked(input)); +} + +async function ensureCurrentBuildUnlocked(input: { + requireControlUi?: boolean; + checkDirty?: boolean; +}): Promise { const head = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim(); const buildInfoPath = path.join(repoRoot, "dist/build-info.json"); let buildCommit = ""; @@ -107,39 +114,142 @@ export async function packOpenClaw(input: { return { path: tgzPath, version }; } - await ensureCurrentBuild({ - checkDirty: true, - lockDir: path.join(tmpdir(), "openclaw-parallels-build.lock"), - requireControlUi: input.requireControlUi, + return await withPackageLock(path.join(tmpdir(), "openclaw-parallels-build.lock"), async () => { + await ensureCurrentBuildUnlocked({ + checkDirty: true, + requireControlUi: input.requireControlUi, + }); + run("node", [ + "--import", + "tsx", + "--input-type=module", + "--eval", + "import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());", + ]); + if (input.stageRuntimeDeps) { + run("node", ["scripts/stage-bundled-plugin-runtime-deps.mjs"]); + } + const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(); + const output = run( + "npm", + ["pack", "--ignore-scripts", "--json", "--pack-destination", input.destination], + { + quiet: true, + }, + ).stdout; + const packed = JSON.parse(output).at(-1)?.filename as string | undefined; + if (!packed) { + die("npm pack did not report a filename"); + } + const tgzPath = path.join(input.destination, `openclaw-main-${shortHead}.tgz`); + await copyFile(path.join(input.destination, packed), tgzPath); + const buildCommit = await packageBuildCommitFromTgz(tgzPath); + if (!buildCommit) { + die(`failed to read packed build commit from ${tgzPath}`); + } + say(`Packed ${tgzPath}`); + return { buildCommit, buildCommitShort: buildCommit.slice(0, 7), path: tgzPath }; }); - run("node", [ - "--import", - "tsx", - "--input-type=module", - "--eval", - "import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());", - ]); - if (input.stageRuntimeDeps) { - run("node", ["scripts/stage-bundled-plugin-runtime-deps.mjs"]); - } - const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(); - const output = run( - "npm", - ["pack", "--ignore-scripts", "--json", "--pack-destination", input.destination], - { - quiet: true, - }, - ).stdout; - const packed = JSON.parse(output).at(-1)?.filename as string | undefined; - if (!packed) { - die("npm pack did not report a filename"); - } - const tgzPath = path.join(input.destination, `openclaw-main-${shortHead}.tgz`); - await copyFile(path.join(input.destination, packed), tgzPath); - const buildCommit = await packageBuildCommitFromTgz(tgzPath); - if (!buildCommit) { - die(`failed to read packed build commit from ${tgzPath}`); - } - say(`Packed ${tgzPath}`); - return { buildCommit, buildCommitShort: buildCommit.slice(0, 7), path: tgzPath }; +} + +async function withPackageLock(lockDir: string, fn: () => Promise): Promise { + const ownerToken = randomUUID(); + await acquirePackageLock(lockDir, ownerToken); + try { + return await fn(); + } finally { + await releasePackageLock(lockDir, ownerToken); + } +} + +async function acquirePackageLock(lockDir: string, ownerToken: string): Promise { + const timeoutMs = Number(process.env.OPENCLAW_PARALLELS_PACKAGE_LOCK_TIMEOUT_MS || 30 * 60_000); + const staleMs = Number(process.env.OPENCLAW_PARALLELS_PACKAGE_LOCK_STALE_MS || 2 * 60 * 60_000); + const startedAt = Date.now(); + let announcedWait = false; + while (Date.now() - startedAt < timeoutMs) { + try { + await mkdir(lockDir); + await writeLockOwner(lockDir, ownerToken); + return; + } catch (error) { + if (!isErrorCode(error, "EEXIST")) { + throw error; + } + } + await removeStalePackageLock(lockDir, staleMs); + if (!announcedWait) { + say(`Wait for Parallels package lock: ${lockDir}`); + announcedWait = true; + } + await delay(1_000); + } + throw new Error(`timed out waiting for Parallels package lock: ${lockDir}`); +} + +async function writeLockOwner(lockDir: string, ownerToken: string): Promise { + await writeFile( + path.join(lockDir, "owner.json"), + `${JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + token: ownerToken, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +async function releasePackageLock(lockDir: string, ownerToken: string): Promise { + const owner = await readLockOwner(lockDir); + if (owner?.token === ownerToken) { + await rm(lockDir, { force: true, recursive: true }); + } +} + +async function removeStalePackageLock(lockDir: string, staleMs: number): Promise { + const owner = await readLockOwner(lockDir); + if (owner?.pid && isProcessAlive(owner.pid)) { + return; + } + const ageMs = Date.now() - ((await stat(lockDir).catch(() => undefined))?.mtimeMs ?? Date.now()); + if (owner || ageMs >= staleMs) { + await rm(lockDir, { force: true, recursive: true }).catch(() => undefined); + } +} + +async function readLockOwner(lockDir: string): Promise<{ pid?: number; token?: string } | null> { + const text = await readFile(path.join(lockDir, "owner.json"), "utf8").catch(() => ""); + if (!text) { + return null; + } + try { + const parsed = JSON.parse(text) as { pid?: unknown; token?: unknown }; + return { + pid: typeof parsed.pid === "number" ? parsed.pid : undefined, + token: typeof parsed.token === "string" ? parsed.token : undefined, + }; + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/scripts/e2e/parallels/powershell.ts b/scripts/e2e/parallels/powershell.ts index 11a88d4bdab..5b8b7ac24b8 100644 --- a/scripts/e2e/parallels/powershell.ts +++ b/scripts/e2e/parallels/powershell.ts @@ -11,3 +11,53 @@ export function encodePowerShell(script: string): string { "base64", ); } + +export const windowsOpenClawResolver = String.raw`function Resolve-OpenClawCommand { + if ($script:OpenClawResolvedCommand) { return $script:OpenClawResolvedCommand } + $shimCandidates = @() + if ($env:APPDATA) { + $shimCandidates += Join-Path $env:APPDATA 'npm\openclaw.cmd' + $shimCandidates += Join-Path $env:APPDATA 'npm\openclaw.ps1' + } + foreach ($name in @('openclaw.cmd', 'openclaw.ps1', 'openclaw')) { + $command = Get-Command $name -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command -and $command.Source) { $shimCandidates += $command.Source } + } + $npmPrefix = $null + try { + $npmPrefix = (& npm.cmd prefix -g 2>$null | Select-Object -First 1) + } catch {} + if ($npmPrefix) { + $shimCandidates += Join-Path $npmPrefix 'openclaw.cmd' + $shimCandidates += Join-Path $npmPrefix 'openclaw.ps1' + } + foreach ($candidate in $shimCandidates) { + if ($candidate -and (Test-Path $candidate)) { + $script:OpenClawResolvedCommand = @{ Kind = 'shim'; Path = $candidate } + return $script:OpenClawResolvedCommand + } + } + $entryCandidates = @() + if ($env:APPDATA) { + $entryCandidates += Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs' + } + if ($npmPrefix) { + $entryCandidates += Join-Path $npmPrefix 'node_modules\openclaw\openclaw.mjs' + } + foreach ($candidate in $entryCandidates) { + if ($candidate -and (Test-Path $candidate)) { + $script:OpenClawResolvedCommand = @{ Kind = 'node'; Path = $candidate } + return $script:OpenClawResolvedCommand + } + } + throw 'openclaw command not found in PATH, APPDATA npm, or npm global prefix' +} +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 + } +}`; diff --git a/scripts/e2e/parallels/provider-auth.ts b/scripts/e2e/parallels/provider-auth.ts index f2d0338bb4d..213b740b7c8 100644 --- a/scripts/e2e/parallels/provider-auth.ts +++ b/scripts/e2e/parallels/provider-auth.ts @@ -42,7 +42,7 @@ export function resolveProviderAuth(input: { apiKeyEnv: input.apiKeyEnv || "OPENAI_API_KEY", authChoice: "openai-api-key", authKeyFlag: "openai-api-key", - modelId: input.modelId || process.env.OPENCLAW_PARALLELS_OPENAI_MODEL || "openai/gpt-5.5", + modelId: input.modelId || process.env.OPENCLAW_PARALLELS_OPENAI_MODEL || "openai/gpt-5.4", }, }; const resolved = providerDefaults[input.provider]; diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 9744fe425d5..21d7db730f8 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -32,7 +32,7 @@ import { WindowsGuest } from "./guest-transports.ts"; import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts"; import { waitForVmStatus } from "./parallels-vm.ts"; import { PhaseRunner } from "./phase-runner.ts"; -import { psArray, psSingleQuote } from "./powershell.ts"; +import { encodePowerShell, psArray, psSingleQuote, windowsOpenClawResolver } from "./powershell.ts"; import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts"; interface WindowsOptions { @@ -481,14 +481,39 @@ class WindowsSmoke { script: string, options: { check?: boolean; timeoutMs?: number } = {}, ): string { - return this.guest.powershell(script, options); + return this.guest.powershell(`${windowsOpenClawResolver}\n${script}`, options); } private restoreSnapshot(): void { + this.waitForVmNotRestoring(240); say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`); - run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], { - quiet: true, - }); + let restored = false; + for (let attempt = 1; attempt <= 3; attempt++) { + const result = run( + "prlctl", + ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], + { + check: false, + quiet: true, + }, + ); + this.log(result.stdout); + this.log(result.stderr); + if (result.status === 0) { + restored = true; + break; + } + if (result.stdout.includes("restoring") || result.stderr.includes("restoring")) { + warn(`snapshot-switch retry ${attempt}: VM is still restoring`); + this.waitForVmNotRestoring(240); + continue; + } + throw new Error(`snapshot-switch failed with exit code ${result.status}`); + } + if (!restored) { + throw new Error("snapshot-switch failed after restoring-state retries"); + } + this.waitForVmNotRestoring(240); if (this.snapshot.state === "poweroff") { waitForVmStatus(this.options.vmName, "stopped", 240); say(`Start restored poweroff snapshot ${this.snapshot.name}`); @@ -496,6 +521,21 @@ class WindowsSmoke { } } + private waitForVmNotRestoring(timeoutSeconds: number): void { + const deadline = Date.now() + timeoutSeconds * 1000; + while (Date.now() < deadline) { + const status = run("prlctl", ["status", this.options.vmName], { + check: false, + quiet: true, + }).stdout; + if (!status.includes(" restoring")) { + return; + } + run("sleep", ["5"], { quiet: true }); + } + throw new Error(`VM ${this.options.vmName} did not leave restoring state`); + } + private waitForGuestReady(timeoutSeconds = 240): void { const deadline = Date.now() + timeoutSeconds * 1000; while (Date.now() < deadline) { @@ -523,7 +563,7 @@ class WindowsSmoke { $script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} & ([scriptblock]::Create($script))${versionArg} -NoOnboard if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" } -& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version +Invoke-OpenClaw --version if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, { timeoutMs: 420_000 }, ); @@ -540,7 +580,7 @@ $tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)} curl.exe -fsSL ${psSingleQuote(tgzUrl)} -o $tgz npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } -& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version +Invoke-OpenClaw --version if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, { timeoutMs: 420_000 }, ); @@ -561,9 +601,7 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST } private verifyVersionContains(needle: string): void { - const version = this.guestPowerShell( - "& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version", - ); + const version = this.guestPowerShell("Invoke-OpenClaw --version"); if (!version.includes(needle)) { throw new Error(`version mismatch: expected substring ${needle}`); } @@ -575,16 +613,139 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LAST } private runRefOnboard(): void { - this.guestPowerShell( - `$ErrorActionPreference = 'Stop' + this.guestPowerShellBackground( + "ref-onboard", + `$ErrorActionPreference = 'Continue' +$PSNativeCommandUseErrorActionPreference = $false Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)} -$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw onboard --non-interactive --mode local --auth-choice ${psSingleQuote(this.auth.authChoice)} --secret-input-mode ref --gateway-port 18789 --gateway-bind loopback --install-daemon --skip-skills --skip-health --accept-risk --json +Invoke-OpenClaw onboard --non-interactive --mode local --auth-choice ${psSingleQuote(this.auth.authChoice)} --secret-input-mode ref --gateway-port 18789 --gateway-bind loopback --install-daemon --skip-skills --skip-health --accept-risk --json if ($LASTEXITCODE -ne 0) { throw "openclaw onboard failed with exit code $LASTEXITCODE" }`, - { timeoutMs: 720_000 }, + 720_000, ); } + private guestPowerShellBackground(label: string, script: string, timeoutMs: number): 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}`; + const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)} +$scriptPath = "$base.ps1" +$logPath = "$base.log" +$donePath = "$base.done" +$exitPath = "$base.exit"`; + const payload = Buffer.from( + `$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +${windowsOpenClawResolver} +${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 +}`, + "utf8", + ).toString("base64"); + this.guestPowerShell( + `$payload = ${psSingleQuote(payload)} +${pathsScript} +Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue +[System.IO.File]::WriteAllText($scriptPath, [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)), [System.Text.UTF8Encoding]::new($false)) +if (!(Test-Path $scriptPath)) { throw "background script was not written" }`, + { timeoutMs: 30_000 }, + ); + let launched = false; + let lastLaunchStatus = 0; + for (let attempt = 1; attempt <= 3; attempt++) { + this.waitForGuestReady(120); + const launch = run( + "timeout", + [ + "20s", + "prlctl", + "exec", + this.options.vmName, + "--current-user", + "cmd.exe", + "/d", + "/s", + "/c", + `start "" /min powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "%TEMP%\\${fileBase}.ps1"`, + ], + { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) }, + ); + this.log(launch.stdout); + this.log(launch.stderr); + if (launch.status === 0 || launch.status === 124) { + launched = true; + break; + } + lastLaunchStatus = launch.status; + if (launch.stdout.includes("restoring") || launch.stderr.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}`); + } + if (!launched) { + throw new Error(`${label} background launch failed with exit code ${lastLaunchStatus}`); + } + const deadline = Date.now() + timeoutMs; + let lastLogOffset = 0; + while (Date.now() < deadline) { + const result = this.guest.run( + [ + "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) { + '__OPENCLAW_BACKGROUND_DONE__' + if ((Test-Path $exitPath) -and ((Get-Content -Path $exitPath -Raw).Trim() -ne '0')) { exit 23 } + exit 0 +}`), + ], + { check: false, timeoutMs: this.remainingPhaseTimeoutMs(30_000) }, + ); + const offsetMatch = result.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/); + if (offsetMatch) { + lastLogOffset = Number(offsetMatch[1]); + } + if (result.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) { + if (result.status !== 0) { + throw new Error(`${label} failed`); + } + this.guestPowerShell( + `${pathsScript} +Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`, + { + check: false, + timeoutMs: 30_000, + }, + ); + return; + } + run("sleep", ["5"], { quiet: true }); + } + throw new Error(`${label} timed out`); + } + private runDevChannelUpdate(): void { this.guestPowerShell( `$ErrorActionPreference = 'Stop' @@ -592,11 +753,10 @@ $portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps $env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH" where.exe git.exe $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' -$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw update --channel dev --yes --json +Invoke-OpenClaw update --channel dev --yes --json if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" } -& $openclaw --version -& $openclaw update status --json`, +Invoke-OpenClaw --version +Invoke-OpenClaw update status --json`, { timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200) * 1000 }, ); } @@ -606,7 +766,7 @@ if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXI `$portableGit = Join-Path (Join-Path (Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps') 'portable-git') '' $env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH" where.exe git.exe -& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') update status --json`, +Invoke-OpenClaw update status --json`, ); for (const needle of ['"installKind": "git"', '"value": "dev"', '"branch": "main"']) { if (!status.includes(needle)) { @@ -616,11 +776,13 @@ where.exe git.exe } private gatewayAction(action: "restart" | "stop"): void { - this.guestPowerShell( - `$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw gateway ${action} + this.guestPowerShellBackground( + `gateway-${action}`, + `$ErrorActionPreference = 'Continue' +$PSNativeCommandUseErrorActionPreference = $false +Invoke-OpenClaw gateway ${action} if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTEXITCODE" }`, - { timeoutMs: 420_000 }, + 420_000, ); } @@ -633,7 +795,7 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE const start = Date.now(); while (Date.now() < deadline) { const probe = this.guestPowerShell( - "& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway probe --url ws://127.0.0.1:18789 --timeout 30000 --json", + "Invoke-OpenClaw gateway probe --url ws://127.0.0.1:18789 --timeout 30000 --json", { check: false, timeoutMs: 60_000 }, ); if (/"ok"\s*:\s*true/.test(probe)) { @@ -643,7 +805,7 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE warn( `gateway-reachable recovery: gateway start after ${Math.floor((Date.now() - start) / 1000)}s`, ); - this.guestPowerShell("& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway start", { + this.guestPowerShell("Invoke-OpenClaw gateway start", { check: false, timeoutMs: 120_000, }); @@ -657,22 +819,21 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE } private showGatewayStatusCompat(): void { - const help = this.guestPowerShell( - "& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway status --help", - { - check: false, - }, - ); + const help = this.guestPowerShell("Invoke-OpenClaw gateway status --help", { + check: false, + }); const suffix = help.includes("--require-rpc") ? "--deep --require-rpc" : "--deep"; - this.guestPowerShell(`& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') gateway status ${suffix}`); + this.guestPowerShell(`Invoke-OpenClaw gateway status ${suffix}`); } private verifyTurn(): void { - this.guestPowerShell( - `$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd' -& $openclaw models set ${psSingleQuote(this.auth.modelId)} + this.guestPowerShellBackground( + "agent-turn", + `$ErrorActionPreference = 'Continue' +$PSNativeCommandUseErrorActionPreference = $false +Invoke-OpenClaw models set ${psSingleQuote(this.auth.modelId)} if ($LASTEXITCODE -ne 0) { throw "models set failed" } -& $openclaw config set agents.defaults.skipBootstrap true --strict-json +Invoke-OpenClaw config set agents.defaults.skipBootstrap true --strict-json if ($LASTEXITCODE -ne 0) { throw "config set failed" } ${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")} Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)} @@ -687,11 +848,11 @@ $args = ${psArray([ "Reply with exact ASCII text OK only.", "--json", ])} -$output = & $openclaw @args 2>&1 +$output = Invoke-OpenClaw @args 2>&1 if ($null -ne $output) { $output | ForEach-Object { $_ } } if ($LASTEXITCODE -ne 0) { throw "agent failed with exit code $LASTEXITCODE" } if (($output | Out-String) -notmatch '"finalAssistant(Raw|Visible)Text":\\s*"OK"') { throw 'openclaw agent finished without OK response' }`, - { timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900) * 1000 }, + Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900) * 1000, ); } diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 780d40cedf9..243101b75a3 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -26,6 +26,7 @@ const TS_PATHS = { packageArtifact: "scripts/e2e/parallels/package-artifact.ts", parallelsVm: "scripts/e2e/parallels/parallels-vm.ts", phaseRunner: "scripts/e2e/parallels/phase-runner.ts", + powershell: "scripts/e2e/parallels/powershell.ts", providerAuth: "scripts/e2e/parallels/provider-auth.ts", snapshots: "scripts/e2e/parallels/snapshots.ts", windows: "scripts/e2e/parallels/windows-smoke.ts", @@ -86,7 +87,7 @@ describe("Parallels smoke model selection", () => { const providerAuth = readFileSync(TS_PATHS.providerAuth, "utf8"); expect(providerAuth).toContain("OPENCLAW_PARALLELS_OPENAI_MODEL"); - expect(providerAuth).toContain("openai/gpt-5.5"); + expect(providerAuth).toContain("openai/gpt-5.4"); expect(providerAuth).toContain('authChoice: "openai-api-key"'); expect(providerAuth).toContain('authChoice: "apiKey"'); expect(providerAuth).toContain('authChoice: "minimax-global-api"'); @@ -116,11 +117,14 @@ describe("Parallels smoke model selection", () => { expect(common).toContain('export * from "./snapshots.ts"'); expect(hostCommand).toContain("export function shellQuote"); expect(laneRunner).toContain("export async function runSmokeLane"); + expect(packageArtifact).toContain("withPackageLock"); + expect(packageArtifact).toContain("Wait for Parallels package lock"); expect(packageArtifact).toContain("export async function packageVersionFromTgz"); expect(packageArtifact).toContain("export async function packOpenClaw"); expect(parallelsVm).toContain("export function resolveUbuntuVmName"); expect(parallelsVm).toContain("export function waitForVmStatus"); expect(hostServer).toContain("export async function startHostServer"); + expect(hostServer).toContain("http.server"); expect(snapshots).toContain("export function resolveSnapshot"); for (const scriptPath of OS_TS_PATHS) { @@ -215,7 +219,7 @@ console.log(resolveUbuntuVmName("Ubuntu missing")); apiKeyValue: "sk-openai", authChoice: "openai-api-key", authKeyFlag: "openai-api-key", - modelId: "openai/gpt-5.5", + modelId: "openai/gpt-5.4", }); expect( @@ -336,6 +340,7 @@ console.log(resolveUbuntuVmName("Ubuntu missing")); expect(orchestrator).not.toContain("Remove-FuturePluginEntries"); expect(updateScripts).toContain("Remove-FuturePluginEntries"); expect(updateScripts).toContain("scrub_future_plugin_entries"); + expect(updateScripts).toContain("Invoke-OpenClaw update"); expect(updateScripts).toContain("Parallels npm update smoke test assistant."); }); @@ -359,4 +364,42 @@ console.log(resolveUbuntuVmName("Ubuntu missing")); expect(script).toContain("gateway start"); expect(script).toContain("gateway-reachable recovery"); }); + + it("runs Windows ref onboarding through a detached done-file runner", () => { + const script = readFileSync(TS_PATHS.windows, "utf8"); + + expect(script).toContain("guestPowerShellBackground"); + expect(script).toContain("Join-Path $env:TEMP"); + expect(script).toContain("__OPENCLAW_BACKGROUND_DONE__"); + expect(script).toContain("__OPENCLAW_LOG_OFFSET__"); + expect(script).toContain('start "" /min powershell.exe'); + }); + + it("runs the Windows agent turn through the detached done-file runner", () => { + const script = readFileSync(TS_PATHS.windows, "utf8"); + + expect(script).toContain('guestPowerShellBackground(\n "agent-turn"'); + expect(script).toContain("OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S"); + expect(script).toContain("finalAssistant(Raw|Visible)Text"); + }); + + it("waits through transient Windows restoring state before VM operations", () => { + const script = readFileSync(TS_PATHS.windows, "utf8"); + + expect(script).toContain("waitForVmNotRestoring"); + expect(script).toContain("snapshot-switch retry"); + expect(script).toContain("launch retry"); + }); + + it("resolves Windows OpenClaw commands without assuming the npm shim path", () => { + const powershell = readFileSync(TS_PATHS.powershell, "utf8"); + const windows = readFileSync(TS_PATHS.windows, "utf8"); + + expect(powershell).toContain("windowsOpenClawResolver"); + expect(powershell).toContain("Resolve-OpenClawCommand"); + expect(powershell).toContain("npm\\node_modules\\openclaw\\openclaw.mjs"); + expect(windows).toContain("windowsOpenClawResolver"); + expect(windows).toContain("Invoke-OpenClaw gateway"); + expect(windows).not.toContain("Join-Path $env:APPDATA 'npm\\\\openclaw.cmd'"); + }); });