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