refactor: convert parallels smoke scripts to typescript

This commit is contained in:
Peter Steinberger
2026-04-29 12:52:42 +01:00
parent 016f5ae862
commit 39f810911c
23 changed files with 4829 additions and 8038 deletions

1065
scripts/e2e/parallels-linux-smoke.sh Normal file → Executable file

File diff suppressed because it is too large Load Diff

2126
scripts/e2e/parallels-macos-smoke.sh Normal file → Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2782
scripts/e2e/parallels-windows-smoke.sh Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
export function posixAgentWorkspaceScript(purpose: string): string {
return `set -eu
workspace="\${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
mkdir -p "$workspace/.openclaw"
cat > "$workspace/IDENTITY.md" <<'IDENTITY_EOF'
# Identity
- Name: OpenClaw
- Purpose: ${purpose}
IDENTITY_EOF
cat > "$workspace/.openclaw/workspace-state.json" <<'STATE_EOF'
{
"version": 1,
"setupCompletedAt": "2026-01-01T00:00:00.000Z"
}
STATE_EOF
rm -f "$workspace/BOOTSTRAP.md"`;
}
export function windowsAgentWorkspaceScript(purpose: string): string {
return `$workspace = $env:OPENCLAW_WORKSPACE_DIR
if (-not $workspace) { $workspace = Join-Path $env:USERPROFILE '.openclaw\\workspace' }
$stateDir = Join-Path $workspace '.openclaw'
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
@'
# Identity
- Name: OpenClaw
- Purpose: ${purpose}
'@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8
@'
{
"version": 1,
"setupCompletedAt": "2026-01-01T00:00:00.000Z"
}
'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8
Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue`;
}

View File

@@ -0,0 +1,7 @@
export * from "./filesystem.ts";
export * from "./host-command.ts";
export * from "./host-server.ts";
export * from "./package-artifact.ts";
export * from "./provider-auth.ts";
export * from "./snapshots.ts";
export * from "./types.ts";

View File

@@ -0,0 +1,39 @@
import { writeFileSync, rmSync } from "node:fs";
import { mkdtempSync } from "node:fs";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
export async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
export async function readJson<T>(filePath: string): Promise<T> {
return JSON.parse(await readFile(filePath, "utf8")) as T;
}
export async function writeJson(filePath: string, value: unknown): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
export async function makeTempDir(prefix: string): Promise<string> {
return mkdtempSync(path.join(tmpdir(), prefix));
}
export async function cleanupPath(filePath: string): Promise<void> {
await rm(filePath, { force: true, recursive: true }).catch(() => undefined);
}
export function cleanupPathSync(filePath: string): void {
rmSync(filePath, { force: true, recursive: true });
}
export function writeExecutable(filePath: string, content: string): void {
writeFileSync(filePath, content, { encoding: "utf8", mode: 0o755 });
}

View File

@@ -0,0 +1,116 @@
import { run } from "./host-command.ts";
import type { PhaseRunner } from "./phase-runner.ts";
import { encodePowerShell } from "./powershell.ts";
export interface GuestExecOptions {
check?: boolean;
timeoutMs?: number;
}
export class LinuxGuest {
constructor(
private vmName: string,
private phases: PhaseRunner,
) {}
exec(args: string[], options: GuestExecOptions = {}): string {
const result = run("prlctl", ["exec", this.vmName, "/usr/bin/env", "HOME=/root", ...args], {
check: options.check,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
return result.stdout.trim();
}
bash(script: string): string {
const encoded = Buffer.from(script, "utf8").toString("base64");
return this.exec(["bash", "-lc", `printf '%s' '${encoded}' | base64 -d | bash`]);
}
}
export interface MacosGuestOptions extends GuestExecOptions {
env?: Record<string, string>;
}
export class MacosGuest {
constructor(
private input: {
vmName: string;
getUser: () => string;
getTransport: () => "current-user" | "sudo";
resolveDesktopHome: (user: string) => string;
path: string;
},
private phases: PhaseRunner,
) {}
exec(args: string[], options: MacosGuestOptions = {}): string {
const envArgs = Object.entries({ PATH: this.input.path, ...options.env }).map(
([key, value]) => `${key}=${value}`,
);
const user = this.input.getUser();
const transportArgs =
this.input.getTransport() === "sudo"
? [
"exec",
this.input.vmName,
"/usr/bin/sudo",
"-H",
"-u",
user,
"/usr/bin/env",
`HOME=${this.input.resolveDesktopHome(user)}`,
`USER=${user}`,
`LOGNAME=${user}`,
...envArgs,
...args,
]
: ["exec", this.input.vmName, "--current-user", "/usr/bin/env", ...envArgs, ...args];
const result = run("prlctl", transportArgs, {
check: options.check,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
return result.stdout.trim();
}
sh(script: string, env: Record<string, string> = {}): string {
return this.exec(["/bin/bash", "-lc", script], { env });
}
}
export class WindowsGuest {
constructor(
private vmName: string,
private phases: PhaseRunner,
) {}
exec(args: string[], options: GuestExecOptions = {}): string {
const result = run("prlctl", ["exec", this.vmName, "--current-user", ...args], {
check: options.check,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
return result.stdout.trim();
}
powershell(script: string, options: GuestExecOptions = {}): string {
return this.exec(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(script),
],
options,
);
}
}

View File

@@ -0,0 +1,122 @@
import { spawn, spawnSync, type SpawnOptions } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { CommandResult, RunOptions } from "./types.ts";
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
export function say(message: string): void {
process.stdout.write(`==> ${message}\n`);
}
export function warn(message: string): void {
process.stderr.write(`warn: ${message}\n`);
}
export function die(message: string): never {
process.stderr.write(`error: ${message}\n`);
process.exit(1);
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function run(command: string, args: string[], options: RunOptions = {}): CommandResult {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
encoding: "utf8",
env: { ...process.env, ...options.env },
input: options.input,
maxBuffer: 50 * 1024 * 1024,
stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
timeout: options.timeoutMs,
});
if (result.error) {
throw result.error;
}
const status = result.status ?? (result.signal ? 128 : 1);
const commandResult = {
stderr: result.stderr ?? "",
stdout: result.stdout ?? "",
status,
};
if (options.check !== false && status !== 0) {
if (commandResult.stdout) {
process.stdout.write(commandResult.stdout);
}
if (commandResult.stderr) {
process.stderr.write(commandResult.stderr);
}
die(`command failed (${status}): ${[command, ...args].join(" ")}`);
}
return commandResult;
}
export function sh(script: string, options: RunOptions = {}): CommandResult {
return run("bash", ["-lc", script], options);
}
export async function runStreaming(
command: string,
args: string[],
options: RunOptions & { logPath?: string } = {},
): Promise<number> {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: ["pipe", "pipe", "pipe"],
} satisfies SpawnOptions);
let log = "";
const append = (chunk: Buffer): void => {
const text = chunk.toString("utf8");
log += text;
if (!options.quiet) {
process.stdout.write(text);
}
};
child.stdout?.on("data", append);
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
log += text;
if (!options.quiet) {
process.stderr.write(text);
}
});
if (options.input != null) {
child.stdin?.end(options.input);
} else {
child.stdin?.end();
}
let timedOut = false;
const timer =
options.timeoutMs == null
? undefined
: setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 2_000).unref();
}, options.timeoutMs);
child.on("error", reject);
child.on("close", async (code, signal) => {
if (timer) {
clearTimeout(timer);
}
if (options.logPath) {
await writeFile(options.logPath, log, "utf8");
}
if (timedOut) {
resolve(124);
} else {
resolve(code ?? (signal ? 128 : 1));
}
});
});
}

View File

@@ -0,0 +1,107 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer } from "node:http";
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";
export function resolveHostIp(explicit = ""): string {
if (explicit) {
return explicit;
}
const output = sh("ifconfig | awk '/inet 10\\.211\\./ { print $2; exit }'", {
quiet: true,
}).stdout.trim();
if (!output) {
die("failed to detect Parallels host IP; pass --host-ip");
}
return output;
}
export function allocateHostPort(): number {
return Number(
run(
"python3",
[
"-c",
"import socket; s=socket.socket(); s.bind(('0.0.0.0', 0)); print(s.getsockname()[1]); s.close()",
],
{ quiet: true },
).stdout.trim(),
);
}
export async function isHostPortFree(port: number): Promise<boolean> {
return await new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "0.0.0.0", () => {
server.close(() => resolve(true));
});
});
}
export async function resolveHostPort(
port: number,
explicit: boolean,
defaultPort: number,
): Promise<number> {
if (await isHostPortFree(port)) {
return port;
}
if (explicit) {
die(`host port ${port} already in use`);
}
const allocated = allocateHostPort();
warn(`host port ${defaultPort} busy; using ${allocated}`);
return allocated;
}
export async function startHostServer(input: {
dir: string;
hostIp: string;
port: number;
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;
say(`Serve ${input.label} on ${input.hostIp}:${actualPort}`);
return {
hostIp: input.hostIp,
port: actualPort,
stop: async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
},
urlFor: (filePath) =>
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
};
}

View File

@@ -0,0 +1,789 @@
#!/usr/bin/env -S pnpm tsx
import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
makeTempDir,
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
parseBoolEnv,
parseMode,
parseProvider,
repoRoot,
resolveHostIp,
resolveHostPort,
resolveLatestVersion,
resolveProviderAuth,
resolveSnapshot,
run,
say,
shellQuote,
startHostServer,
warn,
writeJson,
type HostServer,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
import { LinuxGuest } from "./guest-transports.ts";
import { PhaseRunner } from "./phase-runner.ts";
interface LinuxOptions {
vmName: string;
vmNameExplicit: boolean;
snapshotHint: string;
mode: Mode;
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
hostPort: number;
hostPortExplicit: boolean;
hostIp?: string;
latestVersion?: string;
installVersion?: string;
targetPackageSpec?: string;
keepServer: boolean;
json: boolean;
}
interface LinuxSummary {
vm: string;
snapshotHint: string;
snapshotId: string;
mode: Mode;
provider: Provider;
latestVersion: string;
installVersion: string;
targetPackageSpec: string;
currentHead: string;
runDir: string;
daemon: string;
freshMain: {
status: string;
version: string;
gateway: string;
agent: string;
};
upgrade: {
status: string;
latestVersionInstalled: string;
mainVersion: string;
gateway: string;
agent: string;
};
}
const defaultOptions = (): LinuxOptions => ({
apiKeyEnv: undefined,
hostIp: undefined,
hostPort: 18427,
hostPortExplicit: false,
installUrl: "https://openclaw.ai/install.sh",
installVersion: "",
json: false,
keepServer: false,
latestVersion: "",
mode: "both",
modelId: undefined,
provider: "openai",
snapshotHint: "fresh",
targetPackageSpec: "",
vmName: "Ubuntu 24.04.3 ARM64",
vmNameExplicit: false,
});
function usage(): string {
return `Usage: bash scripts/e2e/parallels-linux-smoke.sh [options]
Options:
--vm <name> Parallels VM name. Default: "Ubuntu 24.04.3 ARM64"
Falls back to the closest Ubuntu VM when omitted and unavailable.
--snapshot-hint <name> Snapshot name substring/fuzzy match. Default: "fresh"
--mode <fresh|upgrade|both>
--provider <openai|anthropic|minimax>
Provider auth/model lane. Default: openai
--model <provider/model> Override the model used for the agent-turn smoke.
--api-key-env <var> Host env var name for provider API key.
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.sh
--host-port <port> Host HTTP port for current-main tgz. Default: 18427
--host-ip <ip> Override Parallels host IP.
--latest-version <ver> Override npm latest version lookup.
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main.
--keep-server Leave temp host HTTP server running.
--json Print machine-readable JSON summary.
-h, --help Show help.
`;
}
function parseArgs(argv: string[]): LinuxOptions {
const options = defaultOptions();
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--":
break;
case "--vm":
options.vmName = ensureValue(argv, i, arg);
options.vmNameExplicit = true;
i++;
break;
case "--snapshot-hint":
options.snapshotHint = ensureValue(argv, i, arg);
i++;
break;
case "--mode":
options.mode = parseMode(ensureValue(argv, i, arg));
i++;
break;
case "--provider":
options.provider = parseProvider(ensureValue(argv, i, arg));
i++;
break;
case "--model":
options.modelId = ensureValue(argv, i, arg);
i++;
break;
case "--api-key-env":
case "--openai-api-key-env":
options.apiKeyEnv = ensureValue(argv, i, arg);
i++;
break;
case "--install-url":
options.installUrl = ensureValue(argv, i, arg);
i++;
break;
case "--host-port":
options.hostPort = Number(ensureValue(argv, i, arg));
options.hostPortExplicit = true;
i++;
break;
case "--host-ip":
options.hostIp = ensureValue(argv, i, arg);
i++;
break;
case "--latest-version":
options.latestVersion = ensureValue(argv, i, arg);
i++;
break;
case "--install-version":
options.installVersion = ensureValue(argv, i, arg);
i++;
break;
case "--target-package-spec":
options.targetPackageSpec = ensureValue(argv, i, arg);
i++;
break;
case "--keep-server":
options.keepServer = true;
break;
case "--json":
options.json = true;
break;
case "-h":
case "--help":
process.stdout.write(usage());
process.exit(0);
default:
die(`unknown arg: ${arg}`);
}
}
return options;
}
class LinuxSmoke {
private auth: ProviderAuth;
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
private hostIp = "";
private hostPort = 0;
private server: HostServer | null = null;
private runDir = "";
private tgzDir = "";
private artifact: PackageArtifact | null = null;
private latestVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: LinuxGuest;
private status = {
daemon: "systemd-user-unavailable",
freshAgent: "skip",
freshGateway: "skip",
freshMain: "skip",
freshVersion: "skip",
latestInstalledVersion: "skip",
upgrade: "skip",
upgradeAgent: "skip",
upgradeGateway: "skip",
upgradeVersion: "skip",
};
constructor(private options: LinuxOptions) {
this.auth = resolveProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
provider: options.provider,
});
}
async run(): Promise<void> {
this.runDir = await makeTempDir("openclaw-parallels-linux.");
this.phases = new PhaseRunner(this.runDir);
this.tgzDir = await makeTempDir("openclaw-parallels-linux-tgz.");
try {
this.options.vmName = this.resolveVmName();
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.guest = new LinuxGuest(this.options.vmName, this.phases);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
this.hostIp = resolveHostIp(this.options.hostIp);
this.hostPort = await resolveHostPort(
this.options.hostPort,
this.options.hostPortExplicit,
defaultOptions().hostPort,
);
say(`VM: ${this.options.vmName}`);
say(`Snapshot hint: ${this.options.snapshotHint}`);
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
say(`Latest npm version: ${this.latestVersion}`);
say(
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
);
say(`Run logs: ${this.runDir}`);
this.artifact = await packOpenClaw({
destination: this.tgzDir,
packageSpec: this.options.targetPackageSpec,
requireControlUi: false,
});
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: this.artifactLabel(),
port: this.hostPort,
});
this.hostPort = this.server.port;
if (this.options.mode === "fresh" || this.options.mode === "both") {
await this.runLane("fresh", async () => this.runFreshLane());
}
if (this.options.mode === "upgrade" || this.options.mode === "both") {
await this.runLane("upgrade", async () => this.runUpgradeLane());
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
this.printSummary(summaryPath);
}
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
process.exitCode = 1;
}
} finally {
if (!this.options.keepServer) {
await this.server?.stop().catch(() => undefined);
}
if (!this.options.keepServer) {
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
}
}
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
try {
await fn();
if (name === "fresh") {
this.status.freshMain = "pass";
} else {
this.status.upgrade = "pass";
}
} catch (error) {
if (name === "fresh") {
this.status.freshMain = "fail";
} else {
this.status.upgrade = "fail";
}
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private artifactLabel(): string {
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
}
private resolveVmName(): string {
const payload = JSON.parse(
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
) as Array<{
name?: string;
}>;
const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean);
if (names.includes(this.options.vmName)) {
return this.options.vmName;
}
if (this.options.vmNameExplicit) {
die(`VM not found: ${this.options.vmName}`);
}
const ubuntu = names
.map((name) => ({ name, version: /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1] }))
.filter((item): item is { name: string; version: string } => Boolean(item.version))
.map((item) => ({
name: item.name,
parts: item.version.split(".").map(Number),
version: item.version,
}))
.filter((item) => item.parts[0] >= 24)
.toSorted((a, b) => compareVersions(a.parts, b.parts));
const fallback = ubuntu[0]?.name ?? names.find((name) => /ubuntu/i.test(name));
if (!fallback) {
die(`VM not found: ${this.options.vmName}`);
}
warn(`requested VM ${this.options.vmName} not found; using ${fallback}`);
return fallback;
}
private async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("fresh.bootstrap-guest", 600, () => this.bootstrapGuest());
await this.phase("fresh.install-latest-bootstrap", 420, () => this.installLatestRelease());
await this.phase("fresh.install-main", 420, () =>
this.installMainTgz("openclaw-main-fresh.tgz"),
);
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 90, () => this.verifyTargetVersion());
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.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),
() => this.verifyLocalTurn(),
);
this.status.freshAgent = "pass";
}
private async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("upgrade.bootstrap-guest", 600, () => this.bootstrapGuest());
await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest");
await this.phase("upgrade.verify-latest-version", 90, () =>
this.verifyVersionContains(this.latestVersion),
);
await this.phase("upgrade.install-main", 420, () =>
this.installMainTgz("openclaw-main-upgrade.tgz"),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main");
await this.phase("upgrade.verify-main-version", 90, () => this.verifyTargetVersion());
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.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),
() => this.verifyLocalTurn(),
);
this.status.upgradeAgent = "pass";
}
private async phase(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<void> {
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(): number | undefined {
return this.phases.remainingTimeoutMs();
}
private log(text: string): void {
this.phases.append(text);
}
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
return this.guest.exec(args, options);
}
private guestBash(script: string): string {
return this.guest.bash(script);
}
private waitForVmStatus(expected: string, timeoutSeconds = 180): 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(` ${expected}`)) {
return;
}
run("sleep", ["1"], { quiet: true });
}
die(`VM ${this.options.vmName} did not reach ${expected}`);
}
private waitForGuestReady(timeoutSeconds = 180): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
if (
run("prlctl", ["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", "/bin/true"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
}).status === 0
) {
return;
}
run("sleep", ["2"], { quiet: true });
}
die(`guest did not become ready in ${this.options.vmName}`);
}
private restoreSnapshot(): void {
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
});
if (this.snapshot.state === "poweroff") {
this.waitForVmStatus("stopped");
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
}
this.waitForGuestReady();
}
private bootstrapGuest(): void {
const hostNow = `@${Math.floor(Date.now() / 1000)}`;
this.guestExec(["date", "-u", "-s", hostNow]);
this.guestExec(["hwclock", "--systohc"], { check: false });
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
this.guestExec(["apt-get", "-o", "Acquire::Check-Date=false", "update"]);
this.guestExec(["apt-get", "install", "-y", "curl", "ca-certificates"]);
}
private installLatestRelease(): void {
this.guestExec(["curl", "-fsSL", this.options.installUrl, "-o", "/tmp/openclaw-install.sh"]);
if (this.options.installVersion) {
this.guestExec([
"/usr/bin/env",
"OPENCLAW_NO_ONBOARD=1",
"bash",
"/tmp/openclaw-install.sh",
"--version",
this.options.installVersion,
"--no-onboard",
]);
} else {
this.guestExec([
"/usr/bin/env",
"OPENCLAW_NO_ONBOARD=1",
"bash",
"/tmp/openclaw-install.sh",
"--no-onboard",
]);
}
this.guestExec(["openclaw", "--version"]);
}
private installMainTgz(tempName: string): void {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.guestExec(["curl", "-fsSL", tgzUrl, "-o", `/tmp/${tempName}`]);
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
this.guestExec(["openclaw", "--version"]);
}
private async verifyTargetVersion(): Promise<void> {
if (!this.artifact) {
die("package artifact missing");
}
if (this.options.targetPackageSpec) {
const version = this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
this.verifyVersionContains(version);
return;
}
const commit =
this.artifact.buildCommitShort ||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
this.verifyVersionContains(commit);
}
private verifyVersionContains(needle: string): void {
const version = this.guestExec(["openclaw", "--version"]);
if (!version.includes(needle)) {
throw new Error(`version mismatch: expected substring ${needle}`);
}
}
private runRefOnboard(): void {
this.guestExec([
"/usr/bin/env",
`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`,
"openclaw",
"onboard",
"--non-interactive",
"--mode",
"local",
"--auth-choice",
this.auth.authChoice,
"--secret-input-mode",
"ref",
"--gateway-port",
"18789",
"--gateway-bind",
"loopback",
"--skip-skills",
"--skip-health",
"--accept-risk",
"--json",
]);
}
private injectBadPluginFixture(): void {
this.guestBash(String.raw`set -euo pipefail
plugin_dir=/root/.openclaw/test-bad-plugin
mkdir -p "$plugin_dir"
cat >"$plugin_dir/package.json" <<'JSON'
{"name":"@openclaw/test-bad-plugin","version":"1.0.0","openclaw":{"extensions":["./index.cjs"],"setupEntry":"./setup-entry.cjs"}}
JSON
cat >"$plugin_dir/openclaw.plugin.json" <<'JSON'
{"id":"test-bad-plugin","configSchema":{"type":"object","additionalProperties":false,"properties":{}},"channels":["test-bad-plugin"]}
JSON
cat >"$plugin_dir/index.cjs" <<'JS'
module.exports = { id: "test-bad-plugin", register() {} };
JS
cat >"$plugin_dir/setup-entry.cjs" <<'JS'
module.exports = {
kind: "bundled-channel-setup-entry",
loadSetupPlugin() {
throw new Error("boom: bad plugin smoke fixture");
},
};
JS
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.setdefault("paths", [])
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")
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",
String.raw`pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
rm -f /tmp/openclaw-parallels-linux-gateway.log
setsid sh -lc ` +
shellQuote(
`exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjourEnv} ${this.auth.apiKeyEnv}=${shellQuote(
this.auth.apiKeyValue,
)} 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)) {
return;
}
run("sleep", ["2"], { quiet: true });
}
throw new Error("gateway did not become ready");
}
private showGatewayStatusCompat(check = true): boolean {
const help = this.guestExec(["openclaw", "gateway", "status", "--help"], { check: false });
const args = help.includes("--require-rpc")
? ["openclaw", "gateway", "status", "--deep", "--require-rpc"]
: ["openclaw", "gateway", "status", "--deep"];
const result = run(
"prlctl",
["exec", this.options.vmName, "/usr/bin/env", "HOME=/root", ...args],
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
},
);
this.log(result.stdout);
this.log(result.stderr);
if (check && result.status !== 0) {
throw new Error("gateway status failed");
}
return result.status === 0;
}
private verifyGatewayStatus(): void {
for (let attempt = 1; attempt <= 8; attempt++) {
const result = run(
"prlctl",
[
"exec",
this.options.vmName,
"/usr/bin/env",
"HOME=/root",
"openclaw",
"gateway",
"status",
"--deep",
"--require-rpc",
"--timeout",
"15000",
],
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs() },
);
this.log(result.stdout);
this.log(result.stderr);
if (result.status === 0) {
return;
}
if (attempt < 8) {
warn(`gateway-status retry ${attempt}`);
run("sleep", ["5"], { quiet: true });
}
}
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 verifyLocalTurn(): void {
this.guestExec(["openclaw", "models", "set", this.auth.modelId]);
this.guestExec([
"openclaw",
"config",
"set",
"agents.defaults.skipBootstrap",
"true",
"--strict-json",
]);
this.prepareAgentWorkspace();
this.guestExec([
"/bin/sh",
"-lc",
`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."),
]);
}
private async extractLastVersion(phaseId: string): Promise<string> {
const text = await readFile(path.join(this.runDir, `${phaseId}.log`), "utf8").catch(() => "");
return [...text.matchAll(/OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)/g)].at(-1)?.[0] ?? "";
}
private async writeSummary(): Promise<string> {
const summaryPath = path.join(this.runDir, "summary.json");
const summary: LinuxSummary = {
currentHead:
this.artifact?.buildCommitShort ||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
daemon: this.status.daemon,
freshMain: {
agent: this.status.freshAgent,
gateway: this.status.freshGateway,
status: this.status.freshMain,
version: this.status.freshVersion,
},
installVersion: this.options.installVersion || "",
latestVersion: this.latestVersion,
mode: this.options.mode,
provider: this.options.provider,
runDir: this.runDir,
snapshotHint: this.options.snapshotHint,
snapshotId: this.snapshot.id,
targetPackageSpec: this.options.targetPackageSpec || "",
upgrade: {
agent: this.status.upgradeAgent,
gateway: this.status.upgradeGateway,
latestVersionInstalled: this.status.latestInstalledVersion,
mainVersion: this.status.upgradeVersion,
status: this.status.upgrade,
},
vm: this.options.vmName,
};
await writeJson(summaryPath, summary);
return summaryPath;
}
private printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
if (this.options.targetPackageSpec) {
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
}
if (this.options.installVersion) {
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
}
process.stdout.write(` daemon: ${this.status.daemon}\n`);
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
process.stdout.write(
` latest->main: ${this.status.upgrade} (${this.status.upgradeVersion})\n`,
);
process.stdout.write(` logs: ${this.runDir}\n`);
process.stdout.write(` summary: ${summaryPath}\n`);
}
}
function compareVersions(a: number[], b: number[]): number {
for (let index = 0; index < Math.max(a.length, b.length); index++) {
const diff = (a[index] ?? 0) - (b[index] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
const options = parseArgs(process.argv.slice(2));
await mkdir(repoRoot, { recursive: true });
await new LinuxSmoke(options).run();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
#!/usr/bin/env -S pnpm tsx
import { spawn } from "node:child_process";
import { readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
makeTempDir,
packOpenClaw,
parsePlatformList,
parseProvider,
repoRoot,
resolveHostIp,
resolveLatestVersion,
resolveProviderAuth,
run,
say,
shellQuote,
startHostServer,
warn,
writeJson,
type HostServer,
type PackageArtifact,
type Platform,
type Provider,
type ProviderAuth,
} from "./common.ts";
import { encodePowerShell, psSingleQuote } from "./powershell.ts";
interface NpmUpdateOptions {
packageSpec: string;
updateTarget: string;
platforms: Set<Platform>;
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
json: boolean;
}
interface Job {
done: boolean;
label: string;
logPath: string;
promise: Promise<number>;
}
interface NpmUpdateSummary {
packageSpec: string;
updateTarget: string;
updateExpected: string;
provider: Provider;
latestVersion: string;
currentHead: string;
runDir: string;
fresh: Record<Platform, string>;
update: Record<Platform, { status: string; version: string }>;
}
const macosVm = "macOS Tahoe";
const windowsVm = "Windows 11";
const linuxVmDefault = "Ubuntu 24.04.3 ARM64";
const updateTimeoutSeconds = Number(process.env.OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S || 1200);
function usage(): string {
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
Options:
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
--update-target <target> Target passed to guest 'openclaw update --tag'.
Default: host-served tgz packed from current checkout.
--platform <list> Comma-separated platforms to run: all, macos, windows, linux.
Default: all
--provider <openai|anthropic|minimax>
--model <provider/model> Override the model used for agent-turn smoke checks.
--api-key-env <var> Host env var name for provider API key.
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
--json Print machine-readable JSON summary.
-h, --help Show help.
`;
}
function parseArgs(argv: string[]): NpmUpdateOptions {
const options: NpmUpdateOptions = {
apiKeyEnv: undefined,
json: false,
modelId: undefined,
packageSpec: "",
platforms: parsePlatformList("all"),
provider: "openai",
updateTarget: "",
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--":
break;
case "--package-spec":
options.packageSpec = ensureValue(argv, i, arg);
i++;
break;
case "--update-target":
options.updateTarget = ensureValue(argv, i, arg);
i++;
break;
case "--platform":
case "--only":
options.platforms = parsePlatformList(ensureValue(argv, i, arg));
i++;
break;
case "--provider":
options.provider = parseProvider(ensureValue(argv, i, arg));
i++;
break;
case "--model":
options.modelId = ensureValue(argv, i, arg);
i++;
break;
case "--api-key-env":
case "--openai-api-key-env":
options.apiKeyEnv = ensureValue(argv, i, arg);
i++;
break;
case "--json":
options.json = true;
break;
case "-h":
case "--help":
process.stdout.write(usage());
process.exit(0);
default:
die(`unknown arg: ${arg}`);
}
}
return options;
}
function platformRecord<T>(value: T): Record<Platform, T> {
return { linux: value, macos: value, windows: value };
}
class NpmUpdateSmoke {
private auth: ProviderAuth;
private runDir = "";
private tgzDir = "";
private latestVersion = "";
private packageSpec = "";
private currentHead = "";
private currentHeadShort = "";
private hostIp = "";
private server: HostServer | null = null;
private artifact: PackageArtifact | null = null;
private updateTargetEffective = "";
private updateExpectedNeedle = "";
private linuxVm = linuxVmDefault;
private freshStatus = platformRecord("skip");
private updateStatus = platformRecord("skip");
private updateVersion = platformRecord("skip");
constructor(private options: NpmUpdateOptions) {
this.auth = resolveProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
provider: options.provider,
});
}
async run(): Promise<void> {
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
try {
this.latestVersion = resolveLatestVersion();
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
quiet: true,
}).stdout.trim();
this.hostIp = resolveHostIp("");
if (this.options.platforms.has("linux")) {
this.linuxVm = this.resolveLinuxVmName();
}
this.preflightRegistryUpdateTarget();
say(`Run fresh npm baseline: ${this.packageSpec}`);
say(`Platforms: ${[...this.options.platforms].join(",")}`);
say(`Run dir: ${this.runDir}`);
await this.runFreshBaselines();
await this.prepareUpdateTarget();
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
await this.runSameGuestUpdates();
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
say(`Run dir: ${this.runDir}`);
process.stdout.write(await readFile(summaryPath, "utf8"));
}
} finally {
await this.server?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
}
private async runFreshBaselines(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
jobs.push(this.spawnFresh("macOS", "macos", []));
}
if (this.options.platforms.has("windows")) {
jobs.push(this.spawnFresh("Windows", "windows", []));
}
if (this.options.platforms.has("linux")) {
jobs.push(
this.spawnFresh("Linux", "linux", ["--vm", this.linuxVm], {
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR: "1",
}),
);
}
await this.monitorJobs("fresh", jobs);
for (const job of jobs) {
const status = (await job.promise) === 0 ? "pass" : "fail";
const platform = this.platformFromLabel(job.label);
this.freshStatus[platform] = status;
if (status !== "pass") {
this.dumpLogTail(job.logPath);
die(`${job.label} fresh baseline failed`);
}
}
}
private spawnFresh(
label: string,
platform: Platform,
extraArgs: string[],
env: NodeJS.ProcessEnv = {},
): Job {
const logPath = path.join(this.runDir, `${platform}-fresh.log`);
const args = [
"exec",
"tsx",
`scripts/e2e/parallels/${platform}-smoke.ts`,
"--mode",
"fresh",
"--provider",
this.options.provider,
"--model",
this.auth.modelId,
"--api-key-env",
this.auth.apiKeyEnv,
"--target-package-spec",
this.packageSpec,
"--json",
...extraArgs,
];
const job: Job = {
done: false,
label,
logPath,
promise: Promise.resolve(1),
};
job.promise = this.spawnLogged("pnpm", args, logPath, env).finally(() => {
job.done = true;
});
return job;
}
private async prepareUpdateTarget(): Promise<void> {
if (!this.options.updateTarget || this.options.updateTarget === "local-main") {
this.artifact = await packOpenClaw({
destination: this.tgzDir,
requireControlUi: true,
stageRuntimeDeps: true,
});
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: "current main tgz",
port: 0,
});
this.updateTargetEffective = this.server.urlFor(this.artifact.path);
this.updateExpectedNeedle = this.currentHeadShort;
return;
}
this.updateTargetEffective = this.options.updateTarget;
this.updateExpectedNeedle = this.isExplicitPackageTarget(this.updateTargetEffective)
? ""
: this.resolveRegistryTargetVersion(this.updateTargetEffective) || this.updateTargetEffective;
}
private async runSameGuestUpdates(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
this.ensureVmRunning(macosVm);
jobs.push(this.spawnUpdate("macOS", "macos", () => this.runMacosUpdate()));
}
if (this.options.platforms.has("windows")) {
this.ensureVmRunning(windowsVm);
jobs.push(this.spawnUpdate("Windows", "windows", () => this.runWindowsUpdate()));
}
if (this.options.platforms.has("linux")) {
this.ensureVmRunning(this.linuxVm);
jobs.push(this.spawnUpdate("Linux", "linux", () => this.runLinuxUpdate()));
}
await this.monitorJobs("update", jobs);
for (const job of jobs) {
const platform = this.platformFromLabel(job.label);
const status = (await job.promise) === 0 ? "pass" : "fail";
this.updateStatus[platform] = status;
this.updateVersion[platform] = await this.extractLastVersion(job.logPath);
if (status !== "pass") {
this.dumpLogTail(job.logPath);
die(`${job.label} update failed`);
}
}
}
private spawnUpdate(label: string, platform: Platform, fn: () => void): Job {
const logPath = path.join(this.runDir, `${platform}-update.log`);
const job: Job = {
done: false,
label,
logPath,
promise: Promise.resolve(1),
};
job.promise = (async () => {
const originalStdout = process.stdout.write.bind(process.stdout);
const originalStderr = process.stderr.write.bind(process.stderr);
let log = "";
const append = (chunk: string | Uint8Array): boolean => {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
log += text;
return true;
};
const timeout = setTimeout(() => {
append(`${label} update timed out after ${updateTimeoutSeconds}s\n`);
}, updateTimeoutSeconds * 1000);
try {
process.stdout.write = ((chunk: string | Uint8Array) =>
append(chunk)) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Uint8Array) =>
append(chunk)) as typeof process.stderr.write;
fn();
await writeFile(logPath, log, "utf8");
return 0;
} catch (error) {
append(`${error instanceof Error ? error.message : String(error)}\n`);
await writeFile(logPath, log, "utf8");
return 1;
} finally {
clearTimeout(timeout);
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
})().finally(() => {
job.done = true;
});
return job;
}
private runMacosUpdate(): void {
const expectedCheck = this.updateExpectedNeedle
? `version="$(/opt/homebrew/bin/openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(
this.updateExpectedNeedle,
)}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac`
: "/opt/homebrew/bin/openclaw --version";
const script = String.raw`set -euo pipefail
scrub_future_plugin_entries() {
python3 - <<'PY'
import json
from pathlib import Path
path = Path.home() / ".openclaw" / "openclaw.json"
if not path.exists():
raise SystemExit(0)
try:
config = json.loads(path.read_text())
except Exception:
raise SystemExit(0)
plugins = config.get("plugins")
if not isinstance(plugins, dict):
raise SystemExit(0)
entries = plugins.get("entries")
if isinstance(entries, dict):
entries.pop("feishu", None)
entries.pop("whatsapp", None)
allow = plugins.get("allow")
if isinstance(allow, list):
plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp"}]
path.write_text(json.dumps(config, indent=2) + "\n")
PY
}
stop_openclaw_gateway_processes() {
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop || true
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
}
scrub_future_plugin_entries
stop_openclaw_gateway_processes
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json
${expectedCheck}
/opt/homebrew/bin/openclaw gateway restart
/opt/homebrew/bin/openclaw gateway status --deep --require-rpc
/opt/homebrew/bin/openclaw models set ${shellQuote(this.auth.modelId)}
/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} /opt/homebrew/bin/openclaw agent --local --agent main --session-id parallels-npm-update-macos --message 'Reply with exact ASCII text OK only.' --json`;
this.guestMacos(script, updateTimeoutSeconds * 1000);
}
private runWindowsUpdate(): void {
const expected = this.updateExpectedNeedle;
this.guestWindows(
`$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
function Remove-FuturePluginEntries {
$configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json'
if (-not (Test-Path $configPath)) { return }
try { $config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable } catch { return }
$plugins = $config['plugins']
if (-not ($plugins -is [hashtable])) { return }
$entries = $plugins['entries']
if ($entries -is [hashtable]) {
foreach ($pluginId in @('feishu', 'whatsapp')) {
if ($entries.ContainsKey($pluginId)) { $entries.Remove($pluginId) }
}
}
$allow = $plugins['allow']
if ($allow -is [array]) {
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
}
$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
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'openclaw.*gateway' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
}
Remove-FuturePluginEntries
Stop-OpenClawGatewayProcesses
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
& $openclaw update --tag ${psSingleQuote(this.updateTargetEffective)} --yes --json
if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" }
$version = & $openclaw --version
$version
${expected ? `if (($version | Out-String) -notlike ${psSingleQuote(`*${expected}*`)}) { throw ${psSingleQuote(`version mismatch: expected ${expected}`)} }` : ""}
& $openclaw gateway restart
& $openclaw gateway status --deep --require-rpc
& $openclaw models set ${psSingleQuote(this.auth.modelId)}
& $openclaw config set agents.defaults.skipBootstrap true --strict-json
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
& $openclaw agent --local --agent main --session-id parallels-npm-update-windows --message 'Reply with exact ASCII text OK only.' --json`,
updateTimeoutSeconds * 1000,
);
}
private runLinuxUpdate(): void {
const expectedCheck = this.updateExpectedNeedle
? `version="$(openclaw --version)"; printf '%s\\n' "$version"; case "$version" in *${shellQuote(this.updateExpectedNeedle)}*) ;; *) echo "version mismatch: expected ${this.updateExpectedNeedle}" >&2; exit 1 ;; esac`
: "openclaw --version";
const script = String.raw`set -euo pipefail
scrub_future_plugin_entries() {
node - <<'JS'
const fs = require("node:fs");
const path = require("node:path");
const configPath = path.join(process.env.HOME || "/root", ".openclaw", "openclaw.json");
if (!fs.existsSync(configPath)) process.exit(0);
let config;
try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); }
const plugins = config.plugins;
if (!plugins || typeof plugins !== "object") process.exit(0);
if (plugins.entries && typeof plugins.entries === "object") {
delete plugins.entries.feishu;
delete plugins.entries.whatsapp;
}
if (Array.isArray(plugins.allow)) {
plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
JS
}
stop_openclaw_gateway_processes() {
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop || true
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
}
scrub_future_plugin_entries
stop_openclaw_gateway_processes
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(this.updateTargetEffective)} --yes --json
${expectedCheck}
openclaw gateway restart
openclaw gateway status --deep --require-rpc
openclaw models set ${shellQuote(this.auth.modelId)}
openclaw config set agents.defaults.skipBootstrap true --strict-json
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
${this.auth.apiKeyEnv}=${shellQuote(this.auth.apiKeyValue)} openclaw agent --local --agent main --session-id parallels-npm-update-linux --message 'Reply with exact ASCII text OK only.' --json`;
this.guestLinux(script, updateTimeoutSeconds * 1000);
}
private spawnLogged(
command: string,
args: string[],
logPath: string,
env: NodeJS.ProcessEnv = {},
): Promise<number> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let log = "";
child.stdout.on("data", (chunk: Buffer) => {
log += chunk.toString("utf8");
});
child.stderr.on("data", (chunk: Buffer) => {
log += chunk.toString("utf8");
});
child.on("error", reject);
child.on("close", async (code) => {
await writeFile(logPath, log, "utf8");
resolve(code ?? 1);
});
});
}
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {
const pending = new Set(jobs.map((job) => job.label));
while (pending.size > 0) {
await new Promise((resolve) => setTimeout(resolve, 15_000));
for (const job of jobs) {
if (!pending.has(job.label)) {
continue;
}
if (job.done) {
pending.delete(job.label);
}
}
if (pending.size > 0) {
say(`${label} still running: ${[...pending].join(", ")}`);
}
}
}
private guestMacos(script: string, timeoutMs: number): void {
run(
"prlctl",
[
"exec",
macosVm,
"--current-user",
"/usr/bin/env",
"PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin",
"/bin/bash",
"-lc",
script,
],
{ timeoutMs },
);
}
private guestWindows(script: string, timeoutMs: number): void {
run(
"prlctl",
[
"exec",
windowsVm,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(script),
],
{ timeoutMs },
);
}
private guestLinux(script: string, timeoutMs: number): void {
run("prlctl", ["exec", this.linuxVm, "/usr/bin/env", "HOME=/root", "bash", "-lc", script], {
timeoutMs,
});
}
private ensureVmRunning(vmName: string): void {
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
const status = this.vmStatus(vmName);
if (status === "running") {
return;
}
if (status === "stopped") {
say(`Start ${vmName} before update phase`);
run("prlctl", ["start", vmName], { quiet: true });
} else if (status === "suspended" || status === "paused") {
say(`Resume ${vmName} before update phase`);
run("prlctl", ["resume", vmName], { quiet: true });
} else if (status === "missing") {
die(`VM not found before update phase: ${vmName}`);
}
run("sleep", ["5"], { quiet: true });
}
die(`VM did not become running before update phase: ${vmName}`);
}
private vmStatus(vmName: string): string {
const payload = JSON.parse(
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
) as Array<{
name?: string;
status?: string;
}>;
return payload.find((vm) => vm.name === vmName)?.status || "missing";
}
private resolveLinuxVmName(): string {
const payload = JSON.parse(
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
) as Array<{
name?: string;
}>;
const names = payload.map((item) => (item.name ?? "").trim()).filter(Boolean);
if (names.includes(linuxVmDefault)) {
return linuxVmDefault;
}
const fallback = names.find((name) => /ubuntu/i.test(name));
if (!fallback) {
die(`VM not found: ${linuxVmDefault}`);
}
warn(`requested VM ${linuxVmDefault} not found; using ${fallback}`);
return fallback;
}
private resolveRegistryTargetVersion(target: string): string {
const spec = target.startsWith("openclaw@") ? target : `openclaw@${target}`;
return (
run("npm", ["view", spec, "version"], { check: false, quiet: true })
.stdout.trim()
.split("\n")
.at(-1) ?? ""
);
}
private isExplicitPackageTarget(target: string): boolean {
return (
target.includes("://") ||
target.includes("#") ||
/^(file|github|git\+ssh|git\+https|git\+http|git\+file|npm):/.test(target)
);
}
private preflightRegistryUpdateTarget(): void {
if (
!this.options.updateTarget ||
this.options.updateTarget === "local-main" ||
this.isExplicitPackageTarget(this.options.updateTarget)
) {
return;
}
const baseline = this.resolveRegistryTargetVersion(this.packageSpec);
const target = this.resolveRegistryTargetVersion(this.options.updateTarget);
if (baseline && target && baseline === target) {
die(
`--update-target ${this.options.updateTarget} resolves to openclaw@${target}, same as baseline ${this.packageSpec}; publish or choose a newer --update-target before running VM update coverage`,
);
}
}
private platformFromLabel(label: string): Platform {
if (label === "macOS") {
return "macos";
}
return label.toLowerCase() as Platform;
}
private async extractLastVersion(logPath: string): Promise<string> {
const log = await readFile(logPath, "utf8").catch(() => "");
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
return matches.at(-1)?.[1] ?? "";
}
private dumpLogTail(logPath: string): void {
const log = run("tail", ["-n", "80", logPath], { check: false, quiet: true }).stdout;
if (log) {
process.stderr.write(log);
}
}
private async writeSummary(): Promise<string> {
const summary: NpmUpdateSummary = {
currentHead: this.currentHeadShort,
fresh: this.freshStatus,
latestVersion: this.latestVersion,
packageSpec: this.packageSpec,
provider: this.options.provider,
runDir: this.runDir,
update: {
linux: { status: this.updateStatus.linux, version: this.updateVersion.linux },
macos: { status: this.updateStatus.macos, version: this.updateVersion.macos },
windows: { status: this.updateStatus.windows, version: this.updateVersion.windows },
},
updateExpected: this.updateExpectedNeedle,
updateTarget: this.updateTargetEffective,
};
const summaryPath = path.join(this.runDir, "summary.json");
await writeJson(summaryPath, summary);
return summaryPath;
}
}
await new NpmUpdateSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
die(error instanceof Error ? error.message : String(error));
});

View File

@@ -0,0 +1,145 @@
import { copyFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { exists, readJson } from "./filesystem.ts";
import { die, repoRoot, run, say, sh } from "./host-command.ts";
import type { PackageArtifact } from "./types.ts";
export async function extractPackageJsonFromTgz<T>(tgzPath: string, entry: string): Promise<T> {
const output = run("tar", ["-xOf", tgzPath, entry], { quiet: true }).stdout;
return JSON.parse(output) as T;
}
export async function packageVersionFromTgz(tgzPath: string): Promise<string> {
const pkg = await extractPackageJsonFromTgz<{ version: string }>(tgzPath, "package/package.json");
return pkg.version;
}
export async function packageBuildCommitFromTgz(tgzPath: string): Promise<string> {
const info = await extractPackageJsonFromTgz<{ commit?: string }>(
tgzPath,
"package/dist/build-info.json",
);
return info.commit ?? "";
}
export async function ensureCurrentBuild(input: {
lockDir: string;
requireControlUi?: boolean;
checkDirty?: boolean;
}): Promise<void> {
void input.lockDir;
const head = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
const buildInfoPath = path.join(repoRoot, "dist/build-info.json");
let buildCommit = "";
if (await exists(buildInfoPath)) {
buildCommit = (await readJson<{ commit?: string }>(buildInfoPath)).commit ?? "";
}
const dirty =
input.checkDirty !== false &&
run(
"git",
[
"status",
"--porcelain",
"--",
"src",
"ui",
"packages",
"extensions",
"package.json",
"pnpm-lock.yaml",
"tsconfig*.json",
],
{ quiet: true },
).stdout.trim() !== "";
const controlReady =
!input.requireControlUi ||
((await exists(path.join(repoRoot, "dist/control-ui/index.html"))) &&
sh("compgen -G 'dist/control-ui/assets/*' >/dev/null", { check: false, quiet: true })
.status === 0);
if (buildCommit === head && !dirty && controlReady) {
return;
}
say("Build dist for current head");
run("pnpm", ["build"]);
if (input.requireControlUi) {
say("Build Control UI for current head");
run("pnpm", ["ui:build"]);
}
const drift = run("git", ["status", "--porcelain", "--", "src/canvas-host/a2ui/.bundle.hash"], {
quiet: true,
}).stdout.trim();
if (drift) {
die(`generated file drift after build; commit or revert before Parallels packaging:\n${drift}`);
}
}
export async function packOpenClaw(input: {
destination: string;
packageSpec?: string;
requireControlUi?: boolean;
stageRuntimeDeps?: boolean;
}): Promise<PackageArtifact> {
await mkdir(input.destination, { recursive: true });
if (input.packageSpec) {
say(`Pack target package tgz: ${input.packageSpec}`);
const output = run(
"npm",
[
"pack",
input.packageSpec,
"--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, path.basename(packed));
const version = await packageVersionFromTgz(tgzPath);
say(`Packed ${tgzPath}`);
say(`Target package version: ${version}`);
return { path: tgzPath, version };
}
await ensureCurrentBuild({
checkDirty: true,
lockDir: path.join(tmpdir(), "openclaw-parallels-build.lock"),
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 };
}

View File

@@ -0,0 +1,74 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { say, warn } from "./host-command.ts";
export class PhaseRunner {
private logText = "";
private deadlineMs = 0;
constructor(private runDir: string) {}
async phase(name: string, timeoutSeconds: number, fn: () => Promise<void> | void): Promise<void> {
const logPath = path.join(this.runDir, `${name}.log`);
say(name);
this.logText = "";
this.deadlineMs = Date.now() + timeoutSeconds * 1000;
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${name} timed out after ${timeoutSeconds}s`)),
timeoutSeconds * 1000,
);
});
try {
await Promise.race([Promise.resolve(fn()), timeout]);
await writeFile(logPath, this.logText, "utf8");
} catch (error) {
await writeFile(logPath, this.logText, "utf8").catch(() => undefined);
warn(`${name} failed`);
warn(`log tail: ${logPath}`);
process.stderr.write(this.logText.split("\n").slice(-80).join("\n"));
process.stderr.write("\n");
throw error;
} finally {
if (timer) {
clearTimeout(timer);
}
this.deadlineMs = 0;
}
}
async phaseReturns(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> {
try {
await this.phase(name, timeoutSeconds, fn);
return true;
} catch {
return false;
}
}
remainingTimeoutMs(fallbackMs?: number): number | undefined {
if (this.deadlineMs === 0) {
return fallbackMs;
}
const remaining = this.deadlineMs - Date.now();
if (remaining <= 0) {
throw new Error("phase deadline exceeded before starting guest command");
}
return Math.max(1_000, fallbackMs == null ? remaining : Math.min(remaining, fallbackMs));
}
append(text: string): void {
if (!text) {
return;
}
this.logText += text;
if (!text.endsWith("\n")) {
this.logText += "\n";
}
}
}

View File

@@ -0,0 +1,13 @@
export function psSingleQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function psArray(values: string[]): string {
return `@(${values.map(psSingleQuote).join(", ")})`;
}
export function encodePowerShell(script: string): string {
return Buffer.from(`$ProgressPreference = 'SilentlyContinue'\n${script}`, "utf16le").toString(
"base64",
);
}

View File

@@ -0,0 +1,106 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { die, run } from "./host-command.ts";
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
export function parseBoolEnv(value: string | undefined): boolean {
return /^(1|true|yes|on)$/i.test(value ?? "");
}
export function ensureValue(args: string[], index: number, flag: string): string {
const value = args[index + 1];
if (value == null || value === "") {
die(`${flag} requires a value`);
}
return value;
}
export function resolveProviderAuth(input: {
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
}): ProviderAuth {
const providerDefaults: Record<Provider, Omit<ProviderAuth, "apiKeyValue">> = {
anthropic: {
apiKeyEnv: input.apiKeyEnv || "ANTHROPIC_API_KEY",
authChoice: "apiKey",
authKeyFlag: "anthropic-api-key",
modelId:
input.modelId ||
process.env.OPENCLAW_PARALLELS_ANTHROPIC_MODEL ||
"anthropic/claude-sonnet-4-6",
},
minimax: {
apiKeyEnv: input.apiKeyEnv || "MINIMAX_API_KEY",
authChoice: "minimax-global-api",
authKeyFlag: "minimax-api-key",
modelId:
input.modelId || process.env.OPENCLAW_PARALLELS_MINIMAX_MODEL || "minimax/MiniMax-M2.7",
},
openai: {
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",
},
};
const resolved = providerDefaults[input.provider];
const apiKeyValue = process.env[resolved.apiKeyEnv] ?? "";
if (!apiKeyValue) {
die(`${resolved.apiKeyEnv} is required`);
}
return { ...resolved, apiKeyValue };
}
export function parseProvider(value: string): Provider {
if (value === "openai" || value === "anthropic" || value === "minimax") {
return value;
}
return die(`invalid --provider: ${value}`);
}
export function parseMode(value: string): Mode {
if (value === "fresh" || value === "upgrade" || value === "both") {
return value;
}
return die(`invalid --mode: ${value}`);
}
export function parsePlatformList(value: string): Set<Platform> {
const normalized = value.replaceAll(" ", "");
if (normalized === "all") {
return new Set(["macos", "windows", "linux"]);
}
const result = new Set<Platform>();
for (const entry of normalized.split(",")) {
if (entry === "macos" || entry === "windows" || entry === "linux") {
result.add(entry);
} else {
die(`invalid --platform entry: ${entry}`);
}
}
if (result.size === 0) {
die("--platform must include at least one platform");
}
return result;
}
export function resolveLatestVersion(versionOverride = ""): string {
if (versionOverride) {
return versionOverride;
}
return run(
"npm",
[
"view",
"openclaw",
"version",
"--userconfig",
mkdtempSync(path.join(tmpdir(), "openclaw-npm-")),
],
{
quiet: true,
},
).stdout.trim();
}

View File

@@ -0,0 +1,73 @@
import { die, run } from "./host-command.ts";
import type { SnapshotInfo } from "./types.ts";
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
const output = run("prlctl", ["snapshot-list", vmName, "--json"], { quiet: true }).stdout;
const payload = JSON.parse(output) as Record<string, { name?: string; state?: string }>;
let best: SnapshotInfo | null = null;
let bestScore = -1;
const aliases = (name: string): string[] => {
const values = [name];
for (const pattern of [/^(.*)-poweroff$/, /^(.*)-poweroff-\d{4}-\d{2}-\d{2}$/]) {
const match = name.match(pattern);
if (match?.[1]) {
values.push(match[1]);
}
}
return values;
};
const normalizedHint = hint.trim().toLowerCase();
for (const [id, meta] of Object.entries(payload)) {
const name = (meta.name ?? "").trim();
if (!name) {
continue;
}
let score = 0;
for (const alias of aliases(name.toLowerCase())) {
if (alias === normalizedHint) {
score = Math.max(score, 10);
} else if (normalizedHint && alias.includes(normalizedHint)) {
score = Math.max(score, 5 + normalizedHint.length / Math.max(alias.length, 1));
} else {
score = Math.max(score, stringSimilarity(normalizedHint, alias));
}
}
if ((meta.state ?? "").toLowerCase() === "poweroff") {
score += 0.5;
}
if (score > bestScore) {
bestScore = score;
best = { id, name, state: (meta.state ?? "").trim() };
}
}
if (!best) {
die("no snapshot matched");
}
return best;
}
export function stringSimilarity(a: string, b: string): number {
if (a === b) {
return 1;
}
const rows = a.length + 1;
const cols = b.length + 1;
const matrix = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
for (let i = 0; i < rows; i++) {
matrix[i][0] = i;
}
for (let j = 0; j < cols; j++) {
matrix[0][j] = j;
}
for (let i = 1; i < rows; i++) {
for (let j = 1; j < cols; j++) {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
const distance = matrix[a.length][b.length];
return 1 - distance / Math.max(a.length, b.length, 1);
}

View File

@@ -0,0 +1,46 @@
export type Provider = "openai" | "anthropic" | "minimax";
export type Mode = "fresh" | "upgrade" | "both";
export type Platform = "macos" | "windows" | "linux";
export interface CommandResult {
stdout: string;
stderr: string;
status: number;
}
export interface RunOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
input?: string;
timeoutMs?: number;
check?: boolean;
quiet?: boolean;
}
export interface ProviderAuth {
authChoice: string;
authKeyFlag: string;
apiKeyEnv: string;
apiKeyValue: string;
modelId: string;
}
export interface SnapshotInfo {
id: string;
state: string;
name: string;
}
export interface PackageArtifact {
path: string;
version?: string;
buildCommit?: string;
buildCommitShort?: string;
}
export interface HostServer {
hostIp: string;
port: number;
urlFor(filePath: string): string;
stop(): Promise<void>;
}

View File

@@ -0,0 +1,124 @@
import path from "node:path";
import type { WindowsGuest } from "./guest-transports.ts";
import { die, run, say } from "./host-command.ts";
import { psSingleQuote } from "./powershell.ts";
import type { HostServer } from "./types.ts";
export async function prepareMinGitZip(tgzDir: string): Promise<string> {
const metadata = run(
"python3",
[
"-c",
String.raw`import json
import urllib.request
req = urllib.request.Request(
"https://api.github.com/repos/git-for-windows/git/releases/latest",
headers={
"User-Agent": "openclaw-parallels-smoke",
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=30) as response:
data = json.load(response)
assets = data.get("assets", [])
preferred_names = [
"MinGit-2.53.0.2-arm64.zip",
"MinGit-2.53.0.2-64-bit.zip",
]
best = None
for wanted in preferred_names:
for asset in assets:
if asset.get("name") == wanted:
best = asset
break
if best:
break
if best is None:
candidates = []
for asset in assets:
name = asset.get("name", "")
if not (name.startswith("MinGit-") and name.endswith(".zip")):
continue
if "busybox" in name:
continue
if "-arm64." in name:
rank = 0
elif "-64-bit." in name:
rank = 1
elif "-32-bit." in name:
rank = 2
else:
rank = 3
candidates.append((rank, name, asset))
if candidates:
best = sorted(candidates, key=lambda item: (item[0], item[1]))[0][2]
if best is None:
raise SystemExit("no MinGit asset found")
print(best["name"])
print(best["browser_download_url"])`,
],
{ quiet: true },
).stdout.trim();
const [name, url] = metadata.split("\n");
if (!name || !url) {
die("failed to resolve MinGit download metadata");
}
const zipPath = path.join(tgzDir, name);
say(`Download ${name}`);
run("curl", [
"--retry",
"5",
"--retry-delay",
"3",
"--retry-all-errors",
"-fsSL",
url,
"-o",
zipPath,
]);
return zipPath;
}
export function ensureGuestGit(input: {
guest: WindowsGuest;
server: HostServer | null;
minGitZipPath: string;
}): void {
const existing = input.guest.exec(
["cmd.exe", "/d", "/s", "/c", "where git.exe && git.exe --version"],
{
check: false,
timeoutMs: 120_000,
},
);
if (existing.includes("git version")) {
return;
}
if (!input.server || !input.minGitZipPath) {
die("MinGit artifact/server missing");
}
const minGitUrl = input.server.urlFor(input.minGitZipPath);
const minGitName = path.basename(input.minGitZipPath);
input.guest.powershell(
`$ErrorActionPreference = 'Stop'
$depsRoot = Join-Path $env:LOCALAPPDATA 'OpenClaw\\deps'
$portableGit = Join-Path $depsRoot 'portable-git'
$archive = Join-Path $env:TEMP ${psSingleQuote(minGitName)}
if (Test-Path $portableGit) {
Remove-Item $portableGit -Recurse -Force
}
New-Item -ItemType Directory -Force -Path $portableGit | Out-Null
curl.exe -fsSL ${psSingleQuote(minGitUrl)} -o $archive
tar.exe -xf $archive -C $portableGit
Remove-Item $archive -Force -ErrorAction SilentlyContinue
$env:PATH = "$portableGit\\cmd;$portableGit\\mingw64\\bin;$portableGit\\usr\\bin;$env:PATH"
git.exe --version`,
{ timeoutMs: 1_200_000 },
);
}

View File

@@ -0,0 +1,782 @@
#!/usr/bin/env -S pnpm tsx
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
makeTempDir,
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
parseMode,
parseProvider,
resolveHostIp,
resolveHostPort,
resolveLatestVersion,
resolveProviderAuth,
resolveSnapshot,
run,
say,
startHostServer,
warn,
writeJson,
type HostServer,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
import { WindowsGuest } from "./guest-transports.ts";
import { PhaseRunner } from "./phase-runner.ts";
import { psArray, psSingleQuote } from "./powershell.ts";
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts";
interface WindowsOptions {
vmName: string;
snapshotHint: string;
mode: Mode;
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
hostPort: number;
hostPortExplicit: boolean;
hostIp?: string;
latestVersion?: string;
installVersion?: string;
targetPackageSpec?: string;
upgradeFromPackedMain: boolean;
skipLatestRefCheck: boolean;
keepServer: boolean;
json: boolean;
}
interface WindowsSummary {
vm: string;
snapshotHint: string;
snapshotId: string;
mode: Mode;
provider: Provider;
latestVersion: string;
installVersion: string;
targetPackageSpec: string;
currentHead: string;
runDir: string;
freshMain: {
status: string;
version: string;
gateway: string;
agent: string;
};
upgrade: {
precheck: string;
status: string;
latestVersionInstalled: string;
mainVersion: string;
gateway: string;
agent: string;
};
}
const defaultOptions = (): WindowsOptions => ({
hostIp: undefined,
hostPort: 18426,
hostPortExplicit: false,
installUrl: "https://openclaw.ai/install.ps1",
installVersion: "",
json: false,
keepServer: false,
latestVersion: "",
mode: "both",
modelId: undefined,
provider: "openai",
skipLatestRefCheck: false,
snapshotHint: "pre-openclaw-native-e2e-2026-03-12",
targetPackageSpec: "",
upgradeFromPackedMain: false,
vmName: "Windows 11",
});
function usage(): string {
return `Usage: bash scripts/e2e/parallels-windows-smoke.sh [options]
Options:
--vm <name> Parallels VM name. Default: "Windows 11"
--snapshot-hint <name> Snapshot name substring/fuzzy match.
Default: "pre-openclaw-native-e2e-2026-03-12"
--mode <fresh|upgrade|both>
--provider <openai|anthropic|minimax>
--model <provider/model> Override the model used for the agent-turn smoke.
--api-key-env <var> Host env var name for provider API key.
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.ps1
--host-port <port> Host HTTP port for current-main tgz. Default: 18426
--host-ip <ip> Override Parallels host IP.
--latest-version <ver> Override npm latest version lookup.
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
--upgrade-from-packed-main
Upgrade lane: install packed current-main npm tgz as baseline,
then run openclaw update --channel dev.
--target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main.
--skip-latest-ref-check Skip latest-release ref-mode precheck.
--keep-server Leave temp host HTTP server running.
--json Print machine-readable JSON summary.
-h, --help Show help.
`;
}
function parseArgs(argv: string[]): WindowsOptions {
const options = defaultOptions();
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "--":
break;
case "--vm":
options.vmName = ensureValue(argv, i, arg);
i++;
break;
case "--snapshot-hint":
options.snapshotHint = ensureValue(argv, i, arg);
i++;
break;
case "--mode":
options.mode = parseMode(ensureValue(argv, i, arg));
i++;
break;
case "--provider":
options.provider = parseProvider(ensureValue(argv, i, arg));
i++;
break;
case "--model":
options.modelId = ensureValue(argv, i, arg);
i++;
break;
case "--api-key-env":
case "--openai-api-key-env":
options.apiKeyEnv = ensureValue(argv, i, arg);
i++;
break;
case "--install-url":
options.installUrl = ensureValue(argv, i, arg);
i++;
break;
case "--host-port":
options.hostPort = Number(ensureValue(argv, i, arg));
options.hostPortExplicit = true;
i++;
break;
case "--host-ip":
options.hostIp = ensureValue(argv, i, arg);
i++;
break;
case "--latest-version":
options.latestVersion = ensureValue(argv, i, arg);
i++;
break;
case "--install-version":
options.installVersion = ensureValue(argv, i, arg);
i++;
break;
case "--upgrade-from-packed-main":
options.upgradeFromPackedMain = true;
break;
case "--target-package-spec":
options.targetPackageSpec = ensureValue(argv, i, arg);
i++;
break;
case "--skip-latest-ref-check":
options.skipLatestRefCheck = true;
break;
case "--keep-server":
options.keepServer = true;
break;
case "--json":
options.json = true;
break;
case "-h":
case "--help":
process.stdout.write(usage());
process.exit(0);
default:
die(`unknown arg: ${arg}`);
}
}
return options;
}
class WindowsSmoke {
private auth: ProviderAuth;
private hostIp = "";
private hostPort = 0;
private runDir = "";
private tgzDir = "";
private server: HostServer | null = null;
private artifact: PackageArtifact | null = null;
private minGitZipPath = "";
private latestVersion = "";
private installVersion = "";
private targetExpectVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: WindowsGuest;
private status = {
freshAgent: "skip",
freshGateway: "skip",
freshMain: "skip",
freshVersion: "skip",
latestInstalledVersion: "skip",
upgrade: "skip",
upgradeAgent: "skip",
upgradeGateway: "skip",
upgradePrecheck: "skip",
upgradeVersion: "skip",
};
constructor(private options: WindowsOptions) {
this.auth = resolveProviderAuth({
apiKeyEnv: options.apiKeyEnv,
modelId: options.modelId,
provider: options.provider,
});
}
async run(): Promise<void> {
this.runDir = await makeTempDir("openclaw-parallels-windows.");
this.phases = new PhaseRunner(this.runDir);
this.guest = new WindowsGuest(this.options.vmName, this.phases);
this.tgzDir = await makeTempDir("openclaw-parallels-windows-tgz.");
try {
this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
this.installVersion = this.options.installVersion || this.latestVersion;
this.hostIp = resolveHostIp(this.options.hostIp);
this.hostPort = await resolveHostPort(
this.options.hostPort,
this.options.hostPortExplicit,
defaultOptions().hostPort,
);
say(`VM: ${this.options.vmName}`);
say(`Snapshot hint: ${this.options.snapshotHint}`);
say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`);
say(`Latest npm version: ${this.latestVersion}`);
say(
`Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`,
);
say(`Run logs: ${this.runDir}`);
this.minGitZipPath = await prepareMinGitZip(this.tgzDir);
if (this.needsHostTgz()) {
this.artifact = await packOpenClaw({
destination: this.tgzDir,
packageSpec: this.options.targetPackageSpec,
requireControlUi: false,
});
if (this.options.targetPackageSpec) {
this.targetExpectVersion =
this.artifact.version || (await packageVersionFromTgz(this.artifact.path));
}
this.server = await startHostServer({
artifactPath: this.artifact.path,
dir: this.tgzDir,
hostIp: this.hostIp,
label: this.artifactLabel(),
port: this.hostPort,
});
this.hostPort = this.server.port;
}
if (!this.server) {
this.server = await startHostServer({
artifactPath: this.minGitZipPath,
dir: this.tgzDir,
hostIp: this.hostIp,
label: "Windows smoke artifacts",
port: this.hostPort,
});
this.hostPort = this.server.port;
}
if (this.options.mode === "fresh" || this.options.mode === "both") {
await this.runLane("fresh", async () => this.runFreshLane());
}
if (this.options.mode === "upgrade" || this.options.mode === "both") {
await this.runLane("upgrade", async () => this.runUpgradeLane());
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
this.printSummary(summaryPath);
}
if (this.status.freshMain === "fail" || this.status.upgrade === "fail") {
process.exitCode = 1;
}
} finally {
if (!this.options.keepServer) {
await this.server?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
}
}
private needsHostTgz(): boolean {
return (
this.options.mode === "fresh" ||
this.options.mode === "both" ||
this.options.upgradeFromPackedMain ||
Boolean(this.options.targetPackageSpec)
);
}
private artifactLabel(): string {
if (
!this.options.targetPackageSpec &&
this.options.mode === "upgrade" &&
!this.options.upgradeFromPackedMain
) {
return "Windows smoke artifacts";
}
if (this.options.targetPackageSpec) {
return "baseline package tgz";
}
if (this.options.upgradeFromPackedMain) {
return "packed main tgz";
}
return "current main tgz";
}
private upgradeSummaryLabel(): string {
if (this.options.targetPackageSpec) {
return "target-package->dev";
}
return this.options.upgradeFromPackedMain ? "packed-main->dev" : "latest->dev";
}
private async runLane(name: "fresh" | "upgrade", fn: () => Promise<void>): Promise<void> {
try {
await fn();
if (name === "fresh") {
this.status.freshMain = "pass";
} else {
this.status.upgrade = "pass";
}
} catch (error) {
if (name === "fresh") {
this.status.freshMain = "fail";
} else {
this.status.upgrade = "fail";
}
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("fresh.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("fresh.ensure-git", 1200, () =>
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
);
await this.phase("fresh.install-main", 420, () => this.installMain("openclaw-main-fresh.tgz"));
this.status.freshVersion = await this.extractLastVersion("fresh.install-main");
await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion());
await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard());
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.freshGateway = "pass";
await this.phase(
"fresh.first-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900),
() => this.verifyTurn(),
);
this.status.freshAgent = "pass";
}
private async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 240, () => this.restoreSnapshot());
await this.phase("upgrade.wait-for-user", 240, () => this.waitForGuestReady());
await this.phase("upgrade.ensure-git", 1200, () =>
ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }),
);
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
await this.phase("upgrade.install-baseline-package", 420, () =>
this.installMain("openclaw-main-upgrade.tgz"),
);
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline-package",
);
await this.phase("upgrade.verify-baseline-package-version", 120, () =>
this.verifyTargetVersion(),
);
} else {
await this.phase("upgrade.install-baseline", 420, () => this.installLatestRelease());
this.status.latestInstalledVersion = await this.extractLastVersion(
"upgrade.install-baseline",
);
await this.phase("upgrade.verify-baseline-version", 120, () =>
this.verifyVersionContains(this.installVersion),
);
}
if (this.options.skipLatestRefCheck) {
this.status.upgradePrecheck = "skipped";
} else if (
await this.phaseReturns("upgrade.latest-ref-precheck", 720, () =>
this.captureLatestRefFailure(),
)
) {
this.status.upgradePrecheck = "latest-ref-pass";
} else {
this.status.upgradePrecheck = "latest-ref-fail";
}
await this.phase(
"upgrade.update-dev",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200),
() => this.runDevChannelUpdate(),
);
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
await this.phase("upgrade.gateway-stop", 420, () => this.gatewayAction("stop"));
await this.phase("upgrade.onboard-ref", 720, () => this.runRefOnboard());
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
this.status.upgradeGateway = "pass";
await this.phase(
"upgrade.first-agent-turn",
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 900),
() => this.verifyTurn(),
);
this.status.upgradeAgent = "pass";
}
private async phase(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<void> {
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
return this.phases.remainingTimeoutMs(fallbackMs);
}
private async phaseReturns(
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> {
return await this.phases.phaseReturns(name, timeoutSeconds, fn);
}
private log(text: string): void {
this.phases.append(text);
}
private guestExec(args: string[], options: { check?: boolean; timeoutMs?: number } = {}): string {
return this.guest.exec(args, options);
}
private guestPowerShell(
script: string,
options: { check?: boolean; timeoutMs?: number } = {},
): string {
return this.guest.powershell(script, options);
}
private restoreSnapshot(): void {
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
});
if (this.snapshot.state === "poweroff") {
this.waitForVmStatus("stopped");
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
}
}
private waitForVmStatus(expected: string, timeoutSeconds = 240): 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(` ${expected}`)) {
return;
}
run("sleep", ["1"], { quiet: true });
}
throw new Error(`VM ${this.options.vmName} did not reach ${expected}`);
}
private waitForGuestReady(timeoutSeconds = 240): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const result = run(
"prlctl",
["exec", this.options.vmName, "--current-user", "cmd.exe", "/d", "/s", "/c", "echo ready"],
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
},
);
if (result.status === 0) {
return;
}
run("sleep", ["3"], { quiet: true });
}
throw new Error("Windows guest did not become ready");
}
private installLatestRelease(): void {
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
$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
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 420_000 },
);
}
private installMain(tempName: string): void {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
$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
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 420_000 },
);
}
private async verifyTargetVersion(): Promise<void> {
if (this.options.targetPackageSpec) {
this.verifyVersionContains(this.targetExpectVersion);
return;
}
if (!this.artifact) {
die("package artifact missing");
}
const commit =
this.artifact.buildCommitShort ||
(await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7);
this.verifyVersionContains(commit);
}
private verifyVersionContains(needle: string): void {
const version = this.guestPowerShell(
"& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') --version",
);
if (!version.includes(needle)) {
throw new Error(`version mismatch: expected substring ${needle}`);
}
}
private captureLatestRefFailure(): void {
this.runRefOnboard();
this.showGatewayStatusCompat();
}
private runRefOnboard(): void {
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
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
if ($LASTEXITCODE -ne 0) { throw "openclaw onboard failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 720_000 },
);
}
private runDevChannelUpdate(): void {
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
$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
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
& $openclaw update --channel dev --yes --json
if ($LASTEXITCODE -ne 0) { throw "openclaw update failed with exit code $LASTEXITCODE" }
& $openclaw --version
& $openclaw update status --json`,
{ timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200) * 1000 },
);
}
private verifyDevChannelUpdate(): void {
const status = this.guestPowerShell(
`$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`,
);
for (const needle of ['"installKind": "git"', '"value": "dev"', '"branch": "main"']) {
if (!status.includes(needle)) {
throw new Error(`dev update status missing ${needle}`);
}
}
}
private gatewayAction(action: "restart" | "stop"): void {
this.guestPowerShell(
`$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
& $openclaw gateway ${action}
if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTEXITCODE" }`,
{ timeoutMs: 420_000 },
);
}
private verifyGatewayReachable(): void {
const deadline = Date.now() + 420_000;
let attempt = 1;
let recoveryTried = false;
const recoveryAfter =
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S || 180) * 1000;
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",
{ check: false, timeoutMs: 60_000 },
);
if (/"ok"\s*:\s*true/.test(probe)) {
return;
}
if (!recoveryTried && Date.now() - start >= recoveryAfter) {
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", {
check: false,
timeoutMs: 120_000,
});
recoveryTried = true;
}
warn(`gateway-reachable retry ${attempt}`);
attempt++;
run("sleep", ["5"], { quiet: true });
}
throw new Error("gateway did not become reachable");
}
private showGatewayStatusCompat(): void {
const help = this.guestPowerShell(
"& (Join-Path $env:APPDATA 'npm\\openclaw.cmd') 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}`);
}
private verifyTurn(): void {
this.guestPowerShell(
`$openclaw = Join-Path $env:APPDATA 'npm\\openclaw.cmd'
& $openclaw models set ${psSingleQuote(this.auth.modelId)}
if ($LASTEXITCODE -ne 0) { throw "models set failed" }
& $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)}
$args = ${psArray([
"agent",
"--local",
"--agent",
"main",
"--session-id",
"parallels-windows-smoke",
"--message",
"Reply with exact ASCII text OK only.",
"--json",
])}
$output = & $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 },
);
}
private async extractLastVersion(phaseName: string): Promise<string> {
const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => "");
const matches = [...log.matchAll(/openclaw\s+([0-9][^\s]*)/g)];
return matches.at(-1)?.[1] ?? "";
}
private async writeSummary(): Promise<string> {
const summary: WindowsSummary = {
currentHead:
this.artifact?.buildCommitShort ||
run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(),
freshMain: {
agent: this.status.freshAgent,
gateway: this.status.freshGateway,
status: this.status.freshMain,
version: this.status.freshVersion,
},
installVersion: this.options.installVersion || "",
latestVersion: this.latestVersion,
mode: this.options.mode,
provider: this.options.provider,
runDir: this.runDir,
snapshotHint: this.options.snapshotHint,
snapshotId: this.snapshot.id,
targetPackageSpec: this.options.targetPackageSpec || "",
upgrade: {
agent: this.status.upgradeAgent,
gateway: this.status.upgradeGateway,
latestVersionInstalled: this.status.latestInstalledVersion,
mainVersion: this.status.upgradeVersion,
precheck: this.status.upgradePrecheck,
status: this.status.upgrade,
},
vm: this.options.vmName,
};
const summaryPath = path.join(this.runDir, "summary.json");
await writeJson(summaryPath, summary);
return summaryPath;
}
private printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
if (this.options.targetPackageSpec) {
process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`);
}
if (this.options.upgradeFromPackedMain) {
process.stdout.write(" upgrade-from-packed-main: yes\n");
}
if (this.options.installVersion) {
process.stdout.write(` baseline-install-version: ${this.options.installVersion}\n`);
}
process.stdout.write(` fresh-main: ${this.status.freshMain} (${this.status.freshVersion})\n`);
process.stdout.write(
` ${this.upgradeSummaryLabel()} precheck: ${this.status.upgradePrecheck} (${this.status.latestInstalledVersion})\n`,
);
process.stdout.write(
` ${this.upgradeSummaryLabel()}: ${this.status.upgrade} (${this.status.upgradeVersion})\n`,
);
process.stdout.write(` logs: ${this.runDir}\n`);
process.stdout.write(` summary: ${summaryPath}\n`);
}
}
await new WindowsSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => {
die(error instanceof Error ? error.message : String(error));
});