fix: harden parallels smoke harness

This commit is contained in:
Peter Steinberger
2026-04-29 19:37:07 +01:00
parent 86956f71e6
commit c728d604b2
10 changed files with 637 additions and 162 deletions

View File

@@ -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, string> = {}): 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,
});
}
}
}

View File

@@ -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<HostServer> {
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<void>((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<void>((resolve) => server.close(() => resolve()));
child.kill("SIGTERM");
await new Promise<void>((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<void> {
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<boolean> {
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);
});
});
}

View File

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

View File

@@ -457,14 +457,14 @@ class MacosSmoke {
}
private async runFreshLane(): Promise<void> {
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<void> {
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`,
);

View File

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

View File

@@ -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> {
void input.lockDir;
await withPackageLock(input.lockDir, async () => ensureCurrentBuildUnlocked(input));
}
async function ensureCurrentBuildUnlocked(input: {
requireControlUi?: boolean;
checkDirty?: boolean;
}): Promise<void> {
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<T>(lockDir: string, fn: () => Promise<T>): Promise<T> {
const ownerToken = randomUUID();
await acquirePackageLock(lockDir, ownerToken);
try {
return await fn();
} finally {
await releasePackageLock(lockDir, ownerToken);
}
}
async function acquirePackageLock(lockDir: string, ownerToken: string): Promise<void> {
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<void> {
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<void> {
const owner = await readLockOwner(lockDir);
if (owner?.token === ownerToken) {
await rm(lockDir, { force: true, recursive: true });
}
}
async function removeStalePackageLock(lockDir: string, staleMs: number): Promise<void> {
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<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

View File

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

View File

@@ -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];

View File

@@ -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,
);
}

View File

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